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.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
target 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 .PHONY: backend
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 .PHONY: generate
generate: generate:

View file

@ -26,19 +26,25 @@ Requirements:
- Redis 6.0 or later - Redis 6.0 or later
- Node.js (latest version) - Node.js (latest version)
- MinIO **if using avatars, flags, or data exports** (_not_ required otherwise) - MinIO **if using avatars, flags, or data exports** (_not_ required otherwise)
- [Air](https://github.com/cosmtrek/air) for live reloading the backend
### Setup ### 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;` 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. 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. 3. Copy `frontend/.env.example` to `frontend/env` and fill out the required options.
4. Run `go run -v . web` to run the backend. 4. Run `go run -v . database migrate` to initialize the database, then optionally `go run -v . database seed` to insert a test user.
5. Copy `frontend/.env.example` into `frontend/.env` and tweak as necessary. 5. Run `pnpm dev`. Alternatively, if you don't want the backend to live reload, run `go run -v . web`,
6. cd into the `frontend` directory and run `pnpm dev` to run the frontend. 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. 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 ## License
Copyright (C) 2022 Sam <u1f320> 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") 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 { if err != nil {
return de, errors.Wrap(err, "executing sql") return de, errors.Wrap(err, "executing sql")
} }

View file

@ -48,11 +48,11 @@ func (f FediverseApp) ClientConfig() *oauth2.Config {
} }
func (f FediverseApp) MastodonCompatible() bool { 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 { 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") 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) { 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" "io"
"strings" "strings"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/davidbyttow/govips/v2/vips" "github.com/davidbyttow/govips/v2/vips"
@ -20,11 +21,12 @@ import (
) )
type PrideFlag struct { type PrideFlag struct {
ID xid.ID `json:"id"` ID xid.ID `json:"id"`
UserID xid.ID `json:"-"` SnowflakeID common.FlagID `json:"id_new"`
Hash string `json:"hash"` UserID xid.ID `json:"-"`
Name string `json:"name"` Hash string `json:"hash"`
Description *string `json:"description"` Name string `json:"name"`
Description *string `json:"description"`
} }
type UserFlag struct { 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"). sql, args, err := sq.Insert("pride_flags").
SetMap(map[string]any{ SetMap(map[string]any{
"id": xid.New(), "id": xid.New(),
"hash": "", "snowflake_id": common.GenerateID(),
"user_id": userID.String(), "hash": "",
"name": name, "user_id": userID.String(),
"description": description, "name": name,
"description": description,
}).Suffix("RETURNING *").ToSql() }).Suffix("RETURNING *").ToSql()
if err != nil { if err != nil {
return f, errors.Wrap(err, "building query") return f, errors.Wrap(err, "building query")

View file

@ -6,6 +6,7 @@ import (
"encoding/base64" "encoding/base64"
"time" "time"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/georgysavva/scany/v2/pgxscan" "github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5" "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 { if err != nil {
return i, errors.Wrap(err, "beginning transaction") 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 var maxInvites, inviteCount int
err = tx.QueryRow(ctx, "SELECT max_invites FROM users WHERE id = $1", userID).Scan(&maxInvites) err = tx.QueryRow(ctx, "SELECT max_invites FROM users WHERE id = $1", userID).Scan(&maxInvites)

View file

@ -6,6 +6,8 @@ import (
"strings" "strings"
"time" "time"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/Masterminds/squirrel" "github.com/Masterminds/squirrel"
"github.com/georgysavva/scany/v2/pgxscan" "github.com/georgysavva/scany/v2/pgxscan"
@ -22,6 +24,7 @@ const (
type Member struct { type Member struct {
ID xid.ID ID xid.ID
UserID xid.ID UserID xid.ID
SnowflakeID common.MemberID
SID string `db:"sid"` SID string `db:"sid"`
Name string Name string
DisplayName *string DisplayName *string
@ -39,12 +42,14 @@ const (
) )
// member names must match this regex // 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. // List of member names that cannot be used because they would break routing or be inaccessible due to page conflicts.
var invalidMemberNames = []string{ var invalidMemberNames = []string{
// these break routing outright
".", ".",
"..", "..",
// the user edit page lives at `/@{username}/edit`, so a member named "edit" would be inaccessible
"edit", "edit",
} }
@ -71,9 +76,23 @@ func (db *DB) Member(ctx context.Context, id xid.ID) (m Member, err error) {
return m, nil 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. // UserMember returns a member scoped by user.
func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) (m Member, err error) { 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 { if err != nil {
return m, errors.Wrap(err, "building sql") return m, errors.Wrap(err, "building sql")
} }
@ -135,8 +154,8 @@ func (db *DB) CreateMember(
name string, displayName *string, bio string, links []string, name string, displayName *string, bio string, links []string,
) (m Member, err error) { ) (m Member, err error) {
sql, args, err := sq.Insert("members"). sql, args, err := sq.Insert("members").
Columns("user_id", "id", "sid", "name", "display_name", "bio", "links"). Columns("user_id", "snowflake_id", "id", "sid", "name", "display_name", "bio", "links").
Values(userID, xid.New(), squirrel.Expr("find_free_member_sid()"), name, displayName, bio, links). Values(userID, common.GenerateID(), xid.New(), squirrel.Expr("find_free_member_sid()"), name, displayName, bio, links).
Suffix("RETURNING *").ToSql() Suffix("RETURNING *").ToSql()
if err != nil { if err != nil {
return m, errors.Wrap(err, "building sql") 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 { if err != nil {
return "", errors.Wrap(err, "beginning transaction") 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"). sql, args, err := sq.Update("members").
Set("sid", squirrel.Expr("find_free_member_sid()")). 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 { type User struct {
ID xid.ID ID xid.ID
SnowflakeID common.UserID
SID string `db:"sid"` SID string `db:"sid"`
Username string Username string
DisplayName *string DisplayName *string
@ -54,6 +55,7 @@ type User struct {
ListPrivate bool ListPrivate bool
LastSIDReroll time.Time `db:"last_sid_reroll"` LastSIDReroll time.Time `db:"last_sid_reroll"`
Timezone *string Timezone *string
Settings UserSettings
DeletedAt *time.Time DeletedAt *time.Time
SelfDelete *bool SelfDelete *bool
@ -205,7 +207,7 @@ func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u Use
return u, err 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 { if err != nil {
return u, errors.Wrap(err, "building sql") 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 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. // Username gets a user by username.
func (db *DB) Username(ctx context.Context, name string) (u User, err error) { func (db *DB) Username(ctx context.Context, name string) (u User, err error) {
sql, args, err := sq.Select("*").From("users").Where("username = ?", name).ToSql() 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 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" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"github.com/davidbyttow/govips/v2/vips" "github.com/davidbyttow/govips/v2/vips"
"github.com/getsentry/sentry-go"
"github.com/go-chi/render" "github.com/go-chi/render"
_ "github.com/joho/godotenv/autoload" _ "github.com/joho/godotenv/autoload"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
@ -23,6 +24,19 @@ var Command = &cli.Command{
} }
func run(c *cli.Context) error { 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 // set vips log level to WARN, else it will spam logs on info level
vips.LoggingSettings(nil, vips.LogLevelWarning) vips.LoggingSettings(nil, vips.LogLevelWarning)
@ -62,9 +76,8 @@ func run(c *cli.Context) error {
return nil return nil
case err := <-e: case err := <-e:
log.Fatalf("Error running server: %v", err) log.Fatalf("Error running server: %v", err)
return err
} }
return nil
} }
const MaxContentLength = 2 * 1024 * 1024 const MaxContentLength = 2 * 1024 * 1024

File diff suppressed because one or more lines are too long

View file

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

View file

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

View file

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

View file

@ -12,6 +12,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4" "github.com/mediocregopher/radix/v4"
"github.com/rs/xid" "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) err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil { if err != nil {
log.Errorf("saving undelete token: %v", err) log.Errorf("saving undelete token: %v", err)
return err return errors.Wrap(err, "saving undelete token")
} }
render.JSON(w, r, fediCallbackResponse{ render.JSON(w, r, fediCallbackResponse{
@ -114,7 +115,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
tokenID := xid.New() tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil { if err != nil {
return err return errors.Wrap(err, "creating token")
} }
// save token to database // save token to database
@ -137,7 +138,7 @@ func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
return nil return nil
} else if err != db.ErrUserNotFound { // internal error } 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 // 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") err = s.DB.SetJSON(ctx, "misskey:"+ticket, mu.User, "EX", "600")
if err != nil { if err != nil {
log.Errorf("setting misskey user for ticket %q: %v", ticket, err) 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{ 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) valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil { if err != nil {
return err return errors.Wrap(err, "checking if username is taken")
} }
if !valid { if !valid {
return server.APIError{Code: server.ErrInvalidUsername} return server.APIError{Code: server.ErrInvalidUsername}
@ -247,7 +248,12 @@ func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return errors.Wrap(err, "beginning transaction") 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) mu := new(partialMisskeyAccount)
err = s.DB.GetJSON(ctx, "misskey:"+req.Ticket, &mu) 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 { 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) return s.noAppMisskeyURL(ctx, w, r, softwareName, instance)
case "mastodon", "pleroma", "akkoma", "pixelfed", "gotosocial": case "mastodon", "pleroma", "akkoma", "incestoma", "pixelfed", "gotosocial":
case "glitchcafe", "hometown": 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" softwareName = "mastodon"
default: default:
return server.APIError{Code: server.ErrUnsupportedInstance} return server.APIError{Code: server.ErrUnsupportedInstance}

View file

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

View file

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

View file

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

View file

@ -12,6 +12,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4" "github.com/mediocregopher/radix/v4"
"github.com/rs/xid" "github.com/rs/xid"
"golang.org/x/oauth2" "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 the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil { if err != nil {
return err return errors.Wrap(err, "validating state")
} }
return server.APIError{Code: server.ErrInvalidState} 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) err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil { if err != nil {
log.Errorf("saving undelete token: %v", err) log.Errorf("saving undelete token: %v", err)
return err return errors.Wrap(err, "saving undelete token")
} }
render.JSON(w, r, tumblrCallbackResponse{ render.JSON(w, r, tumblrCallbackResponse{
@ -166,7 +167,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
tokenID := xid.New() tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil { if err != nil {
return err return errors.Wrap(err, "creating token")
} }
// save token to database // save token to database
@ -189,7 +190,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
return nil return nil
} else if err != db.ErrUserNotFound { // internal error } 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 // 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") err = s.DB.SetJSON(ctx, "tumblr:"+ticket, tumblrUserInfo{ID: tumblrID, Name: tumblrName}, "EX", "600")
if err != nil { if err != nil {
log.Errorf("setting Tumblr user for ticket %q: %v", ticket, err) 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{ 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) valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil { if err != nil {
return err return errors.Wrap(err, "checking if username is taken")
} }
if !valid { if !valid {
return server.APIError{Code: server.ErrInvalidUsername} return server.APIError{Code: server.ErrInvalidUsername}
@ -327,7 +328,12 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return errors.Wrap(err, "beginning transaction") 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) tui := new(tumblrUserInfo)
err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui) 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" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
) )
type CreateMemberRequest struct { type CreateMemberRequest struct {
@ -119,7 +120,12 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
if err != nil { if err != nil {
return errors.Wrap(err, "starting transaction") 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) m, err := s.DB.CreateMember(ctx, tx, claims.UserID, cmr.Name, cmr.DisplayName, cmr.Bio, cmr.Links)
if err != nil { 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 server.APIError{Code: server.ErrMemberNameInUse}
} }
return err return errors.Wrap(err, "creating member")
} }
// set names, pronouns, fields // set names, pronouns, fields
err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, db.NotNull(cmr.Names), db.NotNull(cmr.Pronouns)) err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, db.NotNull(cmr.Names), db.NotNull(cmr.Pronouns))
if err != nil { if err != nil {
log.Errorf("setting names and pronouns for member %v: %v", m.ID, err) 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.Names = cmr.Names
m.Pronouns = cmr.Pronouns 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) err = s.DB.SetMemberFields(ctx, tx, m.ID, cmr.Fields)
if err != nil { if err != nil {
log.Errorf("setting fields for member %v: %v", m.ID, err) log.Errorf("setting fields for member %v: %v", m.ID, err)
return err return errors.Wrap(err, "setting fields")
} }
if cmr.Avatar != "" { 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) 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) hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
if err != nil { if err != nil {
log.Errorf("uploading member avatar: %v", err) 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) 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) err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil { if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err) 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) err = tx.Commit(ctx)

View file

@ -8,12 +8,13 @@ import (
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/rs/xid" "github.com/rs/xid"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server" "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() ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx) 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"} return server.APIError{Code: server.ErrMissingPermissions, Details: "this token is read-only"}
} }
id, err := xid.FromString(chi.URLParam(r, "memberRef")) var m db.Member
if err != nil { if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
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}
}
m, err := s.DB.Member(ctx, id) return errors.Wrap(err, "getting member")
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
} }
} 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 { 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) err = s.DB.UpdateActiveTime(ctx, s.DB, claims.UserID)
if err != nil { if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err) 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) render.NoContent(w, r)

View file

@ -4,7 +4,9 @@ import (
"context" "context"
"net/http" "net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
@ -13,13 +15,14 @@ import (
) )
type GetMemberResponse struct { type GetMemberResponse struct {
ID xid.ID `json:"id"` ID xid.ID `json:"id"`
SID string `json:"sid"` SnowflakeID common.MemberID `json:"id_new"`
Name string `json:"name"` SID string `json:"sid"`
DisplayName *string `json:"display_name"` Name string `json:"name"`
Bio *string `json:"bio"` DisplayName *string `json:"display_name"`
Avatar *string `json:"avatar"` Bio *string `json:"bio"`
Links []string `json:"links"` Avatar *string `json:"avatar"`
Links []string `json:"links"`
Names []db.FieldEntry `json:"names"` Names []db.FieldEntry `json:"names"`
Pronouns []db.PronounEntry `json:"pronouns"` 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 { func dbMemberToMember(u db.User, m db.Member, fields []db.Field, flags []db.MemberFlag, isOwnMember bool) GetMemberResponse {
r := GetMemberResponse{ r := GetMemberResponse{
ID: m.ID, ID: m.ID,
SnowflakeID: m.SnowflakeID,
SID: m.SID, SID: m.SID,
Name: m.Name, Name: m.Name,
DisplayName: m.DisplayName, DisplayName: m.DisplayName,
@ -48,6 +52,7 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field, flags []db.Memb
User: PartialUser{ User: PartialUser{
ID: u.ID, ID: u.ID,
SnowflakeID: u.SnowflakeID,
Username: u.Username, Username: u.Username,
DisplayName: u.DisplayName, DisplayName: u.DisplayName,
Avatar: u.Avatar, Avatar: u.Avatar,
@ -64,32 +69,43 @@ func dbMemberToMember(u db.User, m db.Member, fields []db.Field, flags []db.Memb
type PartialUser struct { type PartialUser struct {
ID xid.ID `json:"id"` ID xid.ID `json:"id"`
SnowflakeID common.UserID `json:"id_new"`
Username string `json:"name"` Username string `json:"name"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
Avatar *string `json:"avatar"` Avatar *string `json:"avatar"`
CustomPreferences db.CustomPreferences `json:"custom_preferences"` 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() ctx := r.Context()
id, err := xid.FromString(chi.URLParam(r, "memberRef")) var m db.Member
if err != nil { if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
return server.APIError{ m, err = s.DB.Member(ctx, id)
Code: server.ErrMemberNotFound, 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) m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
if err != nil { if err != nil {
return server.APIError{ return server.APIError{
Code: server.ErrMemberNotFound, Code: server.ErrMemberNotFound,
}
} }
} }
u, err := s.DB.User(ctx, m.UserID) u, err := s.DB.User(ctx, m.UserID)
if err != nil { if err != nil {
return err return errors.Wrap(err, "getting user")
} }
if u.DeletedAt != nil { 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) fields, err := s.DB.MemberFields(ctx, m.ID)
if err != nil { if err != nil {
return err return errors.Wrap(err, "getting member fields")
} }
flags, err := s.DB.MemberFlags(ctx, m.ID) flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil { if err != nil {
return err return errors.Wrap(err, "getting member flags")
} }
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember)) 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) fields, err := s.DB.MemberFields(ctx, m.ID)
if err != nil { if err != nil {
return err return errors.Wrap(err, "getting member fields")
} }
flags, err := s.DB.MemberFlags(ctx, m.ID) flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil { if err != nil {
return err return errors.Wrap(err, "getting member flags")
} }
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember)) 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) fields, err := s.DB.MemberFields(ctx, m.ID)
if err != nil { if err != nil {
return err return errors.Wrap(err, "getting member fields")
} }
flags, err := s.DB.MemberFlags(ctx, m.ID) flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil { if err != nil {
return err return errors.Wrap(err, "getting member flags")
} }
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true)) 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) { 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) u, err := s.DB.User(ctx, id)
if err == nil { if err == nil {
return u, 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) return s.DB.Username(ctx, userRef)
} }

View file

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

View file

@ -13,6 +13,7 @@ import (
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/rs/xid" "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"} 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) u, err := s.DB.User(ctx, claims.UserID)
if err != nil { if err != nil {
return errors.Wrap(err, "getting user") return errors.Wrap(err, "getting user")
} }
m, err := s.DB.Member(ctx, id) var m db.Member
if err != nil { if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
if err == db.ErrMemberNotFound { 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 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 { 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) 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) hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
if err != nil { if err != nil {
log.Errorf("uploading member avatar: %v", err) log.Errorf("uploading member avatar: %v", err)
return err return errors.Wrap(err, "writing member avatar")
} }
avatarHash = &hash avatarHash = &hash
@ -230,11 +245,16 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
tx, err := s.DB.Begin(ctx) tx, err := s.DB.Begin(ctx)
if err != nil { if err != nil {
log.Errorf("creating transaction: %v", err) 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 { if err != nil {
switch errors.Cause(err) { switch errors.Cause(err) {
case db.ErrNothingToUpdate: case db.ErrNothingToUpdate:
@ -258,10 +278,10 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
pronouns = *req.Pronouns 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 { if err != nil {
log.Errorf("setting names for member %v: %v", id, err) log.Errorf("setting names for member %v: %v", m.ID, err)
return err return errors.Wrap(err, "setting names/pronouns")
} }
m.Names = names m.Names = names
m.Pronouns = pronouns m.Pronouns = pronouns
@ -269,17 +289,17 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
var fields []db.Field var fields []db.Field
if req.Fields != nil { 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 { if err != nil {
log.Errorf("setting fields for member %v: %v", id, err) log.Errorf("setting fields for member %v: %v", m.ID, err)
return err return errors.Wrap(err, "setting fields")
} }
fields = *req.Fields fields = *req.Fields
} else { } else {
fields, err = s.DB.MemberFields(ctx, id) fields, err = s.DB.MemberFields(ctx, m.ID)
if err != nil { if err != nil {
log.Errorf("getting fields for member %v: %v", id, err) log.Errorf("getting fields for member %v: %v", m.ID, err)
return 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) 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) err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil { if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err) 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) err = tx.Commit(ctx)
if err != nil { if err != nil {
log.Errorf("committing transaction: %v", err) 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) // 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) flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil { if err != nil {
log.Errorf("getting user flags: %v", err) log.Errorf("getting user flags: %v", err)
return err return errors.Wrap(err, "getting flags")
} }
// echo the updated member back on success // echo the updated member back on success
@ -321,7 +341,7 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
return nil 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() ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx) 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"} return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
} }
id, err := xid.FromString(chi.URLParam(r, "memberRef")) var m db.Member
if err != nil { if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
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}
}
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) 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") 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 { if m.UserID != claims.UserID {
return server.APIError{Code: server.ErrNotOwnMember} return server.APIError{Code: server.ErrNotOwnMember}
} }

View file

@ -4,6 +4,8 @@ import (
"net/http" "net/http"
"os" "os"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
@ -20,11 +22,17 @@ func Mount(srv *server.Server, r chi.Router) {
} }
type MetaResponse struct { type MetaResponse struct {
GitRepository string `json:"git_repository"` GitRepository string `json:"git_repository"`
GitCommit string `json:"git_commit"` GitCommit string `json:"git_commit"`
Users MetaUsers `json:"users"` Users MetaUsers `json:"users"`
Members int64 `json:"members"` Members int64 `json:"members"`
RequireInvite bool `json:"require_invite"` RequireInvite bool `json:"require_invite"`
Notice *MetaNotice `json:"notice"`
}
type MetaNotice struct {
ID int `json:"id"`
Notice string `json:"notice"`
} }
type MetaUsers struct { 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) 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{ render.JSON(w, r, MetaResponse{
GitRepository: server.Repository, GitRepository: server.Repository,
GitCommit: server.Revision, GitCommit: server.Revision,
@ -50,6 +70,7 @@ func (s *Server) meta(w http.ResponseWriter, r *http.Request) error {
}, },
Members: numMembers, Members: numMembers,
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true", RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
Notice: notice,
}) })
return nil return nil
} }

View file

@ -3,6 +3,7 @@ package mod
import ( import (
"net/http" "net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
@ -18,7 +19,7 @@ type CreateReportRequest struct {
Reason string `json:"reason"` 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() ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx) 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"} return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
} }
userID, err := xid.FromString(chi.URLParam(r, "id")) var u db.User
if err != nil { if id, err := xid.FromString(chi.URLParam(r, "id")); err == nil {
return server.APIError{Code: server.ErrBadRequest, Details: "Invalid user ID"} 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) log.Errorf("getting user %v: %v", id, err)
if err != nil { return errors.Wrap(err, "getting user")
if err == db.ErrUserNotFound { }
} else {
id, err := common.ParseSnowflake(chi.URLParam(r, "id"))
if err != nil {
return server.APIError{Code: server.ErrUserNotFound} return server.APIError{Code: server.ErrUserNotFound}
} }
log.Errorf("getting user %v: %v", userID, err) u, err = s.DB.UserBySnowflake(ctx, common.UserID(id))
return errors.Wrap(err, "getting user") 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 { 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"} return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
} }
memberID, err := xid.FromString(chi.URLParam(r, "id")) var m db.Member
if err != nil { if id, err := xid.FromString(chi.URLParam(r, "id")); err == nil {
return server.APIError{Code: server.ErrBadRequest, Details: "Invalid member ID"} 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) log.Errorf("getting user %v: %v", id, err)
if err != nil { return errors.Wrap(err, "getting user")
if err == db.ErrMemberNotFound { }
return server.APIError{Code: server.ErrMemberNotFound} } 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) m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
return errors.Wrap(err, "getting member") 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) 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" "emperror.dev/errors"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
) )
type resolveReportRequest struct { type resolveReportRequest struct {
@ -43,7 +44,12 @@ func (s *Server) resolveReport(w http.ResponseWriter, r *http.Request) error {
log.Errorf("creating transaction: %v", err) log.Errorf("creating transaction: %v", err)
return errors.Wrap(err, "creating transaction") 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) report, err := s.DB.Report(ctx, tx, id)
if err != nil { 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.Get("/reports/by-reporter/{id}", server.WrapHandler(s.getReportsByReporter))
r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport)) r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport))
r.Post("/notices", server.WrapHandler(s.createNotice))
}) })
r.With(MustAdmin).Handle("/metrics", promhttp.Handler()) r.With(MustAdmin).Handle("/metrics", promhttp.Handler())

View file

@ -3,9 +3,11 @@ package user
import ( import (
"net/http" "net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
) )
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error { 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 { if err != nil {
return errors.Wrap(err, "creating transaction") 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, "") err = s.DB.DeleteUser(ctx, tx, claims.UserID, true, "")
if err != nil { if err != nil {

View file

@ -7,6 +7,7 @@ import (
"codeberg.org/pronounscc/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render" "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) log.Errorf("getting export for user %v: %v", claims.UserID, err)
return err return errors.Wrap(err, "getting export")
} }
render.JSON(w, r, dataExportResponse{ render.JSON(w, r, dataExportResponse{

View file

@ -1,6 +1,7 @@
package user package user
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
@ -12,6 +13,7 @@ import (
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/rs/xid" "github.com/rs/xid"
) )
@ -79,7 +81,12 @@ func (s *Server) postUserFlag(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return errors.Wrap(err, "starting transaction") 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) flag, err := s.DB.CreateFlag(ctx, tx, claims.UserID, req.Name, req.Description)
if err != nil { if err != nil {
@ -121,6 +128,24 @@ type patchUserFlagRequest struct {
Description *string `json:"description"` 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 { func (s *Server) patchUserFlag(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context() ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx) 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"} 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) flags, err := s.DB.AccountFlags(ctx, claims.UserID)
if err != nil { if err != nil {
return errors.Wrap(err, "getting current user flags") return errors.Wrap(err, "getting current user flags")
} }
if len(flags) >= db.MaxPrideFlags {
return server.APIError{ flag, ok := s.parseFlag(ctx, flags, chi.URLParam(r, "flagID"))
Code: server.ErrFlagLimitReached, if !ok {
}
}
var found bool
for _, flag := range flags {
if flag.ID == flagID {
found = true
break
}
}
if !found {
return server.APIError{Code: server.ErrNotFound, Details: "No flag with that ID found"} 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 { if err != nil {
return errors.Wrap(err, "beginning transaction") 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 { if err != nil {
return errors.Wrap(err, "updating flag") 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"} 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 { 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) flag, ok := s.parseFlag(ctx, flags, chi.URLParam(r, "flagID"))
if err != nil { if !ok {
if err == db.ErrFlagNotFound { return server.APIError{Code: server.ErrNotFound, Details: "No flag with that ID found"}
return server.APIError{Code: server.ErrNotFound, Details: "Flag not found"}
}
return errors.Wrap(err, "getting flag object")
} }
if flag.UserID != claims.UserID { if flag.UserID != claims.UserID {
return server.APIError{Code: server.ErrNotFound, Details: "Flag not found"} return server.APIError{Code: server.ErrNotFound, Details: "Flag not found"}
} }

View file

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

View file

@ -12,6 +12,7 @@ import (
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/rs/xid" "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) 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) hash, err := s.DB.WriteUserAvatar(ctx, claims.UserID, webp, jpg)
if err != nil { if err != nil {
log.Errorf("uploading user avatar: %v", err) log.Errorf("uploading user avatar: %v", err)
return err return errors.Wrap(err, "uploading avatar")
} }
avatarHash = &hash avatarHash = &hash
@ -219,9 +220,14 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
tx, err := s.DB.Begin(ctx) tx, err := s.DB.Begin(ctx)
if err != nil { if err != nil {
log.Errorf("creating transaction: %v", err) 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 // update username
if req.Username != nil && *req.Username != u.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) 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 { if err != nil && errors.Cause(err) != db.ErrNothingToUpdate {
log.Errorf("updating user: %v", err) log.Errorf("updating user: %v", err)
return err return errors.Wrap(err, "updating user")
} }
if req.Names != nil || req.Pronouns != nil { 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) err = s.DB.SetUserNamesPronouns(ctx, tx, claims.UserID, names, pronouns)
if err != nil { if err != nil {
log.Errorf("setting names for member %v: %v", claims.UserID, err) log.Errorf("setting names for member %v: %v", claims.UserID, err)
return err return errors.Wrap(err, "setting names/pronouns")
} }
u.Names = names u.Names = names
u.Pronouns = pronouns 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) err = s.DB.SetUserFields(ctx, tx, claims.UserID, *req.Fields)
if err != nil { if err != nil {
log.Errorf("setting fields for user %v: %v", claims.UserID, err) log.Errorf("setting fields for user %v: %v", claims.UserID, err)
return err return errors.Wrap(err, "setting fields")
} }
fields = *req.Fields fields = *req.Fields
} else { } else {
fields, err = s.DB.UserFields(ctx, claims.UserID) fields, err = s.DB.UserFields(ctx, claims.UserID)
if err != nil { if err != nil {
log.Errorf("getting fields for user %v: %v", claims.UserID, err) 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) 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) err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil { if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err) 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) err = tx.Commit(ctx)
if err != nil { if err != nil {
log.Errorf("committing transaction: %v", err) 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 // 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) flags, err := s.DB.UserFlags(ctx, u.ID)
if err != nil { if err != nil {
log.Errorf("getting user flags: %v", err) log.Errorf("getting user flags: %v", err)
return err return errors.Wrap(err, "getting flags")
} }
// echo the updated user back on success // 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 package server
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/log" "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" "github.com/go-chi/render"
) )
@ -12,6 +16,11 @@ import (
// The inner HandlerFunc additionally returns an error. // The inner HandlerFunc additionally returns an error.
func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc { func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
hub := sentry.GetHubFromContext(r.Context())
if hub == nil {
hub = sentry.CurrentHub().Clone()
}
err := hn(w, r) err := hn(w, r)
if err != nil { if err != nil {
// if the function returned an API error, just render that verbatim // 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 return
} }
// otherwise, we log the error and return an internal server error message rctx := chi.RouteContext(r.Context())
log.Errorf("error in http handler: %v", err) 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() apiErr.prepare()
render.Status(r, apiErr.Status) 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. // APIError is an object returned by the API when an error occurs.
// It implements the error interface and can be returned by handlers. // It implements the error interface and can be returned by handlers.
type APIError struct { type APIError struct {
Code int `json:"code"` Code int `json:"code"`
Message string `json:"message,omitempty"` ID *sentry.EventID `json:"id,omitempty"`
Details string `json:"details,omitempty"` Message string `json:"message,omitempty"`
Details string `json:"details,omitempty"`
RatelimitReset *int `json:"ratelimit_reset,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.Logger)
} }
s.Router.Use(middleware.Recoverer) s.Router.Use(middleware.Recoverer)
// add Sentry tracing handler
s.Router.Use(s.sentry)
// add CORS // add CORS
s.Router.Use(cors.Handler(cors.Options{ s.Router.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"https://*", "http://*"}, AllowedOrigins: []string{"https://*", "http://*"},
@ -97,23 +100,23 @@ func New() (*Server, error) {
// set scopes // set scopes
// users // users
rateLimiter.Scope("GET", "/users/*", 60) _ = rateLimiter.Scope("GET", "/users/*", 60)
rateLimiter.Scope("PATCH", "/users/@me", 10) _ = rateLimiter.Scope("PATCH", "/users/@me", 10)
// members // 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("POST", "/members", 10)
rateLimiter.Scope("GET", "/members/*", 60) _ = rateLimiter.Scope("GET", "/members/*", 60)
rateLimiter.Scope("PATCH", "/members/*", 20) _ = rateLimiter.Scope("PATCH", "/members/*", 20)
rateLimiter.Scope("DELETE", "/members/*", 5) _ = rateLimiter.Scope("DELETE", "/members/*", 5)
// auth // auth
rateLimiter.Scope("*", "/auth/*", 20) _ = rateLimiter.Scope("*", "/auth/*", 20)
rateLimiter.Scope("*", "/auth/tokens", 10) _ = rateLimiter.Scope("*", "/auth/tokens", 10)
rateLimiter.Scope("*", "/auth/invites", 10) _ = rateLimiter.Scope("*", "/auth/invites", 10)
rateLimiter.Scope("POST", "/auth/discord/*", 10) _ = rateLimiter.Scope("POST", "/auth/discord/*", 10)
s.Router.Use(rateLimiter.Handler()) 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 ## Pride flag
| Field | Type | Description | | Field | Type | Description |
| ----------- | ------- | ------------------------------------- | | ----------- | --------- | ------------------------------------- |
| id | string | the flag's unique ID | | id | string | the flag's unique ID |
| hash | string | the flag's [image hash](/api/#images) | | id_new | snowflake | the flag's unique snowflake ID |
| name | string | the flag's name | | hash | string | the flag's [image hash](/api/#images) |
| description | string? | the flag's description or alt text | | name | string | the flag's name |
| description | string? | the flag's description or alt text |

View file

@ -5,6 +5,7 @@
| Field | Type | Description | | Field | Type | Description |
| ------------ | ---------------------------------------------------- | --------------------------------------------------------------------------------- | | ------------ | ---------------------------------------------------- | --------------------------------------------------------------------------------- |
| id | string | the member's unique ID | | 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 | | sid | string | the member's 6-letter short ID |
| name | string | the member's name | | name | string | the member's name |
| display_name | string? | the member's display name or nickname | | display_name | string? | the member's display name or nickname |
@ -23,6 +24,7 @@
| Field | Type | Description | | Field | Type | Description |
| ------------------ | ---------------------------------------------------- | -------------------------------------- | | ------------------ | ---------------------------------------------------- | -------------------------------------- |
| id | string | the user's unique ID | | id | string | the user's unique ID |
| id_new | snowflake | the user's unique snowflake ID |
| name | string | the user's username | | name | string | the user's username |
| display_name | string? | the user's display name or nickname | | display_name | string? | the user's display name or nickname |
| avatar | string? | the user's [avatar hash](/api/#images) | | 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 #### Request body parameters
| Field | Type | Description | | Field | Type | Description |
| ------------------ | -------------------- | --------------------------------------------------------------------------------------------------- | | ------------ | --------------- | ------------------------------------------------------------------------------------------------------ |
| name | string | the member's new name. Must be unique per user, and be between 1 and 100 characters. | | 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 | | 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 | | 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 | | 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 | | names | field_entry[] | the member's new preferred names |
| pronouns | pronoun_entry[] | the member's new preferred pronouns | | pronouns | pronoun_entry[] | the member's new preferred pronouns |
| fields | field[] | the member's new profile fields | | 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. | | 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 | | 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 | | unlisted | bool | whether or not the member should be hidden from the member list |
### Delete member ### Delete member

View file

@ -5,6 +5,7 @@
| Field | Type | Description | | Field | Type | Description |
| ------------------ | ---------------------------------------------------- | --------------------------------------------------------------------------- | | ------------------ | ---------------------------------------------------- | --------------------------------------------------------------------------- |
| id | string | the user's unique ID | | 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 | | sid | string | the user's 5 letter short ID |
| name | string | the user's username | | name | string | the user's username |
| display_name | string? | the user's display name or nickname | | display_name | string? | the user's display name or nickname |
@ -45,6 +46,7 @@
| Field | Type | Description | | Field | Type | Description |
| ------------ | ----------------------------------- | ---------------------------------------- | | ------------ | ----------------------------------- | ---------------------------------------- |
| id | string | the member's unique ID | | 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 | | sid | string | the member's 6-letter short ID |
| name | string | the member's name | | name | string | the member's name |
| display_name | string? | the member's display name or nickname | | 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 | | names | field_entry[] | the user's new preferred names |
| pronouns | pronoun_entry[] | the user's new preferred pronouns | | pronouns | pronoun_entry[] | the user's new preferred pronouns |
| fields | field[] | the user's new profile fields | | 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 | | 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 | | 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 | | 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. 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 | | Field | Type | Description |
| --------------- | ------- | --------------------------------------------------------------- | | --------------- | ------- | ------------------------------------------------------------------- |
| code | int | an [error code](./errors#error-codes) | | code | int | an [error code](./errors#error-codes) |
| message | ?string | a human-readable description of the error | | id | ?string | an opaque Sentry event ID, only returned for internal server errors |
| details | ?string | more details about the error, most often for bad request errors | | message | ?string | a human-readable description of the error |
| ratelimit_reset | ?int | the unix time when an expired rate limit will reset | | 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 ### Error codes

View file

@ -62,19 +62,23 @@ The "type" column in tables is formatted as follows:
## IDs ## IDs
::: info ### Snowflake IDs
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.
:::
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. 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). 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`. 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. 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 ## Images

View file

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

View file

@ -1,20 +1,31 @@
module.exports = { module.exports = {
root: true, root: true,
parser: '@typescript-eslint/parser', parser: "@typescript-eslint/parser",
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'], extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"],
plugins: ['svelte3', '@typescript-eslint'], plugins: ["svelte3", "@typescript-eslint"],
ignorePatterns: ['*.cjs'], ignorePatterns: ["*.cjs"],
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }], overrides: [{ files: ["*.svelte"], processor: "svelte3/svelte3" }],
settings: { settings: {
'svelte3/typescript': () => require('typescript') "svelte3/typescript": () => require("typescript"),
}, },
parserOptions: { parserOptions: {
sourceType: 'module', sourceType: "module",
ecmaVersion: 2020 ecmaVersion: 2020,
}, },
env: { env: {
browser: true, browser: true,
es2017: true, es2017: true,
node: 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 const goCode1 = `// Generated code. DO NOT EDIT
package icons package icons
var icons = [...]string{ var icons = map[string]struct{}{
`; `;
const goCode2 = `} const goCode2 = `}
// IsValid returns true if the input is the name of a Bootstrap icon. // IsValid returns true if the input is the name of a Bootstrap icon.
func IsValid(name string) bool { func IsValid(name string) bool {
for i := range icons { _, ok := icons[name]
if icons[i] == name { return ok
return true
}
}
return false
} }
`; `;
let goOutput = goCode1; let goOutput = goCode1;
keys.forEach((element) => { keys.forEach((element) => {
goOutput += ` "${element}",\n`; goOutput += ` "${element}": {},\n`;
}); });
goOutput += goCode2; goOutput += goCode2;

View file

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

View file

@ -2,7 +2,9 @@ import { PRIVATE_SENTRY_DSN } from "$env/static/private";
import * as Sentry from "@sentry/node"; import * as Sentry from "@sentry/node";
import type { HandleServerError } from "@sveltejs/kit"; 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 }) => { export const handleError = (({ error, event }) => {
console.log(error); 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"; import { PUBLIC_BASE_URL, PUBLIC_MEDIA_URL } from "$env/static/public";
export const MAX_MEMBERS = 500; export const MAX_MEMBERS = 500;
@ -7,6 +8,7 @@ export const MAX_FLAGS = 500;
export interface User { export interface User {
id: string; id: string;
id_new: string;
sid: string; sid: string;
name: string; name: string;
display_name: string | null; display_name: string | null;
@ -61,8 +63,14 @@ export interface MeUser extends User {
timezone: string | null; timezone: string | null;
} }
export interface Settings {
read_changelog: string;
read_settings_notice: string;
read_global_notice: number;
}
export interface Field { export interface Field {
name: string; name: string | null;
entries: FieldEntry[]; entries: FieldEntry[];
} }
@ -79,6 +87,7 @@ export interface Pronoun {
export interface PartialMember { export interface PartialMember {
id: string; id: string;
id_new: string;
sid: string; sid: string;
name: string; name: string;
display_name: string | null; display_name: string | null;
@ -99,6 +108,7 @@ export interface Member extends PartialMember {
export interface MemberPartialUser { export interface MemberPartialUser {
id: string; id: string;
id_new: string;
name: string; name: string;
display_name: string | null; display_name: string | null;
avatar: string | null; avatar: string | null;
@ -107,6 +117,7 @@ export interface MemberPartialUser {
export interface PrideFlag { export interface PrideFlag {
id: string; id: string;
id_new: string;
hash: string; hash: string;
name: string; name: string;
description: string | null; description: string | null;

View file

@ -11,9 +11,16 @@ export async function apiFetch<T>(
body, body,
token, token,
headers, 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", method: method || "GET",
headers: { headers: {
...(token ? { Authorization: token } : {}), ...(token ? { Authorization: token } : {}),
@ -28,12 +35,18 @@ export async function apiFetch<T>(
return data as 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 { try {
const data = await apiFetch<T>(path, { const data = await apiFetch<T>(path, {
method, method,
body, body,
token: localStorage.getItem("pronouns-token") || undefined, token: localStorage.getItem("pronouns-token") || undefined,
version,
}); });
return data; return data;
} catch (e) { } catch (e) {
@ -55,9 +68,16 @@ export async function fastFetch(
body, body,
token, token,
headers, 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", method: method || "GET",
headers: { headers: {
...(token ? { Authorization: token } : {}), ...(token ? { Authorization: token } : {}),
@ -71,12 +91,18 @@ export async function fastFetch(
} }
/** Fetches the specified path without parsing the response body. */ /** 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 { try {
await fastFetch(path, { await fastFetch(path, {
method, method,
body, body,
token: localStorage.getItem("pronouns-token") || undefined, token: localStorage.getItem("pronouns-token") || undefined,
version,
}); });
} catch (e) { } catch (e) {
if ((e as APIError).code === ErrorCode.InvalidToken) { if ((e as APIError).code === ErrorCode.InvalidToken) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,12 +6,12 @@
type User, type User,
type CustomPreferences, type CustomPreferences,
} from "$lib/api/entities"; } from "$lib/api/entities";
import { Icon, Tooltip } from "sveltestrap"; import { Icon, Tooltip } from "@sveltestrap/sveltestrap";
import FallbackImage from "./FallbackImage.svelte"; import FallbackImage from "./FallbackImage.svelte";
export let user: User; export let user: User;
export let member: PartialMember & { export let member: PartialMember & {
unlisted?: boolean unlisted?: boolean;
}; };
let pronouns: string | undefined; let pronouns: string | undefined;
@ -46,13 +46,18 @@
<div> <div>
<a href="/@{user.name}/{member.name}"> <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> </a>
<p class="m-2"> <p class="m-2">
<a class="text-reset fs-5 text-break" href="/@{user.name}/{member.name}"> <a class="text-reset fs-5 text-break" href="/@{user.name}/{member.name}">
{member.display_name ?? member.name} {member.display_name ?? member.name}
{#if member.unlisted === true} {#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> <Tooltip target={iconElement} placement="top">This member is hidden</Tooltip>
{/if} {/if}
</a> </a>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { browser } from "$app/environment"; import { browser } from "$app/environment";
import type { MeUser } from "./api/entities"; import type { MeUser, Settings } from "./api/entities";
const initialUserValue = null; const initialUserValue = null;
export const userStore = writable<MeUser | null>(initialUserValue); export const userStore = writable<MeUser | null>(initialUserValue);
@ -13,4 +13,10 @@ const initialThemeValue = browser
export const themeStore = writable<string>(initialThemeValue); 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"; export const CURRENT_CHANGELOG = "0.6.0";

View file

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

View file

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

View file

@ -10,9 +10,13 @@
import Navigation from "./nav/Navigation.svelte"; import Navigation from "./nav/Navigation.svelte";
import type { LayoutData } from "./$types"; import type { LayoutData } from "./$types";
import { version } from "$app/environment"; import { version } from "$app/environment";
import { settingsStore } from "$lib/store";
import { toastStore } from "$lib/toast"; import { toastStore } from "$lib/toast";
import Toast from "$lib/components/Toast.svelte"; 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; export let data: LayoutData;
@ -21,6 +25,22 @@
if (versionParts.length >= 3) commit = versionParts[2].slice(1); if (versionParts.length >= 3) commit = versionParts[2].slice(1);
const versionMismatch = data.git_commit !== commit && data.git_commit !== "[unknown]"; 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> </script>
<svelte:head> <svelte:head>
@ -34,6 +54,12 @@
<div class="flex-grow-1"> <div class="flex-grow-1">
<Navigation commit={data.git_commit} /> <Navigation commit={data.git_commit} />
<div class="container"> <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 /> <slot />
<div class="position-absolute top-0 start-50 translate-middle-x"> <div class="position-absolute top-0 start-50 translate-middle-x">
{#each $toastStore as toast} {#each $toastStore as toast}

View file

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

View file

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

View file

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

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Icon } from "sveltestrap"; import { Icon } from "@sveltestrap/sveltestrap";
export let link: string; export let link: string;
@ -21,7 +21,7 @@
</script> </script>
{#if isLink} {#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"> <li class="py-2 py-lg-0">
<Icon name="globe" aria-hidden class="text-body" /> <Icon name="globe" aria-hidden class="text-body" />
<span class="text-decoration-underline">{displayLink}</span> <span class="text-decoration-underline">{displayLink}</span>

View file

@ -3,7 +3,7 @@
import { fastFetchClient } from "$lib/api/fetch"; import { fastFetchClient } from "$lib/api/fetch";
import ErrorAlert from "$lib/components/ErrorAlert.svelte"; import ErrorAlert from "$lib/components/ErrorAlert.svelte";
import { addToast } from "$lib/toast"; 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 subject: string;
export let reportUrl: 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.UserNotFound ||
(e as APIError).code === ErrorCode.MemberNotFound (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 type { PageData } from "./$types";
import PronounLink from "$lib/components/PronounLink.svelte"; import PronounLink from "$lib/components/PronounLink.svelte";
import FallbackImage from "$lib/components/FallbackImage.svelte"; import FallbackImage from "$lib/components/FallbackImage.svelte";
import { Alert, Button, Icon, InputGroup } from "sveltestrap"; import { Alert, Button, Icon, InputGroup } from "@sveltestrap/sveltestrap";
import { import {
memberAvatars, memberAvatars,
pronounDisplay, pronounDisplay,
@ -22,6 +22,7 @@
import { addToast } from "$lib/toast"; import { addToast } from "$lib/toast";
import ProfileFlag from "../ProfileFlag.svelte"; import ProfileFlag from "../ProfileFlag.svelte";
import IconButton from "$lib/components/IconButton.svelte"; import IconButton from "$lib/components/IconButton.svelte";
import PreferencesCheatsheet from "../PreferencesCheatsheet.svelte";
export let data: PageData; export let data: PageData;
@ -154,6 +155,12 @@
</div> </div>
{/each} {/each}
</div> </div>
<PreferencesCheatsheet
preferences={data.user.custom_preferences}
names={data.names}
pronouns={data.pronouns}
fields={data.fields}
/>
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<InputGroup> <InputGroup>
@ -170,7 +177,7 @@
/> />
{/if} {/if}
{#if $userStore && $userStore.id !== data.user.id} {#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} {/if}
</InputGroup> </InputGroup>
</div> </div>

View file

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

View file

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

View file

@ -3,7 +3,7 @@
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import { encode } from "base64-arraybuffer"; 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 { memberAvatars, type Member } from "$lib/api/entities";
import FallbackImage from "$lib/components/FallbackImage.svelte"; import FallbackImage from "$lib/components/FallbackImage.svelte";
import EditableName from "$lib/components/edit/EditableName.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 { MAX_DESCRIPTION_LENGTH, type Member } from "$lib/api/entities";
import { charCount, renderMarkdown } from "$lib/utils"; import { charCount, renderMarkdown } from "$lib/utils";
import MarkdownHelp from "$lib/components/edit/MarkdownHelp.svelte"; 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"); const member = getContext<Writable<Member>>("member");
</script> </script>

View file

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { Writable } from "svelte/store"; 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 type { Member } from "$lib/api/entities";
import EditableField from "$lib/components/edit/EditableField.svelte"; import EditableField from "$lib/components/edit/EditableField.svelte";
@ -27,10 +27,10 @@
</script> </script>
{#if $member.fields.length === 0} {#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 /> 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. For example, you could use them for gender terms, honorifics, or compliments.
</Alert> </div>
{/if} {/if}
<div class="grid gap-3"> <div class="grid gap-3">
<div class="row row-cols-1 row-cols-md-2"> <div class="row row-cols-1 row-cols-md-2">
@ -45,9 +45,7 @@
</div> </div>
</div> </div>
<div> <div>
<Button <Button on:click={() => ($member.fields = [...$member.fields, { name: null, entries: [] }])}>
on:click={() => ($member.fields = [...$member.fields, { name: "New field", entries: [] }])}
>
<Icon name="plus" aria-hidden /> Add new field <Icon name="plus" aria-hidden /> Add new field
</Button> </Button>
</div> </div>

View file

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

View file

@ -2,7 +2,7 @@
import { getContext } from "svelte"; import { getContext } from "svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { DateTime } from "luxon"; 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 type { APIError, Member } from "$lib/api/entities";
import { PUBLIC_SHORT_BASE } from "$env/static/public"; import { PUBLIC_SHORT_BASE } from "$env/static/public";
@ -25,7 +25,7 @@
const rerollSid = async () => { const rerollSid = async () => {
try { 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!" }); addToast({ header: "Success", body: "Rerolled short ID!" });
error = null; error = null;
$member.sid = resp.sid; $member.sid = resp.sid;

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