Compare commits

..

63 commits

Author SHA1 Message Date
8f34367d1a added incestoma compatibility 2024-04-02 19:13:05 +13:00
sam
5fcd87a94a
also add sharkey to the fediverse URL endpoint 2024-02-13 17:29:50 +01:00
sam
0633a32f64
Merge branch 'badeline/pronouns.cc:main' 2024-02-13 17:28:56 +01:00
sam
623cdb545e
Merge branch 'main' of https://codeberg.org/badeline/pronouns.cc into badeline-main 2024-02-13 17:28:43 +01:00
sam
4745a1c04b
feat: lazy load member avatars on user pages 2024-02-13 17:13:03 +01:00
badeline
4e78d36eff recognize sharkey as a misskey fork
untested but it will probably work(TM)
2024-01-17 17:54:15 +00:00
sam
31e1862ca9
format 2024-01-07 05:02:00 +01:00
sam
4308bd4d98
ci: run on all branches *except* stable 2024-01-07 04:04:41 +01:00
sam
40672d6d41
fix type error in frontend 2024-01-05 15:24:42 +01:00
sam
cfed74d6bf
Merge branch 'feature/preference-cheatsheet' 2024-01-05 15:13:06 +01:00
sam
b29a0c86db
only run ci on main [skip ci] 2023-12-31 15:14:50 +01:00
sam
1339550c80
fix: don't require a valid sentry dsn for the frontend 2023-12-30 15:41:53 +01:00
sam
55479ae8da
fix eslint errors 2023-12-30 15:33:03 +01:00
sam
ebc10d9558
chore: format 2023-12-30 15:14:01 +01:00
sam
ac603ac18e
fix(frontend): fix type errors 2023-12-30 15:13:24 +01:00
sam
00abe1cb32
fix: let users select the Google account to log in with every time 2023-12-30 04:41:22 +01:00
sam
c13c4e90b6
don't ignore errors in tx.Rollback() 2023-12-30 04:30:32 +01:00
sam
e37b5be376
add backend CI 2023-12-30 04:30:19 +01:00
sam
44b667ff43
add frontend CI 2023-12-30 02:52:31 +01:00
sam
e0ba5ea0dc
feat: add preference cheat sheet to bottom of user/member pages 2023-12-26 04:19:58 +01:00
sam
d559d1a036
chore: upgrade sveltekit to 2.0.0, upgrade svelte to 4.0.0 2023-12-26 01:34:53 +01:00
sam
34002e77d9
chore: update go dependencies 2023-10-28 01:04:20 +02:00
sam
97391c51d8
fix: disallow * in member names, it breaks routing 2023-10-28 00:58:20 +02:00
sam
65b171696a
add snowflake IDs to docs 2023-10-13 23:24:39 +02:00
sam
cb8cfb9d2f
fix(backend): add environment variable to disable tracing 2023-09-20 17:03:12 +02:00
sam
a297ec681e
fix(backend): tweak traces/profiles sample rate 2023-09-20 17:00:20 +02:00
sam
0e6f3a47f4
fix(backend): filter out context.Canceled errors 2023-09-20 15:15:43 +02:00
sam
fc1f4d03f1
i forgot to change the debug setting 2023-09-20 03:42:45 +02:00
sam
9f266ee1a8
feat(backend): also add sentry tracing 2023-09-20 03:40:07 +02:00
sam
b04ed68832
feat(backend): add sentry integration 2023-09-20 02:39:14 +02:00
sam
a6d31d150c
Merge branch 'stable' 2023-09-20 02:38:48 +02:00
sam
f424228fee
update icons.js 2023-09-20 02:29:06 +02:00
sam
bb64378c13
remove unfinished discord bot endpoints 2023-09-20 02:03:20 +02:00
sam
0022ae6112
update README + air config 2023-09-13 16:27:05 +02:00
sam
364c008554
chore: format 2023-09-13 16:25:40 +02:00
sam
4f62d8d589
merge: #100 feat(fields): improve error messages, switch to placeholder 2023-09-13 16:18:28 +02:00
sam
00d3f56f2e
Merge branch 'main' of https://codeberg.org/git_girl/pronouns.cc into git_girl-main 2023-09-13 16:05:09 +02:00
sam
636ee7369d
fix(frontend): make icon tooltips work again 2023-09-13 15:41:01 +02:00
git_girl
b6424cac9c feat(fields): improved error messages, switched to placeholder 2023-09-12 16:05:00 +02:00
sam
dd9c9c442c
fix(frontend): add screenreader/text-only labels for name/pronoun/field entries (fixes #98) 2023-09-11 22:11:22 +02:00
sam
467069c898
fix(frontend): make fediverse login modals forms (fixes #97) 2023-09-11 15:49:06 +02:00
sam
a1b2fce9af
fix(backend): invert error check in /api/v1/meta 2023-09-11 15:23:22 +02:00
sam
727848c801
update terms of service 2023-09-10 17:56:04 +02:00
sam
2da388df2e
add username cleanup 2023-09-10 17:44:35 +02:00
sam
153812d79f
add database seed from file 2023-09-10 16:49:16 +02:00
sam
bad1df395a
Merge branch 'feature/notices' 2023-09-09 17:21:45 +02:00
sam
f39a762072
add global notices 2023-09-09 17:20:18 +02:00
sam
e03c9827b9
readd rel and target attributes to profile links (fixes #93) 2023-09-09 13:41:56 +02:00
sam
cb563bc00b
remove debug prints 2023-09-09 04:45:04 +02:00
sam
c780470afe
move some settings to server side 2023-09-09 00:58:02 +02:00
sam
6c8f2b648e
merge branch 'feature/snowflakes' into main
NOTES:
- After running the migration, you MUST manually run `database
  create-snowflakes`. The entire backend assumes snowflakes are never
  null, so if this isn't done, all requests will fail.
- Avatar and flag files are still saved with xids, this will change
  later.
2023-09-07 17:04:39 +02:00
sam
b6cc5bb130
change frontend API calls to use snowflake IDs 2023-09-07 17:04:18 +02:00
sam
41f5d46891
add snowflake support to member reroll route 2023-09-07 17:01:31 +02:00
sam
04db0507ba
add snowflake support to report routes 2023-09-07 16:53:58 +02:00
sam
1b9a5deb78
make more member routes accept snowflakes + make flag routes accept snowflakes 2023-09-07 01:43:05 +02:00
sam
0171f54592
add snowflake support to some member routes 2023-09-02 16:34:51 +02:00
sam
b5a6d51491
remove OpenAPI spec as it's way out of date 2023-09-02 04:07:57 +02:00
sam
4377d38933
remove autogenerated docs, update API docs link 2023-09-02 04:07:36 +02:00
sam
58eff3ef4b
merge: merge docs 2023-09-02 03:53:08 +02:00
sam
0595e8d5f5
fix(frontend): fix 'sticky' info message on edit fields page 2023-08-20 22:50:23 +02:00
sam
1cce0defca
feat(backend): make snowflake IDs usable in /users/{id}, /users/{id}/members 2023-08-20 22:45:14 +02:00
sam
b1a7ef89ca
feat(backend): add snowflake IDs 2023-08-17 18:49:32 +02:00
sam
d97b3f8da1
feat(backend): add /api/v2/users/@me/settings 2023-08-17 00:49:46 +02:00
162 changed files with 9757 additions and 5059 deletions

43
.air.toml Normal file
View file

@ -0,0 +1,43 @@
root = "."
tmp_dir = "tmp"
[build]
args_bin = ["web"]
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ."
delay = 1000
exclude_dir = ["docs", "frontend", "prns", "pronounslib", "tmp", "target", "node_modules"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
time = false
[misc]
clean_on_exit = false
[screen]
clear_on_rebuild = false
keep_scroll = true

2
.gitignore vendored
View file

@ -12,3 +12,5 @@ package
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
target
tmp
seed.yaml

13
.woodpecker/.backend.yml Normal file
View file

@ -0,0 +1,13 @@
when:
branch:
exclude: stable
steps:
check:
image: golang:alpine
commands:
- apk update && apk add curl vips-dev build-base
- make backend
# Install golangci-lint
- curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.55.2
- golangci-lint run

20
.woodpecker/.frontend.yml Normal file
View file

@ -0,0 +1,20 @@
when:
branch:
exclude: stable
steps:
check:
image: node
directory: frontend
environment: # SvelteKit expects these in the environment during build time.
- PRIVATE_SENTRY_DSN=
- PUBLIC_BASE_URL=http://pronouns.localhost
- PUBLIC_MEDIA_URL=http://pronouns.localhost/media
- PUBLIC_SHORT_BASE=http://prns.localhost
- PUBLIC_HCAPTCHA_SITEKEY=non_existent_sitekey
commands:
- corepack enable
- pnpm install
- pnpm check
- pnpm lint
- pnpm build

View file

@ -2,7 +2,7 @@ all: generate backend frontend
.PHONY: backend
backend:
go build -v -o pronouns -ldflags="-buildid= -X codeberg.org/pronounscc/pronouns.cc/backend/server.Revision=`git rev-parse --short HEAD` -X codeberg.org/pronounscc/pronouns.cc/backend/server.Tag=`git describe --tags --long`" .
go build -v -o pronouns -ldflags="-buildid= -X codeberg.org/pronounscc/pronouns.cc/backend/server.Revision=`git rev-parse --short HEAD` -X codeberg.org/pronounscc/pronouns.cc/backend/server.Tag=`git describe --tags --long --always`" .
.PHONY: generate
generate:

View file

@ -26,19 +26,25 @@ Requirements:
- Redis 6.0 or later
- Node.js (latest version)
- MinIO **if using avatars, flags, or data exports** (_not_ required otherwise)
- [Air](https://github.com/cosmtrek/air) for live reloading the backend
### Setup
1. Create a PostgreSQL user and database (the user should own the database).
1. Create a PostgreSQL user and database (the user should own the database).
For example: `create user pronouns with password 'password'; create database pronouns with owner pronouns;`
2. Copy `.env.example` in the repository root to a new file named `.env` and fill out the required options.
3. Run `go run -v . database migrate` to initialize the database, then optionally `go run -v . database seed` to insert a test user.
4. Run `go run -v . web` to run the backend.
5. Copy `frontend/.env.example` into `frontend/.env` and tweak as necessary.
6. cd into the `frontend` directory and run `pnpm dev` to run the frontend.
3. Copy `frontend/.env.example` to `frontend/env` and fill out the required options.
4. Run `go run -v . database migrate` to initialize the database, then optionally `go run -v . database seed` to insert a test user.
5. Run `pnpm dev`. Alternatively, if you don't want the backend to live reload, run `go run -v . web`,
then change to the `frontend/` directory and run `pnpm dev`.
See [`docs/production.md`](/docs/production.md#configuration) for more information about keys in the backend and frontend `.env` files.
### Seeding
To seed the database with some data, create a `seed.yaml` file, then use `go run -v . database seed`.
For the file format, refer to the `Seed` struct in `scripts/seeddb`.
## License
Copyright (C) 2022 Sam <u1f320>

View file

@ -0,0 +1,65 @@
package common
import (
"math/rand"
"sync/atomic"
"time"
)
// Generator is a snowflake generator.
// For compatibility with other snowflake implementations, both worker and PID are set,
// but they are randomized for every generator.
type IDGenerator struct {
inc *uint64
worker, pid uint64
}
var defaultGenerator = NewIDGenerator(0, 0)
// NewIDGenerator creates a new ID generator with the given worker and pid.
// If worker or pid is empty, it will be set to a random number.
func NewIDGenerator(worker, pid uint64) *IDGenerator {
if worker == 0 {
worker = rand.Uint64()
}
if pid == 0 {
pid = rand.Uint64()
}
g := &IDGenerator{
inc: new(uint64),
worker: worker % 32,
pid: pid % 32,
}
return g
}
// GenerateID generates a new snowflake with the default generator.
// If you need to customize the worker and PID, manually call (*Generator).Generate.
func GenerateID() Snowflake {
return defaultGenerator.Generate()
}
// GenerateID generates a new snowflake with the given time with the default generator.
// If you need to customize the worker and PID, manually call (*Generator).GenerateWithTime.
func GenerateIDWithTime(t time.Time) Snowflake {
return defaultGenerator.GenerateWithTime(t)
}
// Generate generates a snowflake with the current time.
func (g *IDGenerator) Generate() Snowflake {
return g.GenerateWithTime(time.Now())
}
// GenerateWithTime generates a snowflake with the given time.
// To generate a snowflake for comparison, use the top-level New function instead.
func (g *IDGenerator) GenerateWithTime(t time.Time) Snowflake {
increment := atomic.AddUint64(g.inc, 1)
ts := uint64(t.UnixMilli() - Epoch)
worker := g.worker << 17
pid := g.pid << 12
return Snowflake(ts<<22 | worker | pid | (increment % 4096))
}

View file

@ -0,0 +1,83 @@
package common
import (
"strconv"
"strings"
"time"
)
// Epoch is the pronouns.cc epoch (January 1st 2022 at 00:00:00 UTC) in milliseconds.
const Epoch = 1_640_995_200_000
const epochDuration = Epoch * time.Millisecond
const NullSnowflake = ^Snowflake(0)
// Snowflake is a 64-bit integer used as a unique ID, with an embedded timestamp.
type Snowflake uint64
// ID is an alias to Snowflake.
type ID = Snowflake
// ParseSnowflake parses a snowflake from a string.
func ParseSnowflake(sf string) (Snowflake, error) {
if sf == "null" {
return NullSnowflake, nil
}
i, err := strconv.ParseUint(sf, 10, 64)
if err != nil {
return 0, err
}
return Snowflake(i), nil
}
// NewSnowflake creates a new snowflake from the given time.
func NewSnowflake(t time.Time) Snowflake {
ts := time.Duration(t.UnixNano()) - epochDuration
return Snowflake((ts / time.Millisecond) << 22)
}
// String returns the snowflake as a string.
func (s Snowflake) String() string { return strconv.FormatUint(uint64(s), 10) }
// Time returns the creation time of the snowflake.
func (s Snowflake) Time() time.Time {
ts := time.Duration(s>>22)*time.Millisecond + epochDuration
return time.Unix(0, int64(ts))
}
func (s Snowflake) IsValid() bool {
return s != 0 && s != NullSnowflake
}
func (s Snowflake) MarshalJSON() ([]byte, error) {
if !s.IsValid() {
return []byte("null"), nil
}
return []byte(`"` + strconv.FormatUint(uint64(s), 10) + `"`), nil
}
func (s *Snowflake) UnmarshalJSON(src []byte) error {
sf, err := ParseSnowflake(strings.Trim(string(src), `"`))
if err != nil {
return err
}
*s = sf
return nil
}
func (s Snowflake) Worker() uint8 {
return uint8(s & 0x3E0000 >> 17)
}
func (s Snowflake) PID() uint8 {
return uint8(s & 0x1F000 >> 12)
}
func (s Snowflake) Increment() uint16 {
return uint16(s & 0xFFF)
}

View file

@ -0,0 +1,39 @@
package common
import "time"
type UserID Snowflake
func (id UserID) String() string { return Snowflake(id).String() }
func (id UserID) Time() time.Time { return Snowflake(id).Time() }
func (id UserID) IsValid() bool { return Snowflake(id).IsValid() }
func (id UserID) Worker() uint8 { return Snowflake(id).Worker() }
func (id UserID) PID() uint8 { return Snowflake(id).PID() }
func (id UserID) Increment() uint16 { return Snowflake(id).Increment() }
func (id UserID) MarshalJSON() ([]byte, error) { return Snowflake(id).MarshalJSON() }
func (id *UserID) UnmarshalJSON(src []byte) error { return (*Snowflake)(id).UnmarshalJSON(src) }
type MemberID Snowflake
func (id MemberID) String() string { return Snowflake(id).String() }
func (id MemberID) Time() time.Time { return Snowflake(id).Time() }
func (id MemberID) IsValid() bool { return Snowflake(id).IsValid() }
func (id MemberID) Worker() uint8 { return Snowflake(id).Worker() }
func (id MemberID) PID() uint8 { return Snowflake(id).PID() }
func (id MemberID) Increment() uint16 { return Snowflake(id).Increment() }
func (id MemberID) MarshalJSON() ([]byte, error) { return Snowflake(id).MarshalJSON() }
func (id *MemberID) UnmarshalJSON(src []byte) error { return (*Snowflake)(id).UnmarshalJSON(src) }
type FlagID Snowflake
func (id FlagID) String() string { return Snowflake(id).String() }
func (id FlagID) Time() time.Time { return Snowflake(id).Time() }
func (id FlagID) IsValid() bool { return Snowflake(id).IsValid() }
func (id FlagID) Worker() uint8 { return Snowflake(id).Worker() }
func (id FlagID) PID() uint8 { return Snowflake(id).PID() }
func (id FlagID) Increment() uint16 { return Snowflake(id).Increment() }
func (id FlagID) MarshalJSON() ([]byte, error) { return Snowflake(id).MarshalJSON() }
func (id *FlagID) UnmarshalJSON(src []byte) error { return (*Snowflake)(id).UnmarshalJSON(src) }

View file

@ -79,7 +79,7 @@ func (db *DB) CreateExport(ctx context.Context, userID xid.ID, filename string,
return de, errors.Wrap(err, "building query")
}
pgxscan.Get(ctx, db, &de, sql, args...)
err = pgxscan.Get(ctx, db, &de, sql, args...)
if err != nil {
return de, errors.Wrap(err, "executing sql")
}

View file

@ -48,11 +48,11 @@ func (f FediverseApp) ClientConfig() *oauth2.Config {
}
func (f FediverseApp) MastodonCompatible() bool {
return f.InstanceType == "mastodon" || f.InstanceType == "pleroma" || f.InstanceType == "akkoma" || f.InstanceType == "pixelfed" || f.InstanceType == "gotosocial"
return f.InstanceType == "mastodon" || f.InstanceType == "pleroma" || f.InstanceType == "akkoma" || f.InstanceType == "incestoma" || f.InstanceType == "pixelfed" || f.InstanceType == "gotosocial"
}
func (f FediverseApp) Misskey() bool {
return f.InstanceType == "misskey" || f.InstanceType == "foundkey" || f.InstanceType == "calckey" || f.InstanceType == "firefish"
return f.InstanceType == "misskey" || f.InstanceType == "foundkey" || f.InstanceType == "calckey" || f.InstanceType == "firefish" || f.InstanceType == "sharkey"
}
const ErrNoInstanceApp = errors.Sentinel("instance doesn't have an app")

View file

@ -43,7 +43,10 @@ func (f Field) Validate(custom CustomPreferences) string {
}
if !entry.Status.Valid(custom) {
return fmt.Sprintf("entries.%d: status is invalid", i)
if entry.Status == "missing" {
return fmt.Sprintf("didn't select a status for entries.%d. make sure to select it to the right of the field", i)
}
return fmt.Sprintf("entries.%d status: '%s' is invalid", i, entry.Status)
}
}

View file

@ -9,6 +9,7 @@ import (
"io"
"strings"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors"
"github.com/davidbyttow/govips/v2/vips"
@ -20,11 +21,12 @@ import (
)
type PrideFlag struct {
ID xid.ID `json:"id"`
UserID xid.ID `json:"-"`
Hash string `json:"hash"`
Name string `json:"name"`
Description *string `json:"description"`
ID xid.ID `json:"id"`
SnowflakeID common.FlagID `json:"id_new"`
UserID xid.ID `json:"-"`
Hash string `json:"hash"`
Name string `json:"name"`
Description *string `json:"description"`
}
type UserFlag struct {
@ -194,11 +196,12 @@ func (db *DB) CreateFlag(ctx context.Context, tx pgx.Tx, userID xid.ID, name, de
sql, args, err := sq.Insert("pride_flags").
SetMap(map[string]any{
"id": xid.New(),
"hash": "",
"user_id": userID.String(),
"name": name,
"description": description,
"id": xid.New(),
"snowflake_id": common.GenerateID(),
"hash": "",
"user_id": userID.String(),
"name": name,
"description": description,
}).Suffix("RETURNING *").ToSql()
if err != nil {
return f, errors.Wrap(err, "building query")

View file

@ -6,6 +6,7 @@ import (
"encoding/base64"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors"
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
@ -43,7 +44,12 @@ func (db *DB) CreateInvite(ctx context.Context, userID xid.ID) (i Invite, err er
if err != nil {
return i, errors.Wrap(err, "beginning transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
var maxInvites, inviteCount int
err = tx.QueryRow(ctx, "SELECT max_invites FROM users WHERE id = $1", userID).Scan(&maxInvites)

View file

@ -6,6 +6,8 @@ import (
"strings"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors"
"github.com/Masterminds/squirrel"
"github.com/georgysavva/scany/v2/pgxscan"
@ -22,6 +24,7 @@ const (
type Member struct {
ID xid.ID
UserID xid.ID
SnowflakeID common.MemberID
SID string `db:"sid"`
Name string
DisplayName *string
@ -39,12 +42,14 @@ const (
)
// member names must match this regex
var memberNameRegex = regexp.MustCompile("^[^@\\?!#/\\\\[\\]\"\\{\\}'$%&()+<=>^|~`,]{1,100}$")
var memberNameRegex = regexp.MustCompile("^[^@\\?!#/\\\\[\\]\"\\{\\}'$%&()+<=>^|~`,\\*]{1,100}$")
// List of member names that cannot be used because they would break routing or be inaccessible due to page conflicts.
var invalidMemberNames = []string{
// these break routing outright
".",
"..",
// the user edit page lives at `/@{username}/edit`, so a member named "edit" would be inaccessible
"edit",
}
@ -71,9 +76,23 @@ func (db *DB) Member(ctx context.Context, id xid.ID) (m Member, err error) {
return m, nil
}
func (db *DB) MemberBySnowflake(ctx context.Context, id common.MemberID) (m Member, err error) {
sql, args, err := sq.Select("*").From("members").Where("snowflake_id = ?", id).ToSql()
if err != nil {
return m, errors.Wrap(err, "building sql")
}
err = pgxscan.Get(ctx, db, &m, sql, args...)
if err != nil {
return m, errors.Wrap(err, "executing query")
}
return m, nil
}
// UserMember returns a member scoped by user.
func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) (m Member, err error) {
sql, args, err := sq.Select("*").From("members").Where("user_id = ?", userID).Where("(id = ? or name = ?)", memberRef, memberRef).ToSql()
sf, _ := common.ParseSnowflake(memberRef) // error can be ignored as the zero value will never be used as an ID
sql, args, err := sq.Select("*").From("members").Where("user_id = ?", userID).Where("(id = ? or snowflake_id = ? or name = ?)", memberRef, sf, memberRef).ToSql()
if err != nil {
return m, errors.Wrap(err, "building sql")
}
@ -135,8 +154,8 @@ func (db *DB) CreateMember(
name string, displayName *string, bio string, links []string,
) (m Member, err error) {
sql, args, err := sq.Insert("members").
Columns("user_id", "id", "sid", "name", "display_name", "bio", "links").
Values(userID, xid.New(), squirrel.Expr("find_free_member_sid()"), name, displayName, bio, links).
Columns("user_id", "snowflake_id", "id", "sid", "name", "display_name", "bio", "links").
Values(userID, common.GenerateID(), xid.New(), squirrel.Expr("find_free_member_sid()"), name, displayName, bio, links).
Suffix("RETURNING *").ToSql()
if err != nil {
return m, errors.Wrap(err, "building sql")
@ -269,7 +288,12 @@ func (db *DB) RerollMemberSID(ctx context.Context, userID, memberID xid.ID) (new
if err != nil {
return "", errors.Wrap(err, "beginning transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
sql, args, err := sq.Update("members").
Set("sid", squirrel.Expr("find_free_member_sid()")).

66
backend/db/notice.go Normal file
View file

@ -0,0 +1,66 @@
package db
import (
"context"
"time"
"emperror.dev/errors"
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
)
type Notice struct {
ID int
Notice string
StartTime time.Time
EndTime time.Time
}
func (db *DB) Notices(ctx context.Context) (ns []Notice, err error) {
sql, args, err := sq.Select("*").From("notices").OrderBy("id DESC").ToSql()
if err != nil {
return nil, errors.Wrap(err, "building sql")
}
err = pgxscan.Select(ctx, db, &ns, sql, args...)
if err != nil {
return nil, errors.Wrap(err, "executing query")
}
return NotNull(ns), nil
}
func (db *DB) CreateNotice(ctx context.Context, notice string, start, end time.Time) (n Notice, err error) {
sql, args, err := sq.Insert("notices").SetMap(map[string]any{
"notice": notice,
"start_time": start,
"end_time": end,
}).Suffix("RETURNING *").ToSql()
if err != nil {
return n, errors.Wrap(err, "building sql")
}
err = pgxscan.Get(ctx, db, &n, sql, args...)
if err != nil {
return n, errors.Wrap(err, "executing query")
}
return n, nil
}
const ErrNoNotice = errors.Sentinel("no current notice")
func (db *DB) CurrentNotice(ctx context.Context) (n Notice, err error) {
sql, args, err := sq.Select("*").From("notices").Where("end_time > ?", time.Now()).OrderBy("id DESC").Limit(1).ToSql()
if err != nil {
return n, errors.Wrap(err, "building sql")
}
err = pgxscan.Get(ctx, db, &n, sql, args...)
if err != nil {
if errors.Cause(err) == pgx.ErrNoRows {
return n, ErrNoNotice
}
return n, errors.Wrap(err, "executing query")
}
return n, nil
}

View file

@ -22,6 +22,7 @@ import (
type User struct {
ID xid.ID
SnowflakeID common.UserID
SID string `db:"sid"`
Username string
DisplayName *string
@ -54,6 +55,7 @@ type User struct {
ListPrivate bool
LastSIDReroll time.Time `db:"last_sid_reroll"`
Timezone *string
Settings UserSettings
DeletedAt *time.Time
SelfDelete *bool
@ -205,7 +207,7 @@ func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u Use
return u, err
}
sql, args, err := sq.Insert("users").Columns("id", "username", "sid").Values(xid.New(), username, squirrel.Expr("find_free_user_sid()")).Suffix("RETURNING *").ToSql()
sql, args, err := sq.Insert("users").Columns("id", "snowflake_id", "username", "sid").Values(xid.New(), common.GenerateID(), username, squirrel.Expr("find_free_user_sid()")).Suffix("RETURNING *").ToSql()
if err != nil {
return u, errors.Wrap(err, "building sql")
}
@ -493,6 +495,26 @@ func (db *DB) User(ctx context.Context, id xid.ID) (u User, err error) {
return u, nil
}
// UserBySnowflake gets a user by their snowflake ID.
func (db *DB) UserBySnowflake(ctx context.Context, id common.UserID) (u User, err error) {
sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
From("users").Where("snowflake_id = ?", id).ToSql()
if err != nil {
return u, errors.Wrap(err, "building sql")
}
err = pgxscan.Get(ctx, db, &u, sql, args...)
if err != nil {
if errors.Cause(err) == pgx.ErrNoRows {
return u, ErrUserNotFound
}
return u, errors.Wrap(err, "getting user from db")
}
return u, nil
}
// Username gets a user by username.
func (db *DB) Username(ctx context.Context, name string) (u User, err error) {
sql, args, err := sq.Select("*").From("users").Where("username = ?", name).ToSql()
@ -803,3 +825,24 @@ func (db *DB) CleanUser(ctx context.Context, id xid.ID) error {
}
return nil
}
const inactiveUsersSQL = `select id, snowflake_id from users
where last_active < now() - '30 days'::interval
and display_name is null and bio is null and timezone is null
and links is null and avatar is null and member_title is null
and names = '[]' and pronouns = '[]'
and (select count(m.id) from members m where user_id = users.id) = 0
and (select count(f.id) from user_fields f where user_id = users.id) = 0;`
// InactiveUsers gets the list of inactive users from the database.
// "Inactive" is defined as:
// - not logged in for 30 days or more
// - no display name, bio, avatar, names, pronouns, profile links, or profile fields
// - no members
func (db *DB) InactiveUsers(ctx context.Context, tx pgx.Tx) (us []User, err error) {
err = pgxscan.Select(ctx, tx, &us, inactiveUsersSQL)
if err != nil {
return nil, errors.Wrap(err, "executing query")
}
return us, nil
}

View file

@ -0,0 +1,27 @@
package db
import (
"context"
"emperror.dev/errors"
"github.com/rs/xid"
)
type UserSettings struct {
ReadChangelog string `json:"read_changelog"`
ReadSettingsNotice string `json:"read_settings_notice"`
ReadGlobalNotice int `json:"read_global_notice"`
}
func (db *DB) UpdateUserSettings(ctx context.Context, id xid.ID, us UserSettings) error {
sql, args, err := sq.Update("users").Set("settings", us).Where("id = ?", id).ToSql()
if err != nil {
return errors.Wrap(err, "building sql")
}
_, err = db.Exec(ctx, sql, args...)
if err != nil {
return errors.Wrap(err, "executing query")
}
return nil
}

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"github.com/davidbyttow/govips/v2/vips"
"github.com/getsentry/sentry-go"
"github.com/go-chi/render"
_ "github.com/joho/godotenv/autoload"
"github.com/urfave/cli/v2"
@ -23,6 +24,19 @@ var Command = &cli.Command{
}
func run(c *cli.Context) error {
// initialize sentry
if dsn := os.Getenv("SENTRY_DSN"); dsn != "" {
// We don't need to check the error here--it's fine if no DSN is set.
_ = sentry.Init(sentry.ClientOptions{
Dsn: dsn,
Debug: os.Getenv("DEBUG") == "true",
Release: server.Tag,
EnableTracing: os.Getenv("SENTRY_TRACING") == "true",
TracesSampleRate: 0.05,
ProfilesSampleRate: 0.05,
})
}
// set vips log level to WARN, else it will spam logs on info level
vips.LoggingSettings(nil, vips.LogLevelWarning)
@ -62,9 +76,8 @@ func run(c *cli.Context) error {
return nil
case err := <-e:
log.Fatalf("Error running server: %v", err)
return err
}
return nil
}
const MaxContentLength = 2 * 1024 * 1024

File diff suppressed because one or more lines are too long

View file

@ -1,39 +1,30 @@
package backend
import (
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/auth"
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/bot"
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/member"
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/meta"
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/mod"
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/user"
user2 "codeberg.org/pronounscc/pronouns.cc/backend/routes/v2/user"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
_ "embed"
)
//go:embed openapi.html
var openapi string
// mountRoutes mounts all API routes on the server's router.
// they are all mounted under /v1/
func mountRoutes(s *server.Server) {
// future-proofing for API versions
s.Router.Route("/v1", func(r chi.Router) {
auth.Mount(s, r)
user.Mount(s, r)
member.Mount(s, r)
bot.Mount(s, r)
meta.Mount(s, r)
mod.Mount(s, r)
})
// API docs
s.Router.Get("/", func(w http.ResponseWriter, r *http.Request) {
render.HTML(w, r, openapi)
s.Router.Route("/v2", func(r chi.Router) {
user2.Mount(s, r)
})
}

View file

@ -11,6 +11,7 @@ import (
"emperror.dev/errors"
"github.com/bwmarrin/discordgo"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4"
"github.com/rs/xid"
"golang.org/x/oauth2"
@ -61,7 +62,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
// if the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil {
return err
return errors.Wrap(err, "validating state")
}
return server.APIError{Code: server.ErrInvalidState}
@ -79,7 +80,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
dg, _ := discordgo.New(token.Type() + " " + token.AccessToken)
du, err := dg.User("@me")
if err != nil {
return err
return errors.Wrap(err, "getting discord user")
}
u, err := s.DB.DiscordUser(ctx, du.ID)
@ -90,7 +91,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil {
log.Errorf("saving undelete token: %v", err)
return err
return errors.Wrap(err, "saving undelete token")
}
render.JSON(w, r, discordCallbackResponse{
@ -114,7 +115,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil {
return err
return errors.Wrap(err, "creating token")
}
// save token to database
@ -137,7 +138,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
return nil
} else if err != db.ErrUserNotFound { // internal error
return err
return errors.Wrap(err, "getting user")
}
// no user found, so save a ticket + save their Discord info in Redis
@ -145,7 +146,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetJSON(ctx, "discord:"+ticket, du, "EX", "600")
if err != nil {
log.Errorf("setting Discord user for ticket %q: %v", ticket, err)
return err
return errors.Wrap(err, "caching discord user for ticket")
}
render.JSON(w, r, discordCallbackResponse{
@ -278,7 +279,7 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil {
return err
return errors.Wrap(err, "checking if username is taken")
}
if !valid {
return server.APIError{Code: server.ErrInvalidUsername}
@ -291,7 +292,12 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
du := new(discordgo.User)
err = s.DB.GetJSON(ctx, "discord:"+req.Ticket, &du)

View file

@ -11,6 +11,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4"
"github.com/rs/xid"
)
@ -54,7 +55,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
// if the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil {
return err
return errors.Wrap(err, "validating state")
}
return server.APIError{Code: server.ErrInvalidState}
@ -111,7 +112,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil {
log.Errorf("saving undelete token: %v", err)
return err
return errors.Wrap(err, "saving undelete token")
}
render.JSON(w, r, fediCallbackResponse{
@ -135,7 +136,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil {
return err
return errors.Wrap(err, "creating token")
}
// save token to database
@ -158,7 +159,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
return nil
} else if err != db.ErrUserNotFound { // internal error
return err
return errors.Wrap(err, "getting user")
}
// no user found, so save a ticket + save their Mastodon info in Redis
@ -166,7 +167,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
err = s.DB.SetJSON(ctx, "mastodon:"+ticket, mu, "EX", "600")
if err != nil {
log.Errorf("setting mastoAPI user for ticket %q: %v", ticket, err)
return err
return errors.Wrap(err, "setting user for ticket")
}
render.JSON(w, r, fediCallbackResponse{
@ -306,7 +307,7 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil {
return err
return errors.Wrap(err, "checking if username is taken")
}
if !valid {
return server.APIError{Code: server.ErrInvalidUsername}
@ -319,7 +320,12 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
mu := new(partialMastodonAccount)
err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu)

View file

@ -12,6 +12,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4"
"github.com/rs/xid"
)
@ -90,7 +91,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil {
log.Errorf("saving undelete token: %v", err)
return err
return errors.Wrap(err, "saving undelete token")
}
render.JSON(w, r, fediCallbackResponse{
@ -114,7 +115,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil {
return err
return errors.Wrap(err, "creating token")
}
// save token to database
@ -137,7 +138,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
return nil
} else if err != db.ErrUserNotFound { // internal error
return err
return errors.Wrap(err, "getting user")
}
// no user found, so save a ticket + save their Misskey info in Redis
@ -145,7 +146,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetJSON(ctx, "misskey:"+ticket, mu.User, "EX", "600")
if err != nil {
log.Errorf("setting misskey user for ticket %q: %v", ticket, err)
return err
return errors.Wrap(err, "setting user for ticket")
}
render.JSON(w, r, fediCallbackResponse{
@ -234,7 +235,7 @@ func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil {
return err
return errors.Wrap(err, "checking if username is taken")
}
if !valid {
return server.APIError{Code: server.ErrInvalidUsername}
@ -247,7 +248,12 @@ func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error {
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
mu := new(partialMisskeyAccount)
err = s.DB.GetJSON(ctx, "misskey:"+req.Ticket, &mu)

View file

@ -65,13 +65,13 @@ func (s *Server) noAppFediverseURL(ctx context.Context, w http.ResponseWriter, r
}
switch softwareName {
case "misskey", "foundkey", "calckey", "firefish":
case "iceshrimp":
softwareName = "firefish"
fallthrough
case "misskey", "foundkey", "calckey", "firefish", "sharkey":
return s.noAppMisskeyURL(ctx, w, r, softwareName, instance)
case "mastodon", "pleroma", "akkoma", "pixelfed", "gotosocial":
case "mastodon", "pleroma", "akkoma", "incestoma", "pixelfed", "gotosocial":
case "glitchcafe", "hometown":
// plural.cafe (potentially other instances too?) runs Mastodon but changes the software name
// Hometown is a lightweight fork of Mastodon so we can just treat it the same
// changing it back to mastodon here for consistency
softwareName = "mastodon"
default:
return server.APIError{Code: server.ErrUnsupportedInstance}

View file

@ -10,6 +10,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4"
"github.com/rs/xid"
"golang.org/x/oauth2"
@ -60,7 +61,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
// if the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil {
return err
return errors.Wrap(err, "validating state")
}
return server.APIError{Code: server.ErrInvalidState}
@ -109,7 +110,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil {
log.Errorf("saving undelete token: %v", err)
return err
return errors.Wrap(err, "saving undelete token")
}
render.JSON(w, r, googleCallbackResponse{
@ -133,7 +134,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil {
return err
return errors.Wrap(err, "creating token")
}
// save token to database
@ -156,7 +157,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
return nil
} else if err != db.ErrUserNotFound { // internal error
return err
return errors.Wrap(err, "getting user")
}
// no user found, so save a ticket + save their Google info in Redis
@ -164,7 +165,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetJSON(ctx, "google:"+ticket, partialGoogleUser{ID: googleID, Email: googleUsername}, "EX", "600")
if err != nil {
log.Errorf("setting Google user for ticket %q: %v", ticket, err)
return err
return errors.Wrap(err, "setting user for ticket")
}
render.JSON(w, r, googleCallbackResponse{
@ -281,7 +282,7 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil {
return err
return errors.Wrap(err, "checking if username is taken")
}
if !valid {
return server.APIError{Code: server.ErrInvalidUsername}
@ -294,7 +295,12 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
gu := new(partialGoogleUser)
err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu)

View file

@ -4,6 +4,7 @@ import (
"net/http"
"os"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
@ -25,6 +26,7 @@ type Server struct {
type userResponse struct {
ID xid.ID `json:"id"`
SnowflakeID common.UserID `json:"id_new"`
Username string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
@ -51,6 +53,7 @@ type userResponse struct {
func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse {
return &userResponse{
ID: u.ID,
SnowflakeID: u.SnowflakeID,
Username: u.Username,
DisplayName: u.DisplayName,
Bio: u.Bio,
@ -182,7 +185,7 @@ func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error {
if googleOAuthConfig.ClientID != "" {
googleCfg := googleOAuthConfig
googleCfg.RedirectURL = req.CallbackDomain + "/auth/login/google"
resp.Google = googleCfg.AuthCodeURL(state)
resp.Google = googleCfg.AuthCodeURL(state) + "&prompt=select_account"
}
render.JSON(w, r, resp)

View file

@ -5,9 +5,11 @@ import (
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/rs/xid"
)
@ -63,7 +65,12 @@ func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) error {
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
err = s.DB.InvalidateAllTokens(ctx, tx, claims.UserID)
if err != nil {

View file

@ -12,6 +12,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4"
"github.com/rs/xid"
"golang.org/x/oauth2"
@ -77,7 +78,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
// if the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil {
return err
return errors.Wrap(err, "validating state")
}
return server.APIError{Code: server.ErrInvalidState}
@ -142,7 +143,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil {
log.Errorf("saving undelete token: %v", err)
return err
return errors.Wrap(err, "saving undelete token")
}
render.JSON(w, r, tumblrCallbackResponse{
@ -166,7 +167,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil {
return err
return errors.Wrap(err, "creating token")
}
// save token to database
@ -189,7 +190,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
return nil
} else if err != db.ErrUserNotFound { // internal error
return err
return errors.Wrap(err, "getting user")
}
// no user found, so save a ticket + save their Tumblr info in Redis
@ -197,7 +198,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetJSON(ctx, "tumblr:"+ticket, tumblrUserInfo{ID: tumblrID, Name: tumblrName}, "EX", "600")
if err != nil {
log.Errorf("setting Tumblr user for ticket %q: %v", ticket, err)
return err
return errors.Wrap(err, "setting user for ticket")
}
render.JSON(w, r, tumblrCallbackResponse{
@ -314,7 +315,7 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil {
return err
return errors.Wrap(err, "checking if username is taken")
}
if !valid {
return server.APIError{Code: server.ErrInvalidUsername}
@ -327,7 +328,12 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
tui := new(tumblrUserInfo)
err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui)

View file

@ -1,183 +0,0 @@
package bot
import (
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"github.com/bwmarrin/discordgo"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
type Bot struct {
*server.Server
publicKey ed25519.PublicKey
baseURL string
}
func (bot *Bot) UserAvatarURL(u db.User) string {
if u.Avatar == nil {
return ""
}
return bot.baseURL + "/media/users/" + u.ID.String() + "/" + *u.Avatar + ".webp"
}
func Mount(srv *server.Server, r chi.Router) {
publicKey, err := hex.DecodeString(os.Getenv("DISCORD_PUBLIC_KEY"))
if err != nil {
return
}
b := &Bot{
Server: srv,
publicKey: publicKey,
baseURL: os.Getenv("BASE_URL"),
}
r.HandleFunc("/interactions", b.handle)
}
func (bot *Bot) handle(w http.ResponseWriter, r *http.Request) {
if !discordgo.VerifyInteraction(r, bot.publicKey) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
var ev *discordgo.InteractionCreate
if err := json.NewDecoder(r.Body).Decode(&ev); err != nil {
http.Error(w, "Bad Request", http.StatusBadRequest)
}
// we can always respond to ping with pong
if ev.Type == discordgo.InteractionPing {
log.Debug("received ping interaction")
render.JSON(w, r, discordgo.InteractionResponse{
Type: discordgo.InteractionResponsePong,
})
return
}
if ev.Type != discordgo.InteractionApplicationCommand {
return
}
data := ev.ApplicationCommandData()
switch data.Name {
case "Show user's pronouns":
bot.userPronouns(w, r, ev)
case "Show author's pronouns":
}
}
func (bot *Bot) userPronouns(w http.ResponseWriter, r *http.Request, ev *discordgo.InteractionCreate) {
ctx := r.Context()
var du *discordgo.User
for _, user := range ev.ApplicationCommandData().Resolved.Users {
du = user
break
}
if du == nil {
return
}
u, err := bot.DB.DiscordUser(ctx, du.ID)
if err != nil {
if err == db.ErrUserNotFound {
respond(w, r, &discordgo.MessageEmbed{
Description: du.String() + " does not have any pronouns set.",
})
return
}
log.Errorf("getting discord user: %v", err)
return
}
avatarURL := du.AvatarURL("")
if url := bot.UserAvatarURL(u); url != "" {
avatarURL = url
}
name := u.Username
if u.DisplayName != nil {
name = fmt.Sprintf("%s (%s)", *u.DisplayName, u.Username)
}
url := bot.baseURL
if url != "" {
url += "/@" + u.Username
}
e := &discordgo.MessageEmbed{
Author: &discordgo.MessageEmbedAuthor{
Name: name,
IconURL: avatarURL,
URL: url,
},
}
if u.Bio != nil {
e.Fields = append(e.Fields, &discordgo.MessageEmbedField{
Name: "Bio",
Value: *u.Bio,
})
}
fields, err := bot.DB.UserFields(ctx, u.ID)
if err != nil {
respond(w, r, e)
log.Errorf("getting user fields: %v", err)
return
}
for _, field := range fields {
var favs []db.FieldEntry
for _, e := range field.Entries {
if e.Status == "favourite" {
favs = append(favs, e)
}
}
if len(favs) == 0 {
continue
}
var value string
for _, fav := range favs {
if len(fav.Value) > 500 {
break
}
value += fav.Value + "\n"
}
e.Fields = append(e.Fields, &discordgo.MessageEmbedField{
Name: field.Name,
Value: value,
Inline: true,
})
}
respond(w, r, e)
}
func respond(w http.ResponseWriter, r *http.Request, embeds ...*discordgo.MessageEmbed) {
render.JSON(w, r, discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Embeds: embeds,
Flags: discordgo.MessageFlagsEphemeral,
},
})
}

View file

@ -11,6 +11,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
)
type CreateMemberRequest struct {
@ -119,7 +120,12 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
if err != nil {
return errors.Wrap(err, "starting transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
m, err := s.DB.CreateMember(ctx, tx, claims.UserID, cmr.Name, cmr.DisplayName, cmr.Bio, cmr.Links)
if err != nil {
@ -127,14 +133,14 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
return server.APIError{Code: server.ErrMemberNameInUse}
}
return err
return errors.Wrap(err, "creating member")
}
// set names, pronouns, fields
err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, db.NotNull(cmr.Names), db.NotNull(cmr.Pronouns))
if err != nil {
log.Errorf("setting names and pronouns for member %v: %v", m.ID, err)
return err
return errors.Wrap(err, "setting names/pronouns")
}
m.Names = cmr.Names
m.Pronouns = cmr.Pronouns
@ -142,7 +148,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
err = s.DB.SetMemberFields(ctx, tx, m.ID, cmr.Fields)
if err != nil {
log.Errorf("setting fields for member %v: %v", m.ID, err)
return err
return errors.Wrap(err, "setting fields")
}
if cmr.Avatar != "" {
@ -161,13 +167,13 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
}
log.Errorf("converting member avatar: %v", err)
return err
return errors.Wrap(err, "converting avatar")
}
hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
if err != nil {
log.Errorf("uploading member avatar: %v", err)
return err
return errors.Wrap(err, "uploading avatar")
}
err = tx.QueryRow(ctx, "UPDATE members SET avatar = $1 WHERE id = $2", hash, m.ID).Scan(&m.Avatar)
@ -180,7 +186,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return err
return errors.Wrap(err, "updating last active time")
}
err = tx.Commit(ctx)

View file

@ -8,12 +8,13 @@ import (
"github.com/go-chi/render"
"github.com/rs/xid"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
)
func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) error {
func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) (err error) {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
@ -22,18 +23,27 @@ func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrMissingPermissions, Details: "this token is read-only"}
}
id, err := xid.FromString(chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{Code: server.ErrMemberNotFound}
}
var m db.Member
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
m, err = s.DB.Member(ctx, id)
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
m, err := s.DB.Member(ctx, id)
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
return errors.Wrap(err, "getting member")
}
} else if id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef")); err == nil {
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
return errors.Wrap(err, "getting member")
return errors.Wrap(err, "getting member")
}
} else {
return server.APIError{Code: server.ErrMemberNotFound}
}
if m.UserID != claims.UserID {
@ -56,7 +66,7 @@ func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) error {
err = s.DB.UpdateActiveTime(ctx, s.DB, claims.UserID)
if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return err
return errors.Wrap(err, "updating last active time")
}
render.NoContent(w, r)

View file

@ -4,7 +4,9 @@ import (
"context"
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
@ -13,13 +15,14 @@ import (
)
type GetMemberResponse struct {
ID xid.ID `json:"id"`
SID string `json:"sid"`
Name string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
Avatar *string `json:"avatar"`
Links []string `json:"links"`
ID xid.ID `json:"id"`
SnowflakeID common.MemberID `json:"id_new"`
SID string `json:"sid"`
Name string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
Avatar *string `json:"avatar"`
Links []string `json:"links"`
Names []db.FieldEntry `json:"names"`
Pronouns []db.PronounEntry `json:"pronouns"`
@ -34,6 +37,7 @@ type GetMemberResponse struct {
func dbMemberToMember(u db.User, m db.Member, fields []db.Field, flags []db.MemberFlag, isOwnMember bool) GetMemberResponse {
r := GetMemberResponse{
ID: m.ID,
SnowflakeID: m.SnowflakeID,
SID: m.SID,
Name: m.Name,
DisplayName: m.DisplayName,
@ -48,6 +52,7 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field, flags []db.Memb
User: PartialUser{
ID: u.ID,
SnowflakeID: u.SnowflakeID,
Username: u.Username,
DisplayName: u.DisplayName,
Avatar: u.Avatar,
@ -64,32 +69,43 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field, flags []db.Memb
type PartialUser struct {
ID xid.ID `json:"id"`
SnowflakeID common.UserID `json:"id_new"`
Username string `json:"name"`
DisplayName *string `json:"display_name"`
Avatar *string `json:"avatar"`
CustomPreferences db.CustomPreferences `json:"custom_preferences"`
}
func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error {
func (s *Server) getMember(w http.ResponseWriter, r *http.Request) (err error) {
ctx := r.Context()
id, err := xid.FromString(chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{
Code: server.ErrMemberNotFound,
var m db.Member
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
m, err = s.DB.Member(ctx, id)
if err != nil {
log.Errorf("getting member by xid: %v", err)
}
}
// xid was not valid
if !m.SnowflakeID.IsValid() {
id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{
Code: server.ErrMemberNotFound,
}
}
m, err := s.DB.Member(ctx, id)
if err != nil {
return server.APIError{
Code: server.ErrMemberNotFound,
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
if err != nil {
return server.APIError{
Code: server.ErrMemberNotFound,
}
}
}
u, err := s.DB.User(ctx, m.UserID)
if err != nil {
return err
return errors.Wrap(err, "getting user")
}
if u.DeletedAt != nil {
@ -103,12 +119,12 @@ func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error {
fields, err := s.DB.MemberFields(ctx, m.ID)
if err != nil {
return err
return errors.Wrap(err, "getting member fields")
}
flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil {
return err
return errors.Wrap(err, "getting member flags")
}
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember))
@ -143,12 +159,12 @@ func (s *Server) getUserMember(w http.ResponseWriter, r *http.Request) error {
fields, err := s.DB.MemberFields(ctx, m.ID)
if err != nil {
return err
return errors.Wrap(err, "getting member fields")
}
flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil {
return err
return errors.Wrap(err, "getting member flags")
}
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember))
@ -173,12 +189,12 @@ func (s *Server) getMeMember(w http.ResponseWriter, r *http.Request) error {
fields, err := s.DB.MemberFields(ctx, m.ID)
if err != nil {
return err
return errors.Wrap(err, "getting member fields")
}
flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil {
return err
return errors.Wrap(err, "getting member flags")
}
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true))
@ -186,12 +202,22 @@ func (s *Server) getMeMember(w http.ResponseWriter, r *http.Request) error {
}
func (s *Server) parseUser(ctx context.Context, userRef string) (u db.User, err error) {
if id, err := xid.FromString(userRef); err != nil {
// check xid first
if id, err := xid.FromString(userRef); err == nil {
u, err := s.DB.User(ctx, id)
if err == nil {
return u, nil
}
}
// if not an xid, check by snowflake
if id, err := common.ParseSnowflake(userRef); err == nil {
u, err := s.DB.UserBySnowflake(ctx, common.UserID(id))
if err == nil {
return u, nil
}
}
// else, use username
return s.DB.Username(ctx, userRef)
}

View file

@ -3,8 +3,10 @@ package member
import (
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
@ -12,6 +14,7 @@ import (
type memberListResponse struct {
ID xid.ID `json:"id"`
SnowflakeID common.MemberID `json:"id_new"`
SID string `json:"sid"`
Name string `json:"name"`
DisplayName *string `json:"display_name"`
@ -28,6 +31,7 @@ func membersToMemberList(ms []db.Member, isSelf bool) []memberListResponse {
for i := range ms {
resps[i] = memberListResponse{
ID: ms[i].ID,
SnowflakeID: ms[i].SnowflakeID,
SID: ms[i].SID,
Name: ms[i].Name,
DisplayName: ms[i].DisplayName,
@ -71,7 +75,7 @@ func (s *Server) getUserMembers(w http.ResponseWriter, r *http.Request) error {
ms, err := s.DB.UserMembers(ctx, u.ID, isSelf)
if err != nil {
return err
return errors.Wrap(err, "getting members")
}
render.JSON(w, r, membersToMemberList(ms, isSelf))
@ -84,7 +88,7 @@ func (s *Server) getMeMembers(w http.ResponseWriter, r *http.Request) error {
ms, err := s.DB.UserMembers(ctx, claims.UserID, true)
if err != nil {
return err
return errors.Wrap(err, "getting members")
}
render.JSON(w, r, membersToMemberList(ms, true))

View file

@ -13,6 +13,7 @@ import (
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/rs/xid"
)
@ -38,23 +39,37 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
id, err := xid.FromString(chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{Code: server.ErrMemberNotFound}
}
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting user")
}
m, err := s.DB.Member(ctx, id)
if err != nil {
if err == db.ErrMemberNotFound {
var m db.Member
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
log.Debugf("%v/%v is xid", chi.URLParam(r, "memberRef"), id)
m, err = s.DB.Member(ctx, id)
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
return errors.Wrap(err, "getting member")
}
} else {
id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{Code: server.ErrMemberNotFound}
}
return errors.Wrap(err, "getting member")
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
return errors.Wrap(err, "getting member")
}
}
if m.UserID != claims.UserID {
@ -206,13 +221,13 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
}
log.Errorf("converting member avatar: %v", err)
return err
return errors.Wrap(err, "converting member avatar")
}
hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
if err != nil {
log.Errorf("uploading member avatar: %v", err)
return err
return errors.Wrap(err, "writing member avatar")
}
avatarHash = &hash
@ -230,11 +245,16 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
tx, err := s.DB.Begin(ctx)
if err != nil {
log.Errorf("creating transaction: %v", err)
return err
return errors.Wrap(err, "creating transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
m, err = s.DB.UpdateMember(ctx, tx, id, req.Name, req.DisplayName, req.Bio, req.Unlisted, req.Links, avatarHash)
m, err = s.DB.UpdateMember(ctx, tx, m.ID, req.Name, req.DisplayName, req.Bio, req.Unlisted, req.Links, avatarHash)
if err != nil {
switch errors.Cause(err) {
case db.ErrNothingToUpdate:
@ -258,10 +278,10 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
pronouns = *req.Pronouns
}
err = s.DB.SetMemberNamesPronouns(ctx, tx, id, names, pronouns)
err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, names, pronouns)
if err != nil {
log.Errorf("setting names for member %v: %v", id, err)
return err
log.Errorf("setting names for member %v: %v", m.ID, err)
return errors.Wrap(err, "setting names/pronouns")
}
m.Names = names
m.Pronouns = pronouns
@ -269,17 +289,17 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
var fields []db.Field
if req.Fields != nil {
err = s.DB.SetMemberFields(ctx, tx, id, *req.Fields)
err = s.DB.SetMemberFields(ctx, tx, m.ID, *req.Fields)
if err != nil {
log.Errorf("setting fields for member %v: %v", id, err)
return err
log.Errorf("setting fields for member %v: %v", m.ID, err)
return errors.Wrap(err, "setting fields")
}
fields = *req.Fields
} else {
fields, err = s.DB.MemberFields(ctx, id)
fields, err = s.DB.MemberFields(ctx, m.ID)
if err != nil {
log.Errorf("getting fields for member %v: %v", id, err)
return err
log.Errorf("getting fields for member %v: %v", m.ID, err)
return errors.Wrap(err, "getting fields")
}
}
@ -292,7 +312,7 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
}
log.Errorf("updating flags for member %v: %v", m.ID, err)
return err
return errors.Wrap(err, "updating flags")
}
}
@ -300,20 +320,20 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return err
return errors.Wrap(err, "updating last active time")
}
err = tx.Commit(ctx)
if err != nil {
log.Errorf("committing transaction: %v", err)
return err
return errors.Wrap(err, "committing transaction")
}
// get flags to return (we need to return full flag objects, not the array of IDs in the request body)
flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil {
log.Errorf("getting user flags: %v", err)
return err
return errors.Wrap(err, "getting flags")
}
// echo the updated member back on success
@ -321,7 +341,7 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
return nil
}
func (s *Server) rerollMemberSID(w http.ResponseWriter, r *http.Request) error {
func (s *Server) rerollMemberSID(w http.ResponseWriter, r *http.Request) (err error) {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
@ -330,9 +350,32 @@ func (s *Server) rerollMemberSID(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
id, err := xid.FromString(chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{Code: server.ErrMemberNotFound}
var m db.Member
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
m, err = s.DB.Member(ctx, id)
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
log.Errorf("getting user %v: %v", id, err)
return errors.Wrap(err, "getting user")
}
} else {
id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{Code: server.ErrMemberNotFound}
}
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
log.Errorf("getting user %v: %v", id, err)
return errors.Wrap(err, "getting user")
}
}
u, err := s.DB.User(ctx, claims.UserID)
@ -340,15 +383,6 @@ func (s *Server) rerollMemberSID(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "getting user")
}
m, err := s.DB.Member(ctx, id)
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
return errors.Wrap(err, "getting member")
}
if m.UserID != claims.UserID {
return server.APIError{Code: server.ErrNotOwnMember}
}

View file

@ -4,6 +4,8 @@ import (
"net/http"
"os"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
@ -20,11 +22,17 @@ func Mount(srv *server.Server, r chi.Router) {
}
type MetaResponse struct {
GitRepository string `json:"git_repository"`
GitCommit string `json:"git_commit"`
Users MetaUsers `json:"users"`
Members int64 `json:"members"`
RequireInvite bool `json:"require_invite"`
GitRepository string `json:"git_repository"`
GitCommit string `json:"git_commit"`
Users MetaUsers `json:"users"`
Members int64 `json:"members"`
RequireInvite bool `json:"require_invite"`
Notice *MetaNotice `json:"notice"`
}
type MetaNotice struct {
ID int `json:"id"`
Notice string `json:"notice"`
}
type MetaUsers struct {
@ -39,6 +47,18 @@ func (s *Server) meta(w http.ResponseWriter, r *http.Request) error {
numUsers, numMembers, activeDay, activeWeek, activeMonth := s.DB.Counts(ctx)
var notice *MetaNotice
if n, err := s.DB.CurrentNotice(ctx); err != nil {
if err != db.ErrNoNotice {
log.Errorf("getting notice: %v", err)
}
} else {
notice = &MetaNotice{
ID: n.ID,
Notice: n.Notice,
}
}
render.JSON(w, r, MetaResponse{
GitRepository: server.Repository,
GitCommit: server.Revision,
@ -50,6 +70,7 @@ func (s *Server) meta(w http.ResponseWriter, r *http.Request) error {
},
Members: numMembers,
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
Notice: notice,
})
return nil
}

View file

@ -3,6 +3,7 @@ package mod
import (
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
@ -18,7 +19,7 @@ type CreateReportRequest struct {
Reason string `json:"reason"`
}
func (s *Server) createUserReport(w http.ResponseWriter, r *http.Request) error {
func (s *Server) createUserReport(w http.ResponseWriter, r *http.Request) (err error) {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
@ -26,19 +27,32 @@ func (s *Server) createUserReport(w http.ResponseWriter, r *http.Request) error
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
userID, err := xid.FromString(chi.URLParam(r, "id"))
if err != nil {
return server.APIError{Code: server.ErrBadRequest, Details: "Invalid user ID"}
}
var u db.User
if id, err := xid.FromString(chi.URLParam(r, "id")); err == nil {
u, err = s.DB.User(ctx, id)
if err != nil {
if err == db.ErrUserNotFound {
return server.APIError{Code: server.ErrUserNotFound}
}
u, err := s.DB.User(ctx, userID)
if err != nil {
if err == db.ErrUserNotFound {
log.Errorf("getting user %v: %v", id, err)
return errors.Wrap(err, "getting user")
}
} else {
id, err := common.ParseSnowflake(chi.URLParam(r, "id"))
if err != nil {
return server.APIError{Code: server.ErrUserNotFound}
}
log.Errorf("getting user %v: %v", userID, err)
return errors.Wrap(err, "getting user")
u, err = s.DB.UserBySnowflake(ctx, common.UserID(id))
if err != nil {
if err == db.ErrUserNotFound {
return server.APIError{Code: server.ErrUserNotFound}
}
log.Errorf("getting user %v: %v", id, err)
return errors.Wrap(err, "getting user")
}
}
if u.DeletedAt != nil {
@ -73,19 +87,32 @@ func (s *Server) createMemberReport(w http.ResponseWriter, r *http.Request) erro
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
memberID, err := xid.FromString(chi.URLParam(r, "id"))
if err != nil {
return server.APIError{Code: server.ErrBadRequest, Details: "Invalid member ID"}
}
var m db.Member
if id, err := xid.FromString(chi.URLParam(r, "id")); err == nil {
m, err = s.DB.Member(ctx, id)
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
m, err := s.DB.Member(ctx, memberID)
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
log.Errorf("getting user %v: %v", id, err)
return errors.Wrap(err, "getting user")
}
} else {
id, err := common.ParseSnowflake(chi.URLParam(r, "id"))
if err != nil {
return server.APIError{Code: server.ErrUserNotFound}
}
log.Errorf("getting member %v: %v", memberID, err)
return errors.Wrap(err, "getting member")
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
log.Errorf("getting user %v: %v", id, err)
return errors.Wrap(err, "getting user")
}
}
u, err := s.DB.User(ctx, m.UserID)

View file

@ -0,0 +1,55 @@
package mod
import (
"net/http"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/aarondl/opt/omit"
"github.com/go-chi/render"
)
type createNoticeRequest struct {
Notice string `json:"notice"`
Start omit.Val[time.Time] `json:"start"`
End time.Time `json:"end"`
}
type noticeResponse struct {
ID int `json:"id"`
Notice string `json:"notice"`
StartTime time.Time `json:"start"`
EndTime time.Time `json:"end"`
}
func (s *Server) createNotice(w http.ResponseWriter, r *http.Request) error {
var req createNoticeRequest
err := render.Decode(r, &req)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
if common.StringLength(&req.Notice) > 2000 {
return server.APIError{Code: server.ErrBadRequest, Details: "Notice is too long, max 2000 characters"}
}
start := req.Start.GetOr(time.Now())
if req.End.IsZero() {
return server.APIError{Code: server.ErrBadRequest, Details: "`end` is missing or invalid"}
}
n, err := s.DB.CreateNotice(r.Context(), req.Notice, start, req.End)
if err != nil {
return errors.Wrap(err, "creating notice")
}
render.JSON(w, r, noticeResponse{
ID: n.ID,
Notice: n.Notice,
StartTime: n.StartTime,
EndTime: n.EndTime,
})
return nil
}

View file

@ -10,6 +10,7 @@ import (
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
)
type resolveReportRequest struct {
@ -43,7 +44,12 @@ func (s *Server) resolveReport(w http.ResponseWriter, r *http.Request) error {
log.Errorf("creating transaction: %v", err)
return errors.Wrap(err, "creating transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
report, err := s.DB.Report(ctx, tx, id)
if err != nil {

View file

@ -22,6 +22,8 @@ func Mount(srv *server.Server, r chi.Router) {
r.Get("/reports/by-reporter/{id}", server.WrapHandler(s.getReportsByReporter))
r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport))
r.Post("/notices", server.WrapHandler(s.createNotice))
})
r.With(MustAdmin).Handle("/metrics", promhttp.Handler())

View file

@ -3,9 +3,11 @@ package user
import (
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
)
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error {
@ -20,7 +22,12 @@ func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error {
if err != nil {
return errors.Wrap(err, "creating transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
err = s.DB.DeleteUser(ctx, tx, claims.UserID, true, "")
if err != nil {

View file

@ -7,6 +7,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
)
@ -71,7 +72,7 @@ func (s *Server) getExport(w http.ResponseWriter, r *http.Request) error {
}
log.Errorf("getting export for user %v: %v", claims.UserID, err)
return err
return errors.Wrap(err, "getting export")
}
render.JSON(w, r, dataExportResponse{

View file

@ -1,6 +1,7 @@
package user
import (
"context"
"fmt"
"net/http"
"strings"
@ -12,6 +13,7 @@ import (
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/rs/xid"
)
@ -79,7 +81,12 @@ func (s *Server) postUserFlag(w http.ResponseWriter, r *http.Request) error {
if err != nil {
return errors.Wrap(err, "starting transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
flag, err := s.DB.CreateFlag(ctx, tx, claims.UserID, req.Name, req.Description)
if err != nil {
@ -121,6 +128,24 @@ type patchUserFlagRequest struct {
Description *string `json:"description"`
}
func (s *Server) parseFlag(ctx context.Context, flags []db.PrideFlag, flagRef string) (db.PrideFlag, bool) {
if id, err := xid.FromString(flagRef); err == nil {
for _, f := range flags {
if f.ID == id {
return f, true
}
}
}
if id, err := common.ParseSnowflake(flagRef); err == nil {
for _, f := range flags {
if f.SnowflakeID == common.FlagID(id) {
return f, true
}
}
}
return db.PrideFlag{}, false
}
func (s *Server) patchUserFlag(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
@ -129,28 +154,13 @@ func (s *Server) patchUserFlag(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
flagID, err := xid.FromString(chi.URLParam(r, "flagID"))
if err != nil {
return server.APIError{Code: server.ErrNotFound, Details: "Invalid flag ID"}
}
flags, err := s.DB.AccountFlags(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting current user flags")
}
if len(flags) >= db.MaxPrideFlags {
return server.APIError{
Code: server.ErrFlagLimitReached,
}
}
var found bool
for _, flag := range flags {
if flag.ID == flagID {
found = true
break
}
}
if !found {
flag, ok := s.parseFlag(ctx, flags, chi.URLParam(r, "flagID"))
if !ok {
return server.APIError{Code: server.ErrNotFound, Details: "No flag with that ID found"}
}
@ -188,9 +198,14 @@ func (s *Server) patchUserFlag(w http.ResponseWriter, r *http.Request) error {
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
flag, err := s.DB.EditFlag(ctx, tx, flagID, req.Name, req.Description, nil)
flag, err = s.DB.EditFlag(ctx, tx, flag.ID, req.Name, req.Description, nil)
if err != nil {
return errors.Wrap(err, "updating flag")
}
@ -212,19 +227,16 @@ func (s *Server) deleteUserFlag(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
flagID, err := xid.FromString(chi.URLParam(r, "flagID"))
flags, err := s.DB.AccountFlags(ctx, claims.UserID)
if err != nil {
return server.APIError{Code: server.ErrNotFound, Details: "Invalid flag ID"}
return errors.Wrap(err, "getting current user flags")
}
flag, err := s.DB.UserFlag(ctx, flagID)
if err != nil {
if err == db.ErrFlagNotFound {
return server.APIError{Code: server.ErrNotFound, Details: "Flag not found"}
}
return errors.Wrap(err, "getting flag object")
flag, ok := s.parseFlag(ctx, flags, chi.URLParam(r, "flagID"))
if !ok {
return server.APIError{Code: server.ErrNotFound, Details: "No flag with that ID found"}
}
if flag.UserID != claims.UserID {
return server.APIError{Code: server.ErrNotFound, Details: "Flag not found"}
}

View file

@ -4,9 +4,11 @@ import (
"net/http"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
@ -14,6 +16,7 @@ import (
type GetUserResponse struct {
ID xid.ID `json:"id"`
SnowflakeID common.UserID `json:"id_new"`
SID string `json:"sid"`
Username string `json:"name"`
DisplayName *string `json:"display_name"`
@ -58,6 +61,7 @@ type GetMeResponse struct {
type PartialMember struct {
ID xid.ID `json:"id"`
SnowflakeID common.MemberID `json:"id_new"`
SID string `json:"sid"`
Name string `json:"name"`
DisplayName *string `json:"display_name"`
@ -71,6 +75,7 @@ type PartialMember struct {
func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags []db.UserFlag) GetUserResponse {
resp := GetUserResponse{
ID: u.ID,
SnowflakeID: u.SnowflakeID,
SID: u.SID,
Username: u.Username,
DisplayName: u.DisplayName,
@ -97,6 +102,7 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags [
for i := range members {
resp.Members[i] = PartialMember{
ID: members[i].ID,
SnowflakeID: members[i].SnowflakeID,
SID: members[i].SID,
Name: members[i].Name,
DisplayName: members[i].DisplayName,
@ -124,6 +130,15 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) {
}
}
if u.ID.IsNil() {
if id, err := common.ParseSnowflake(userRef); err == nil {
u, err = s.DB.UserBySnowflake(ctx, common.UserID(id))
if err != nil {
log.Errorf("getting user by snowflake: %v", err)
}
}
}
if u.ID.IsNil() {
u, err = s.DB.Username(ctx, userRef)
if err == db.ErrUserNotFound {
@ -132,7 +147,7 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) {
}
} else if err != nil {
log.Errorf("Error getting user by username: %v", err)
return err
return errors.Wrap(err, "getting user")
}
}
@ -148,13 +163,13 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) {
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
log.Errorf("Error getting user fields: %v", err)
return err
return errors.Wrap(err, "getting fields")
}
flags, err := s.DB.UserFlags(ctx, u.ID)
if err != nil {
log.Errorf("getting user flags: %v", err)
return err
return errors.Wrap(err, "getting flags")
}
var members []db.Member
@ -162,7 +177,7 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) {
members, err = s.DB.UserMembers(ctx, u.ID, isSelf)
if err != nil {
log.Errorf("Error getting user members: %v", err)
return err
return errors.Wrap(err, "getting user members")
}
}
@ -177,25 +192,25 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error {
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
log.Errorf("Error getting user: %v", err)
return err
return errors.Wrap(err, "getting users")
}
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
log.Errorf("Error getting user fields: %v", err)
return err
return errors.Wrap(err, "getting fields")
}
members, err := s.DB.UserMembers(ctx, u.ID, true)
if err != nil {
log.Errorf("Error getting user members: %v", err)
return err
return errors.Wrap(err, "getting members")
}
flags, err := s.DB.UserFlags(ctx, u.ID)
if err != nil {
log.Errorf("getting user flags: %v", err)
return err
return errors.Wrap(err, "getting flags")
}
render.JSON(w, r, GetMeResponse{

View file

@ -12,6 +12,7 @@ import (
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/rs/xid"
)
@ -195,13 +196,13 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
}
log.Errorf("converting user avatar: %v", err)
return err
return errors.Wrap(err, "converting avatar")
}
hash, err := s.DB.WriteUserAvatar(ctx, claims.UserID, webp, jpg)
if err != nil {
log.Errorf("uploading user avatar: %v", err)
return err
return errors.Wrap(err, "uploading avatar")
}
avatarHash = &hash
@ -219,9 +220,14 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
tx, err := s.DB.Begin(ctx)
if err != nil {
log.Errorf("creating transaction: %v", err)
return err
return errors.Wrap(err, "creating transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
// update username
if req.Username != nil && *req.Username != u.Username {
@ -243,7 +249,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash, req.Timezone, req.CustomPreferences)
if err != nil && errors.Cause(err) != db.ErrNothingToUpdate {
log.Errorf("updating user: %v", err)
return err
return errors.Wrap(err, "updating user")
}
if req.Names != nil || req.Pronouns != nil {
@ -260,7 +266,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetUserNamesPronouns(ctx, tx, claims.UserID, names, pronouns)
if err != nil {
log.Errorf("setting names for member %v: %v", claims.UserID, err)
return err
return errors.Wrap(err, "setting names/pronouns")
}
u.Names = names
u.Pronouns = pronouns
@ -271,14 +277,14 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetUserFields(ctx, tx, claims.UserID, *req.Fields)
if err != nil {
log.Errorf("setting fields for user %v: %v", claims.UserID, err)
return err
return errors.Wrap(err, "setting fields")
}
fields = *req.Fields
} else {
fields, err = s.DB.UserFields(ctx, claims.UserID)
if err != nil {
log.Errorf("getting fields for user %v: %v", claims.UserID, err)
return err
return errors.Wrap(err, "getting fields")
}
}
@ -291,7 +297,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
}
log.Errorf("updating flags for user %v: %v", claims.UserID, err)
return err
return errors.Wrap(err, "updating flags")
}
}
@ -299,13 +305,13 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return err
return errors.Wrap(err, "updating last active time")
}
err = tx.Commit(ctx)
if err != nil {
log.Errorf("committing transaction: %v", err)
return err
return errors.Wrap(err, "committing transaction")
}
// get fedi instance name if the user has a linked fedi account
@ -321,7 +327,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
flags, err := s.DB.UserFlags(ctx, u.ID)
if err != nil {
log.Errorf("getting user flags: %v", err)
return err
return errors.Wrap(err, "getting flags")
}
// echo the updated user back on success

View file

@ -0,0 +1,22 @@
package user
import (
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
)
func (s *Server) GetSettings(w http.ResponseWriter, r *http.Request) (err error) {
claims, _ := server.ClaimsFromContext(r.Context())
u, err := s.DB.User(r.Context(), claims.UserID)
if err != nil {
log.Errorf("getting user: %v", err)
return errors.Wrap(err, "getting user")
}
render.JSON(w, r, u.Settings)
return nil
}

View file

@ -0,0 +1,49 @@
package user
import (
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/aarondl/opt/omitnull"
"github.com/go-chi/render"
)
type PatchSettingsRequest struct {
ReadChangelog omitnull.Val[string] `json:"read_changelog"`
ReadSettingsNotice omitnull.Val[string] `json:"read_settings_notice"`
ReadGlobalNotice omitnull.Val[int] `json:"read_global_notice"`
}
func (s *Server) PatchSettings(w http.ResponseWriter, r *http.Request) (err error) {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting user")
}
var req PatchSettingsRequest
err = render.Decode(r, &req)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
if !req.ReadChangelog.IsUnset() {
u.Settings.ReadChangelog = req.ReadChangelog.GetOrZero()
}
if !req.ReadSettingsNotice.IsUnset() {
u.Settings.ReadSettingsNotice = req.ReadSettingsNotice.GetOrZero()
}
if !req.ReadGlobalNotice.IsUnset() {
u.Settings.ReadGlobalNotice = req.ReadGlobalNotice.GetOrZero()
}
err = s.DB.UpdateUserSettings(ctx, u.ID, u.Settings)
if err != nil {
return errors.Wrap(err, "updating user settings")
}
render.JSON(w, r, u.Settings)
return nil
}

View file

@ -0,0 +1,23 @@
package user
import (
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5"
)
type Server struct {
*server.Server
}
func Mount(srv *server.Server, r chi.Router) {
s := &Server{
Server: srv,
}
r.Route("/users", func(r chi.Router) {
r.With(server.MustAuth).Group(func(r chi.Router) {
r.Get("/@me/settings", server.WrapHandler(s.GetSettings))
r.Patch("/@me/settings", server.WrapHandler(s.PatchSettings))
})
})
}

View file

@ -1,10 +1,14 @@
package server
import (
"context"
"fmt"
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors"
"github.com/getsentry/sentry-go"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
@ -12,6 +16,11 @@ import (
// The inner HandlerFunc additionally returns an error.
func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
hub := sentry.GetHubFromContext(r.Context())
if hub == nil {
hub = sentry.CurrentHub().Clone()
}
err := hn(w, r)
if err != nil {
// if the function returned an API error, just render that verbatim
@ -24,10 +33,20 @@ func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.Han
return
}
// otherwise, we log the error and return an internal server error message
log.Errorf("error in http handler: %v", err)
rctx := chi.RouteContext(r.Context())
hub.ConfigureScope(func(scope *sentry.Scope) {
scope.SetTag("method", rctx.RouteMethod)
scope.SetTag("path", rctx.RoutePattern())
})
apiErr := APIError{Code: ErrInternalServerError}
var eventID *sentry.EventID = nil
if isExpectedError(err) {
log.Infof("expected error in handler for %v %v, ignoring", rctx.RouteMethod, rctx.RoutePattern())
} else {
log.Errorf("error in handler for %v %v: %v", rctx.RouteMethod, rctx.RoutePattern(), err)
eventID = hub.CaptureException(err)
}
apiErr := APIError{ID: eventID, Code: ErrInternalServerError}
apiErr.prepare()
render.Status(r, apiErr.Status)
@ -36,12 +55,17 @@ func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.Han
}
}
func isExpectedError(err error) bool {
return errors.Is(err, context.Canceled)
}
// APIError is an object returned by the API when an error occurs.
// It implements the error interface and can be returned by handlers.
type APIError struct {
Code int `json:"code"`
Message string `json:"message,omitempty"`
Details string `json:"details,omitempty"`
Code int `json:"code"`
ID *sentry.EventID `json:"id,omitempty"`
Message string `json:"message,omitempty"`
Details string `json:"details,omitempty"`
RatelimitReset *int `json:"ratelimit_reset,omitempty"`

89
backend/server/sentry.go Normal file
View file

@ -0,0 +1,89 @@
package server
import (
"context"
"fmt"
"net/http"
"github.com/getsentry/sentry-go"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func (s *Server) sentry(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
ctx := r.Context()
hub := sentry.GetHubFromContext(ctx)
if hub == nil {
hub = sentry.CurrentHub().Clone()
ctx = sentry.SetHubOnContext(ctx, hub)
}
options := []sentry.SpanOption{
sentry.WithOpName("http.server"),
sentry.ContinueFromRequest(r),
sentry.WithTransactionSource(sentry.SourceURL),
}
// We don't mind getting an existing transaction back so we don't need to
// check if it is.
transaction := sentry.StartTransaction(ctx,
fmt.Sprintf("%s %s", r.Method, r.URL.Path),
options...,
)
defer transaction.Finish()
r = r.WithContext(transaction.Context())
hub.Scope().SetRequest(r)
defer recoverWithSentry(hub, r)
handler.ServeHTTP(ww, r)
transaction.Status = httpStatusToSentryStatus(ww.Status())
rctx := chi.RouteContext(r.Context())
transaction.Name = rctx.RouteMethod + " " + rctx.RoutePattern()
})
}
func recoverWithSentry(hub *sentry.Hub, r *http.Request) {
if err := recover(); err != nil {
hub.RecoverWithContext(
context.WithValue(r.Context(), sentry.RequestContextKey, r),
err,
)
}
}
func httpStatusToSentryStatus(status int) sentry.SpanStatus {
// c.f. https://develop.sentry.dev/sdk/event-payloads/span/
if status >= 200 && status < 400 {
return sentry.SpanStatusOK
}
switch status {
case 499:
return sentry.SpanStatusCanceled
case 500:
return sentry.SpanStatusInternalError
case 400:
return sentry.SpanStatusInvalidArgument
case 504:
return sentry.SpanStatusDeadlineExceeded
case 404:
return sentry.SpanStatusNotFound
case 409:
return sentry.SpanStatusAlreadyExists
case 403:
return sentry.SpanStatusPermissionDenied
case 429:
return sentry.SpanStatusResourceExhausted
case 501:
return sentry.SpanStatusUnimplemented
case 503:
return sentry.SpanStatusUnavailable
case 401:
return sentry.SpanStatusUnauthenticated
default:
return sentry.SpanStatusUnknown
}
}

View file

@ -50,6 +50,9 @@ func New() (*Server, error) {
s.Router.Use(middleware.Logger)
}
s.Router.Use(middleware.Recoverer)
// add Sentry tracing handler
s.Router.Use(s.sentry)
// add CORS
s.Router.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"https://*", "http://*"},
@ -97,23 +100,23 @@ func New() (*Server, error) {
// set scopes
// users
rateLimiter.Scope("GET", "/users/*", 60)
rateLimiter.Scope("PATCH", "/users/@me", 10)
_ = rateLimiter.Scope("GET", "/users/*", 60)
_ = rateLimiter.Scope("PATCH", "/users/@me", 10)
// members
rateLimiter.Scope("GET", "/users/*/members", 60)
rateLimiter.Scope("GET", "/users/*/members/*", 60)
_ = rateLimiter.Scope("GET", "/users/*/members", 60)
_ = rateLimiter.Scope("GET", "/users/*/members/*", 60)
rateLimiter.Scope("POST", "/members", 10)
rateLimiter.Scope("GET", "/members/*", 60)
rateLimiter.Scope("PATCH", "/members/*", 20)
rateLimiter.Scope("DELETE", "/members/*", 5)
_ = rateLimiter.Scope("POST", "/members", 10)
_ = rateLimiter.Scope("GET", "/members/*", 60)
_ = rateLimiter.Scope("PATCH", "/members/*", 20)
_ = rateLimiter.Scope("DELETE", "/members/*", 5)
// auth
rateLimiter.Scope("*", "/auth/*", 20)
rateLimiter.Scope("*", "/auth/tokens", 10)
rateLimiter.Scope("*", "/auth/invites", 10)
rateLimiter.Scope("POST", "/auth/discord/*", 10)
_ = rateLimiter.Scope("*", "/auth/*", 20)
_ = rateLimiter.Scope("*", "/auth/tokens", 10)
_ = rateLimiter.Scope("*", "/auth/invites", 10)
_ = rateLimiter.Scope("POST", "/auth/discord/*", 10)
s.Router.Use(rateLimiter.Handler())

View file

@ -45,9 +45,10 @@ A user can set custom word preferences, which can have custom icons and tooltips
## Pride flag
| Field | Type | Description |
| ----------- | ------- | ------------------------------------- |
| id | string | the flag's unique ID |
| hash | string | the flag's [image hash](/api/#images) |
| name | string | the flag's name |
| description | string? | the flag's description or alt text |
| Field | Type | Description |
| ----------- | --------- | ------------------------------------- |
| id | string | the flag's unique ID |
| id_new | snowflake | the flag's unique snowflake ID |
| hash | string | the flag's [image hash](/api/#images) |
| name | string | the flag's name |
| description | string? | the flag's description or alt text |

View file

@ -5,6 +5,7 @@
| Field | Type | Description |
| ------------ | ---------------------------------------------------- | --------------------------------------------------------------------------------- |
| id | string | the member's unique ID |
| id_new | snowflake | the member's unique snowflake ID |
| sid | string | the member's 6-letter short ID |
| name | string | the member's name |
| display_name | string? | the member's display name or nickname |
@ -23,6 +24,7 @@
| Field | Type | Description |
| ------------------ | ---------------------------------------------------- | -------------------------------------- |
| id | string | the user's unique ID |
| id_new | snowflake | the user's unique snowflake ID |
| name | string | the user's username |
| display_name | string? | the user's display name or nickname |
| avatar | string? | the user's [avatar hash](/api/#images) |
@ -95,18 +97,18 @@ Returns the updated [member](./members#member-object) on success.
#### Request body parameters
| Field | Type | Description |
| ------------------ | -------------------- | --------------------------------------------------------------------------------------------------- |
| name | string | the member's new name. Must be unique per user, and be between 1 and 100 characters. |
| display_name | string | the member's new display name. Must be between 1 and 100 characters |
| bio | string | the member's new bio. Must be between 1 and 1000 characters |
| links | string[] | the member's new profile links. Maximum 25 links, and links must be between 1 and 256 characters |
| names | field_entry[] | the member's new preferred names |
| pronouns | pronoun_entry[] | the member's new preferred pronouns |
| fields | field[] | the member's new profile fields |
| flags | string[] | the member's new flags. This must be an array of [pride flag](./#pride-flag) IDs. |
| avatar | string | the member's new avatar. This must be a PNG, JPEG, or WebP image, encoded in base64 data URI format |
| unlisted | bool | whether or not the member should be hidden from the member list |
| Field | Type | Description |
| ------------ | --------------- | ------------------------------------------------------------------------------------------------------ |
| name | string | the member's new name. Must be unique per user, and be between 1 and 100 characters. |
| display_name | string | the member's new display name. Must be between 1 and 100 characters |
| bio | string | the member's new bio. Must be between 1 and 1000 characters |
| links | string[] | the member's new profile links. Maximum 25 links, and links must be between 1 and 256 characters |
| names | field_entry[] | the member's new preferred names |
| pronouns | pronoun_entry[] | the member's new preferred pronouns |
| fields | field[] | the member's new profile fields |
| flags | string[] | the member's new flags. This must be an array of [pride flag](./#pride-flag) IDs, _not_ snowflake IDs. |
| avatar | string | the member's new avatar. This must be a PNG, JPEG, or WebP image, encoded in base64 data URI format |
| unlisted | bool | whether or not the member should be hidden from the member list |
### Delete member

View file

@ -5,6 +5,7 @@
| Field | Type | Description |
| ------------------ | ---------------------------------------------------- | --------------------------------------------------------------------------- |
| id | string | the user's unique ID |
| id_new | snowflake | the user's unique snowflake ID |
| sid | string | the user's 5 letter short ID |
| name | string | the user's username |
| display_name | string? | the user's display name or nickname |
@ -45,6 +46,7 @@
| Field | Type | Description |
| ------------ | ----------------------------------- | ---------------------------------------- |
| id | string | the member's unique ID |
| id_new | snowflake | the member's unique snowflake ID |
| sid | string | the member's 6-letter short ID |
| name | string | the member's name |
| display_name | string? | the member's display name or nickname |
@ -89,7 +91,7 @@ Returns the updated [user](./users#user-object) object on success.
| names | field_entry[] | the user's new preferred names |
| pronouns | pronoun_entry[] | the user's new preferred pronouns |
| fields | field[] | the user's new profile fields |
| flags | string[] | the user's new flags. This must be an array of [pride flag](./#pride-flag) IDs. |
| flags | string[] | the user's new flags. This must be an array of [pride flag](./#pride-flag) IDs, _not_ snowflake IDs. |
| avatar | string | the user's new avatar. This must be a PNG, JPEG, or WebP image, encoded in base64 data URI format |
| timezone | string | the user's new timezone. Must be in IANA timezone database format |
| list_private | bool | whether or not the user's member list should be hidden |

View file

@ -2,12 +2,13 @@
If there is an error in your request, or the server encounters an error while processing it, an error object will be returned.
| Field | Type | Description |
| --------------- | ------- | --------------------------------------------------------------- |
| code | int | an [error code](./errors#error-codes) |
| message | ?string | a human-readable description of the error |
| details | ?string | more details about the error, most often for bad request errors |
| ratelimit_reset | ?int | the unix time when an expired rate limit will reset |
| Field | Type | Description |
| --------------- | ------- | ------------------------------------------------------------------- |
| code | int | an [error code](./errors#error-codes) |
| id | ?string | an opaque Sentry event ID, only returned for internal server errors |
| message | ?string | a human-readable description of the error |
| details | ?string | more details about the error, most often for bad request errors |
| ratelimit_reset | ?int | the unix time when an expired rate limit will reset |
### Error codes

View file

@ -62,19 +62,23 @@ The "type" column in tables is formatted as follows:
## IDs
::: info
pronouns.cc is [planning a transition](https://codeberg.org/pronounscc/pronouns.cc/issues/89)
to [Snowflake IDs](https://en.wikipedia.org/wiki/Snowflake_ID).
The information below pertains to the current ID format.
:::
### Snowflake IDs
The API uses [xid](https://github.com/rs/xid) for unique IDs. These are always serialized as strings.
For [multiple reasons](https://codeberg.org/pronounscc/pronouns.cc/issues/89),
pronouns.cc is transitioning to using snowflakes for unique IDs. These will become the default in the next API version,
but are already returned as `id_new` in the relevant objects (users, members, and flags).
### xids
[xid](https://github.com/rs/xid) is the previous unique ID format. These are always serialized as strings.
Although xids have timestamp information embedded in them, this is non-trivial to extract.
xids are unique across _all_ resources, they are never shared (for example, a user and a member cannot share the same ID).
### prns.cc IDs
Users and members also have an additional ID type, `sid`.
These are randomly generated 5 or 6 letter strings, and are used for the prns.cc URL shortener.
They can be rerolled once per hour.
**These can change at any time**, as short IDs can be rerolled once per hour.
## Images

View file

@ -1,4 +1,4 @@
# Base of frontend URLs
# Base of frontend URLs (required)
PUBLIC_BASE_URL=http://localhost:5173
# Base of media URLs, required for avatars, pride flags, and data exports

View file

@ -1,20 +1,31 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
plugins: ['svelte3', '@typescript-eslint'],
ignorePatterns: ['*.cjs'],
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
settings: {
'svelte3/typescript': () => require('typescript')
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020
},
env: {
browser: true,
es2017: true,
node: true
}
root: true,
parser: "@typescript-eslint/parser",
extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
plugins: ["svelte3", "@typescript-eslint"],
ignorePatterns: ["*.cjs"],
overrides: [{ files: ["*.svelte"], processor: "svelte3/svelte3" }],
settings: {
"svelte3/typescript": () => require("typescript"),
},
parserOptions: {
sourceType: "module",
ecmaVersion: 2020,
},
env: {
browser: true,
es2017: true,
node: true,
},
rules: {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
destructuredArrayIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
},
};

View file

@ -16,26 +16,22 @@ writeFileSync("src/icons.ts", `const icons = ${output};\nexport default icons;`)
const goCode1 = `// Generated code. DO NOT EDIT
package icons
var icons = [...]string{
var icons = map[string]struct{}{
`;
const goCode2 = `}
// IsValid returns true if the input is the name of a Bootstrap icon.
func IsValid(name string) bool {
for i := range icons {
if icons[i] == name {
return true
}
}
return false
_, ok := icons[name]
return ok
}
`;
let goOutput = goCode1;
keys.forEach((element) => {
goOutput += ` "${element}",\n`;
goOutput += ` "${element}": {},\n`;
});
goOutput += goCode2;

View file

@ -1,52 +1,53 @@
{
"name": "pronouns-fe",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^2.0.0",
"@sveltejs/adapter-node": "^1.2.3",
"@sveltejs/kit": "^1.15.0",
"@types/luxon": "^3.2.2",
"@types/markdown-it": "^12.2.3",
"@types/node": "^18.15.11",
"@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"eslint": "^8.37.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-svelte3": "^4.0.0",
"prettier": "^2.8.7",
"prettier-plugin-svelte": "^2.10.0",
"svelte": "^3.58.0",
"svelte-check": "^3.1.4",
"svelte-hcaptcha": "^0.1.1",
"sveltestrap": "^5.10.0",
"tslib": "^2.5.0",
"typescript": "^4.9.5",
"vite": "^4.2.1",
"vite-plugin-markdown": "^2.1.0"
},
"type": "module",
"dependencies": {
"@fontsource/firago": "^4.5.3",
"@popperjs/core": "^2.11.7",
"@sentry/node": "^7.46.0",
"base64-arraybuffer": "^1.0.2",
"bootstrap": "5.3.0-alpha1",
"bootstrap-icons": "^1.10.4",
"jose": "^4.13.1",
"luxon": "^3.3.0",
"markdown-it": "^13.0.1",
"pretty-bytes": "^6.1.0",
"sanitize-html": "^2.10.0"
}
"name": "pronouns-fe",
"version": "0.0.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write ."
},
"devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-node": "^2.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@sveltestrap/sveltestrap": "^6.0.5",
"@types/luxon": "^3.3.7",
"@types/markdown-it": "^13.0.7",
"@types/node": "^18.15.11",
"@types/sanitize-html": "^2.9.0",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"eslint": "^8.37.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-svelte3": "^4.0.0",
"prettier": "^2.8.7",
"prettier-plugin-svelte": "^2.10.1",
"svelte": "^4.0.0",
"svelte-check": "^3.4.3",
"svelte-hcaptcha": "^0.1.1",
"tslib": "^2.5.0",
"typescript": "^5.0.0",
"vite": "^5.0.0",
"vite-plugin-markdown": "^2.1.0"
},
"type": "module",
"dependencies": {
"@fontsource/firago": "^4.5.3",
"@popperjs/core": "^2.11.7",
"@sentry/node": "^7.46.0",
"base64-arraybuffer": "^1.0.2",
"bootstrap": "^5.3.2",
"bootstrap-icons": "^1.11.2",
"jose": "^4.13.1",
"luxon": "^3.3.0",
"markdown-it": "^13.0.1",
"pretty-bytes": "^6.1.0",
"sanitize-html": "^2.10.0"
}
}

19
frontend/src/app.d.ts vendored
View file

@ -16,23 +16,4 @@ declare global {
}
}
declare module "svelte-hcaptcha" {
import type { SvelteComponent } from "svelte";
export interface HCaptchaProps {
sitekey?: string;
apihost?: string;
hl?: string;
reCaptchaCompat?: boolean;
theme?: CaptchaTheme;
size?: string;
}
declare class HCaptcha extends SvelteComponent {
$$prop_def: HCaptchaProps;
}
export default HCaptcha;
}
export {};

View file

@ -2,7 +2,9 @@ import { PRIVATE_SENTRY_DSN } from "$env/static/private";
import * as Sentry from "@sentry/node";
import type { HandleServerError } from "@sveltejs/kit";
Sentry.init({ dsn: PRIVATE_SENTRY_DSN });
if (PRIVATE_SENTRY_DSN) {
Sentry.init({ dsn: PRIVATE_SENTRY_DSN });
}
export const handleError = (({ error, event }) => {
console.log(error);

File diff suppressed because one or more lines are too long

View file

@ -1,3 +1,4 @@
/* eslint-disable no-unused-vars */
import { PUBLIC_BASE_URL, PUBLIC_MEDIA_URL } from "$env/static/public";
export const MAX_MEMBERS = 500;
@ -7,6 +8,7 @@ export const MAX_FLAGS = 500;
export interface User {
id: string;
id_new: string;
sid: string;
name: string;
display_name: string | null;
@ -61,8 +63,14 @@ export interface MeUser extends User {
timezone: string | null;
}
export interface Settings {
read_changelog: string;
read_settings_notice: string;
read_global_notice: number;
}
export interface Field {
name: string;
name: string | null;
entries: FieldEntry[];
}
@ -79,6 +87,7 @@ export interface Pronoun {
export interface PartialMember {
id: string;
id_new: string;
sid: string;
name: string;
display_name: string | null;
@ -99,6 +108,7 @@ export interface Member extends PartialMember {
export interface MemberPartialUser {
id: string;
id_new: string;
name: string;
display_name: string | null;
avatar: string | null;
@ -107,6 +117,7 @@ export interface MemberPartialUser {
export interface PrideFlag {
id: string;
id_new: string;
hash: string;
name: string;
description: string | null;

View file

@ -11,9 +11,16 @@ export async function apiFetch<T>(
body,
token,
headers,
}: { method?: string; body?: any; token?: string; headers?: Record<string, string> },
version,
}: {
method?: string;
body?: any;
token?: string;
headers?: Record<string, string>;
version?: number;
},
) {
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v1${path}`, {
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v${version || 1}${path}`, {
method: method || "GET",
headers: {
...(token ? { Authorization: token } : {}),
@ -28,12 +35,18 @@ export async function apiFetch<T>(
return data as T;
}
export const apiFetchClient = async <T>(path: string, method = "GET", body: any = null) => {
export const apiFetchClient = async <T>(
path: string,
method = "GET",
body: any = null,
version = 1,
) => {
try {
const data = await apiFetch<T>(path, {
method,
body,
token: localStorage.getItem("pronouns-token") || undefined,
version,
});
return data;
} catch (e) {
@ -55,9 +68,16 @@ export async function fastFetch(
body,
token,
headers,
}: { method?: string; body?: any; token?: string; headers?: Record<string, string> },
version,
}: {
method?: string;
body?: any;
token?: string;
headers?: Record<string, string>;
version?: number;
},
) {
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v1${path}`, {
const resp = await fetch(`${PUBLIC_BASE_URL}/api/v${version || 1}${path}`, {
method: method || "GET",
headers: {
...(token ? { Authorization: token } : {}),
@ -71,12 +91,18 @@ export async function fastFetch(
}
/** Fetches the specified path without parsing the response body. */
export const fastFetchClient = async (path: string, method = "GET", body: any = null) => {
export const fastFetchClient = async (
path: string,
method = "GET",
body: any = null,
version = 1,
) => {
try {
await fastFetch(path, {
method,
body,
token: localStorage.getItem("pronouns-token") || undefined,
version,
});
} catch (e) {
if ((e as APIError).code === ErrorCode.InvalidToken) {

View file

@ -11,6 +11,7 @@ export interface MetaResponse {
users: MetaUsers;
members: number;
require_invite: boolean;
notice: { id: number; notice: string } | null;
}
export interface MetaUsers {

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { NavLink } from "sveltestrap";
import { NavLink } from "@sveltestrap/sveltestrap";
import { page } from "$app/stores";
export let href: string;

View file

@ -1,6 +1,6 @@
<script lang="ts">
import type { APIError } from "$lib/api/entities";
import { Alert } from "sveltestrap";
import { Alert } from "@sveltestrap/sveltestrap";
export let error: APIError;
</script>

View file

@ -4,6 +4,7 @@
export let urls: string[];
export let alt: string;
export let width = 300;
export let lazyLoad = false;
const contentTypeFor = (url: string) => {
if (url.endsWith(".webp")) {
@ -31,6 +32,7 @@
src={urls[0] || defaultAvatars[0]}
{alt}
class="rounded-circle img-fluid"
loading={lazyLoad ? "lazy" : "eager"}
/>
</picture>
{:else}

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { Button, Icon, Tooltip } from "sveltestrap";
import { Button, Icon, Tooltip } from "@sveltestrap/sveltestrap";
export let icon: string;
export let color: "primary" | "secondary" | "success" | "danger";

View file

@ -6,12 +6,12 @@
type User,
type CustomPreferences,
} from "$lib/api/entities";
import { Icon, Tooltip } from "sveltestrap";
import { Icon, Tooltip } from "@sveltestrap/sveltestrap";
import FallbackImage from "./FallbackImage.svelte";
export let user: User;
export let member: PartialMember & {
unlisted?: boolean
unlisted?: boolean;
};
let pronouns: string | undefined;
@ -46,13 +46,18 @@
<div>
<a href="/@{user.name}/{member.name}">
<FallbackImage urls={memberAvatars(member)} width={200} alt="Avatar for {member.name}" />
<FallbackImage
urls={memberAvatars(member)}
width={200}
lazyLoad
alt="Avatar for {member.name}"
/>
</a>
<p class="m-2">
<a class="text-reset fs-5 text-break" href="/@{user.name}/{member.name}">
{member.display_name ?? member.name}
{#if member.unlisted === true}
<span bind:this={iconElement} tabindex={0}><Icon name="lock"/></span>
<span bind:this={iconElement} tabindex={0}><Icon name="lock" /></span>
<Tooltip target={iconElement} placement="top">This member is hidden</Tooltip>
{/if}
</a>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { Icon, Tooltip } from "sveltestrap";
import { Icon, Tooltip } from "@sveltestrap/sveltestrap";
import type { CustomPreference, CustomPreferences } from "$lib/api/entities";
import defaultPreferences from "$lib/api/default_preferences";
@ -15,10 +15,11 @@
$: currentPreference =
status in mergedPreferences ? mergedPreferences[status] : defaultPreferences.missing;
let iconElement: HTMLElement;
let iconElement: HTMLSpanElement;
</script>
<span bind:this={iconElement} tabindex={0}
><Icon name={currentPreference.icon} class={className} /></span
>
<Tooltip target={iconElement} placement="top">{currentPreference.tooltip}</Tooltip>
<span bind:this={iconElement} aria-hidden>
<Icon name={currentPreference.icon} class={className} />
</span>
<span class="visually-hidden">{currentPreference.tooltip}:</span>
<Tooltip aria-hidden target={iconElement} placement="top">{currentPreference.tooltip}</Tooltip>

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { Toast } from "sveltestrap";
import { Toast } from "@sveltestrap/sveltestrap";
export let header: string | undefined = undefined;
export let body: string;

View file

@ -1,7 +1,7 @@
<script lang="ts">
import type { Field, CustomPreferences } from "$lib/api/entities";
import IconButton from "$lib/components/IconButton.svelte";
import { Button, Input, InputGroup } from "sveltestrap";
import { Button, Input, InputGroup } from "@sveltestrap/sveltestrap";
import FieldEntry from "./FieldEntry.svelte";
export let field: Field;
@ -36,7 +36,7 @@
</script>
<div class="p-3">
<h5>{field.name}</h5>
<h5>{field.name ? field.name : "New field"}</h5>
<InputGroup class="m-1">
<IconButton
color="secondary"
@ -50,7 +50,7 @@
tooltip="Move field right"
click={() => moveField(false)}
/>
<Input bind:value={field.name} />
<Input placeholder="New field" bind:value={field.name} />
<Button color="danger" on:click={() => deleteField()}>Delete field</Button>
</InputGroup>
<hr />
@ -65,7 +65,7 @@
/>
{/each}
<form class="input-group m-1" on:submit={addEntry}>
<form class="m-1 input-group" on:submit={addEntry}>
<input type="text" class="form-control" placeholder="New entry" bind:value={newEntry} />
<IconButton color="success" icon="plus" tooltip="Add entry" />
</form>

View file

@ -9,7 +9,7 @@
DropdownToggle,
Icon,
Tooltip,
} from "sveltestrap";
} from "@sveltestrap/sveltestrap";
export let value: string;
export let status: string;

View file

@ -12,7 +12,7 @@
InputGroupText,
Popover,
Tooltip,
} from "sveltestrap";
} from "@sveltestrap/sveltestrap";
export let pronoun: Pronoun;
export let preferences: CustomPreferences;

View file

@ -9,7 +9,7 @@
DropdownToggle,
Icon,
Tooltip,
} from "sveltestrap";
} from "@sveltestrap/sveltestrap";
export let value: string;
export let status: string;

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { flagURL, type PrideFlag } from "$lib/api/entities";
import { Button, Tooltip } from "sveltestrap";
import { Button, Tooltip } from "@sveltestrap/sveltestrap";
export let flag: PrideFlag;
export let tooltip: string;

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { Icon, Modal } from "sveltestrap";
import { Icon, Modal } from "@sveltestrap/sveltestrap";
let isOpen = false;
const toggle = () => (isOpen = !isOpen);

View file

@ -1,7 +1,7 @@
import { writable } from "svelte/store";
import { browser } from "$app/environment";
import type { MeUser } from "./api/entities";
import type { MeUser, Settings } from "./api/entities";
const initialUserValue = null;
export const userStore = writable<MeUser | null>(initialUserValue);
@ -13,4 +13,10 @@ const initialThemeValue = browser
export const themeStore = writable<string>(initialThemeValue);
const defaultSettingsValue = {
settings: { read_changelog: "0.0.0", read_settings_notice: "0" } as Settings,
current: false,
};
export const settingsStore = writable(defaultSettingsValue);
export const CURRENT_CHANGELOG = "0.6.0";

View file

@ -7,8 +7,18 @@ const md = new MarkdownIt({
linkify: true,
}).disable(["heading", "lheading", "link", "table", "blockquote"]);
const unsafeMd = new MarkdownIt({
html: false,
breaks: true,
linkify: true,
});
export function renderMarkdown(src: string | null) {
return src ? sanitize(md.render(src)) : null;
}
export function renderUnsafeMarkdown(src: string) {
return sanitize(unsafeMd.render(src));
}
export const charCount = (str: string) => [...str].length;

View file

@ -22,7 +22,8 @@ export const load = (async () => {
},
members: 0,
require_invite: false,
};
notice: null,
} as MetaResponse;
} else {
throw e;
}

View file

@ -10,9 +10,13 @@
import Navigation from "./nav/Navigation.svelte";
import type { LayoutData } from "./$types";
import { version } from "$app/environment";
import { settingsStore } from "$lib/store";
import { toastStore } from "$lib/toast";
import Toast from "$lib/components/Toast.svelte";
import { Icon } from "sveltestrap";
import { Alert, Icon } from "@sveltestrap/sveltestrap";
import { apiFetchClient } from "$lib/api/fetch";
import type { Settings } from "$lib/api/entities";
import { renderUnsafeMarkdown } from "$lib/utils";
export let data: LayoutData;
@ -21,6 +25,22 @@
if (versionParts.length >= 3) commit = versionParts[2].slice(1);
const versionMismatch = data.git_commit !== commit && data.git_commit !== "[unknown]";
const readNotice = async () => {
try {
const resp = await apiFetchClient<Settings>(
"/users/@me/settings",
"PATCH",
// If this function is run, notice will always be non-null
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
{ read_global_notice: data.notice!.id },
2,
);
settingsStore.set({ current: true, settings: resp });
} catch (e) {
console.log("updating settings:", e);
}
};
</script>
<svelte:head>
@ -34,6 +54,12 @@
<div class="flex-grow-1">
<Navigation commit={data.git_commit} />
<div class="container">
{#if data.notice && $settingsStore.current && data.notice.id > $settingsStore.settings.read_global_notice}
<Alert color="secondary" isOpen={true} toggle={() => readNotice()}>
{@html renderUnsafeMarkdown(data.notice.notice)}
</Alert>
{/if}
<slot />
<div class="position-absolute top-0 start-50 translate-middle-x">
{#each $toastStore as toast}

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { PUBLIC_BASE_URL } from "$env/static/public";
import { Button } from "sveltestrap";
import { Button } from "@sveltestrap/sveltestrap";
import { userStore } from "$lib/store";
</script>

View file

@ -11,7 +11,7 @@ export const load = async ({ params }) => {
return resp;
} catch (e) {
if ((e as APIError).code === ErrorCode.UserNotFound) {
throw error(404, e as APIError);
error(404, e as App.Error);
}
throw e;

View file

@ -4,7 +4,6 @@
import {
Alert,
Badge,
Button,
ButtonGroup,
Icon,
@ -14,8 +13,8 @@
ModalBody,
ModalFooter,
Tooltip,
} from "sveltestrap";
import { DateTime, Duration, FixedOffsetZone, Zone } from "luxon";
} from "@sveltestrap/sveltestrap";
import { DateTime, FixedOffsetZone } from "luxon";
import FieldCard from "$lib/components/FieldCard.svelte";
import PronounLink from "$lib/components/PronounLink.svelte";
import PartialMemberCard from "$lib/components/PartialMemberCard.svelte";
@ -46,6 +45,7 @@
import ProfileFlag from "./ProfileFlag.svelte";
import IconButton from "$lib/components/IconButton.svelte";
import Badges from "./badges/Badges.svelte";
import PreferencesCheatsheet from "./PreferencesCheatsheet.svelte";
export let data: PageData;
@ -190,14 +190,15 @@
{/if}
{#if data.utc_offset}
<Tooltip target="user-clock" placement="top">Current time</Tooltip>
<Icon id="user-clock" name="clock" aria-label="This user's current time" /> {currentTime} <span class="text-body-secondary">(UTC{timezone})</span>
<Icon id="user-clock" name="clock" aria-label="This user's current time" />
{currentTime} <span class="text-body-secondary">(UTC{timezone})</span>
{/if}
{#if profileEmpty && $userStore?.id === data.id}
<hr />
<p>
<em>
Your profile is empty! You can customize it by going to the <a href="/@{data.name}/edit"
>edit profile</a
Your profile is empty! You can customize it by going to the <a
href="/@{data.name}/edit">edit profile</a
> page.</em
> <span class="text-muted">(only you can see this)</span>
</p>
@ -258,6 +259,12 @@
</div>
{/each}
</div>
<PreferencesCheatsheet
preferences={data.custom_preferences}
names={data.names}
pronouns={data.pronouns}
fields={data.fields}
/>
<div class="row">
<div class="col-md-6">
<InputGroup>
@ -274,7 +281,7 @@
/>
{/if}
{#if $userStore && $userStore.id !== data.id}
<ReportButton subject="user" reportUrl="/users/{data.id}/reports" />
<ReportButton subject="user" reportUrl="/users/{data.id_new}/reports" />
{/if}
</InputGroup>
</div>

View file

@ -0,0 +1,65 @@
<script lang="ts">
import type {
CustomPreferences,
CustomPreference,
Field,
FieldEntry,
Pronoun,
} from "$lib/api/entities";
import defaultPreferences from "$lib/api/default_preferences";
import StatusIcon from "$lib/components/StatusIcon.svelte";
export let preferences: CustomPreferences;
export let names: FieldEntry[];
export let pronouns: Pronoun[];
export let fields: Field[];
let mergedPreferences: CustomPreferences;
$: mergedPreferences = Object.assign({}, defaultPreferences, preferences);
// Filter default preferences to the ones the user/member has used
// This is done separately from custom preferences to make the shown list cleaner
let usedDefaultPreferences: Array<{ id: string; preference: CustomPreference }>;
$: usedDefaultPreferences = Object.keys(defaultPreferences)
.filter(
(pref) =>
names.some((entry) => entry.status === pref) ||
pronouns.some((entry) => entry.status === pref) ||
fields.some((field) => field.entries.some((entry) => entry.status === pref)),
)
.map((key) => ({
id: key,
preference: defaultPreferences[key],
}));
// Do the same for custom preferences
let usedCustomPreferences: Array<{ id: string; preference: CustomPreference }>;
$: usedCustomPreferences = Object.keys(preferences)
.filter(
(pref) =>
names.some((entry) => entry.status === pref) ||
pronouns.some((entry) => entry.status === pref) ||
fields.some((field) => field.entries.some((entry) => entry.status === pref)),
)
.map((pref) => ({ id: pref, preference: mergedPreferences[pref] }));
</script>
<div class="text-center">
<ul class="list-inline text-body-secondary">
{#each usedDefaultPreferences as pref (pref.id)}
<li class="list-inline-item mx-2">
<StatusIcon {preferences} status={pref.id} />
{pref.preference.tooltip}
</li>
{/each}
</ul>
{#if usedCustomPreferences}
<ul class="list-inline text-body-secondary">
{#each usedCustomPreferences as pref (pref.id)}
<li class="list-inline-item mx-2">
<StatusIcon {preferences} status={pref.id} />
{pref.preference.tooltip}
</li>
{/each}
</ul>
{/if}
</div>

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { flagURL, type PrideFlag } from "$lib/api/entities";
import { Tooltip } from "sveltestrap";
import { Tooltip } from "@sveltestrap/sveltestrap";
export let flag: PrideFlag;

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { Icon } from "sveltestrap";
import { Icon } from "@sveltestrap/sveltestrap";
export let link: string;
@ -21,7 +21,7 @@
</script>
{#if isLink}
<a href={link} class="text-decoration-none">
<a href={link} class="text-decoration-none" rel="me nofollow noreferrer" target="_blank">
<li class="py-2 py-lg-0">
<Icon name="globe" aria-hidden class="text-body" />
<span class="text-decoration-underline">{displayLink}</span>

View file

@ -3,7 +3,7 @@
import { fastFetchClient } from "$lib/api/fetch";
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
import { addToast } from "$lib/toast";
import { Button, FormGroup, Icon, Modal, ModalBody, ModalFooter } from "sveltestrap";
import { Button, FormGroup, Icon, Modal, ModalBody, ModalFooter } from "@sveltestrap/sveltestrap";
export let subject: string;
export let reportUrl: string;

View file

@ -14,9 +14,9 @@ export const load = async ({ params }) => {
(e as APIError).code === ErrorCode.UserNotFound ||
(e as APIError).code === ErrorCode.MemberNotFound
) {
throw error(404, e as APIError);
error(404, e as App.Error);
}
throw error(500, e as APIError);
error(500, e as App.Error);
}
};

View file

@ -4,7 +4,7 @@
import type { PageData } from "./$types";
import PronounLink from "$lib/components/PronounLink.svelte";
import FallbackImage from "$lib/components/FallbackImage.svelte";
import { Alert, Button, Icon, InputGroup } from "sveltestrap";
import { Alert, Button, Icon, InputGroup } from "@sveltestrap/sveltestrap";
import {
memberAvatars,
pronounDisplay,
@ -22,6 +22,7 @@
import { addToast } from "$lib/toast";
import ProfileFlag from "../ProfileFlag.svelte";
import IconButton from "$lib/components/IconButton.svelte";
import PreferencesCheatsheet from "../PreferencesCheatsheet.svelte";
export let data: PageData;
@ -154,6 +155,12 @@
</div>
{/each}
</div>
<PreferencesCheatsheet
preferences={data.user.custom_preferences}
names={data.names}
pronouns={data.pronouns}
fields={data.fields}
/>
<div class="row">
<div class="col-md-6">
<InputGroup>
@ -170,7 +177,7 @@
/>
{/if}
{#if $userStore && $userStore.id !== data.user.id}
<ReportButton subject="member" reportUrl="/members/{data.id}/reports" />
<ReportButton subject="member" reportUrl="/members/{data.id_new}/reports" />
{/if}
</InputGroup>
</div>

View file

@ -5,7 +5,15 @@
import type { LayoutData } from "./$types";
import { addToast, delToast } from "$lib/toast";
import { apiFetchClient, fastFetchClient } from "$lib/api/fetch";
import { Button, ButtonGroup, Modal, ModalBody, ModalFooter, Nav, NavItem } from "sveltestrap";
import {
Button,
ButtonGroup,
Modal,
ModalBody,
ModalFooter,
Nav,
NavItem,
} from "@sveltestrap/sveltestrap";
import { goto } from "$app/navigation";
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
import IconButton from "$lib/components/IconButton.svelte";
@ -34,7 +42,7 @@
const deleteMember = async () => {
try {
await fastFetchClient(`/members/${data.member.id}`, "DELETE");
await fastFetchClient(`/members/${data.member.id_new}`, "DELETE");
toggleDeleteOpen();
addToast({
@ -68,7 +76,7 @@
});
try {
const resp = await apiFetchClient<Member>(`/members/${data.member.id}`, "PATCH", {
const resp = await apiFetchClient<Member>(`/members/${data.member.id_new}`, "PATCH", {
name: $member.name,
display_name: $member.display_name,
avatar: $member.avatar,

View file

@ -32,7 +32,7 @@ export const load = (async ({ params }) => {
member.user.name !== params.username ||
member.name !== params.memberName
) {
throw redirect(303, `/@${user.name}/${member.name}`);
redirect(303, `/@${user.name}/${member.name}`);
}
return {
@ -41,8 +41,9 @@ export const load = (async ({ params }) => {
pronouns: pronouns.autocomplete,
flags,
};
} catch (e) {
if ("code" in e) throw error(500, e as APIError);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (e: any) {
if ("code" in e) error(500, e as App.Error);
throw e;
}
}) satisfies LayoutLoad;

View file

@ -3,7 +3,7 @@
import type { Writable } from "svelte/store";
import prettyBytes from "pretty-bytes";
import { encode } from "base64-arraybuffer";
import { FormGroup, Icon, Input } from "sveltestrap";
import { FormGroup, Icon, Input } from "@sveltestrap/sveltestrap";
import { memberAvatars, type Member } from "$lib/api/entities";
import FallbackImage from "$lib/components/FallbackImage.svelte";
import EditableName from "$lib/components/edit/EditableName.svelte";

View file

@ -4,7 +4,7 @@
import { MAX_DESCRIPTION_LENGTH, type Member } from "$lib/api/entities";
import { charCount, renderMarkdown } from "$lib/utils";
import MarkdownHelp from "$lib/components/edit/MarkdownHelp.svelte";
import { Card, CardBody, CardHeader } from "sveltestrap";
import { Card, CardBody, CardHeader } from "@sveltestrap/sveltestrap";
const member = getContext<Writable<Member>>("member");
</script>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { getContext } from "svelte";
import type { Writable } from "svelte/store";
import { Alert, Button, Icon } from "sveltestrap";
import { Button, Icon } from "@sveltestrap/sveltestrap";
import type { Member } from "$lib/api/entities";
import EditableField from "$lib/components/edit/EditableField.svelte";
@ -27,10 +27,10 @@
</script>
{#if $member.fields.length === 0}
<Alert class="mt-3" color="secondary" fade={false}>
<div class="my-2">
Fields are extra categories you can add separate from names and pronouns.<br />
For example, you could use them for gender terms, honorifics, or compliments.
</Alert>
</div>
{/if}
<div class="grid gap-3">
<div class="row row-cols-1 row-cols-md-2">
@ -45,9 +45,7 @@
</div>
</div>
<div>
<Button
on:click={() => ($member.fields = [...$member.fields, { name: "New field", entries: [] }])}
>
<Button on:click={() => ($member.fields = [...$member.fields, { name: null, entries: [] }])}>
<Icon name="plus" aria-hidden /> Add new field
</Button>
</div>

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { getContext } from "svelte";
import type { Writable } from "svelte/store";
import { Alert, ButtonGroup, Input } from "sveltestrap";
import { Alert, ButtonGroup, Input } from "@sveltestrap/sveltestrap";
import type { PageData } from "./$types";
import type { Member, PrideFlag } from "$lib/api/entities";

View file

@ -2,7 +2,7 @@
import { getContext } from "svelte";
import type { Writable } from "svelte/store";
import { DateTime } from "luxon";
import { Button, ButtonGroup, Icon } from "sveltestrap";
import { Button, ButtonGroup, Icon } from "@sveltestrap/sveltestrap";
import type { APIError, Member } from "$lib/api/entities";
import { PUBLIC_SHORT_BASE } from "$env/static/public";
@ -25,7 +25,7 @@
const rerollSid = async () => {
try {
const resp = await apiFetchClient<Member>(`/members/${data.member.id}/reroll`);
const resp = await apiFetchClient<Member>(`/members/${data.member.id_new}/reroll`);
addToast({ header: "Success", body: "Rerolled short ID!" });
error = null;
$member.sid = resp.sid;

Some files were not shown because too many files have changed in this diff Show more