Compare commits

..

298 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
c6195218c5
docs: add other endpoints 2023-09-02 03:52:38 +02:00
sam
bc1948316c
docs: add member endpoints 2023-09-01 17:02:23 +02:00
sam
50b584c8ea
move docs-site to docs 2023-08-21 15:58:07 +02:00
sam
4aa4d35362
move self-hosting documentation to docs site 2023-08-21 15:57:23 +02:00
sam
4df9a4c368
update docs 2023-08-21 15:45:16 +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
d05e1d241c start documentation site 2023-08-20 22:38:53 +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
sam
0c2eeaf954
chore: add down migrations 2023-08-16 03:30:34 +02:00
sam
b826fb3ce6
chore: move backend routes to v1/ subdirectory 2023-08-16 03:12:25 +02:00
sam
b66188cbf9
update changelog 2023-08-14 03:02:10 +02:00
sam
49eb964ed8
feat: allow user to pick timezone manually 2023-08-14 02:32:58 +02:00
sam
9ee6f318c7
merge: reworked edit pages 2023-08-14 02:15:40 +02:00
sam
5fe5f09032
redirect from /edit/member/{id} to new page, add error page for redirects 2023-08-14 02:14:12 +02:00
sam
03311d7004
move member edit page to /@user/member/edit 2023-08-14 02:03:09 +02:00
sam
56c9270fdb
edit member page progress 2023-08-12 17:01:01 +02:00
sam
2f34cd20ba
merge: fix(prns): url encode unicode 2023-08-12 15:01:06 +02:00
sam
cd3e4ef6c5
fix(prns): url encode unicode 2023-08-12 15:00:26 +02:00
sam
b2b3fb37ec
fix: change frontend error object to APIError 2023-08-11 16:33:17 +02:00
sam
b3e191f01a
move /edit/profile to /@username/edit 2023-08-10 21:03:13 +02:00
sam
785f94dd9f
split entire edit user profile page 2023-08-10 20:48:29 +02:00
sam
c92f4c4ba7
feat(backend): disallow some names due to potential confusion or potential errors 2023-08-10 18:26:53 +02:00
sam
575aa01fa5
add fields and flags to new edit page 2023-08-10 18:09:10 +02:00
sam
61f1464e37
add bio and pronouns subpages 2023-08-07 03:10:46 +02:00
sam
93a113206f
tweak: always show 'save changes' button 2023-08-06 15:38:33 +02:00
sam
e0069a9375
feat(frontend): start splitting edit profile page into subpages 2023-08-04 20:52:15 +02:00
sam
eba31f8bda
update changelog 2023-08-02 23:50:44 +02:00
sam
846483ee17
update go.mod 2023-08-02 23:42:40 +02:00
sam
2a4ddaeea5
feat: add timezone display 2023-08-02 23:37:22 +02:00
sam
32ad02a260
tweak detect timezone button placement 2023-08-02 23:27:28 +02:00
sam
3e3ccd971b
feat: add timezone settings 2023-08-02 23:24:38 +02:00
sam
038de34f8f
feat: show max number of flags in settings 2023-07-31 02:17:50 +02:00
sam
e10db2fa09
feat: display timezone 2023-07-30 23:13:35 +02:00
sam
309aa569f6
rename pronouns to pronounslib because the executable is already pronouns 2023-07-30 21:37:04 +02:00
sam
bbd7623855
fix(backend): recognise firefish as misskey and hometown as mastodon 2023-07-30 21:33:16 +02:00
sam
ccd546759b
feat: don't use shared models for prns
The shared models will eventually contain every field in the database,
but prns.cc only needs the fields they currently have, so to cut down on
memory usage it shouldn't use the shared models.
2023-07-28 21:01:36 +02:00
sam
ca138efc8f
update Cargo.lock 2023-07-28 02:31:11 +02:00
sam
74ac9396c9
polish prns.cc code 2023-07-28 02:21:33 +02:00
sam
6754296a48
feat: port prns.cc to � blazing fast � rust 2023-07-27 23:51:12 +02:00
sam
d50f34529c
Merge branch 'main' into rust 2023-07-27 22:53:43 +02:00
sam
c046ae57f2
fix(backend): invert api token check in /auth/warnings/{id}/ack 2023-07-27 00:55:51 +02:00
sam
f608b1046b Merge pull request 'fix: don't copy custom preferences to default preferences' (#83) from fulmine/pronouns.cc:fix/custom-preferences into main
Reviewed-on: https://codeberg.org/pronounscc/pronouns.cc/pulls/83
2023-07-26 22:51:50 +00:00
Jake
dc73f8e3b1 fix: don't copy custom preferences to default preferences 2023-07-17 20:35:45 +02:00
Sam
969e06e31e
*actually* cache counts 2023-06-25 15:55:44 +02:00
Sam
80cf699a73
feat(backend): cache user/member counts 2023-06-25 15:49:19 +02:00
Sam
af1403d0c9
feat(frontend): add disclaimer to report modal 2023-06-25 14:11:49 +02:00
Sam
836029cb7b
merge 2023-06-23 03:28:01 +02:00
Sam
c61186b22a
feat(backend): add request latency tracking 2023-06-23 03:27:09 +02:00
sam
7dd953ef84 Merge pull request 'fix(fediauth): treat 'gotosocial' software as mastodon' (#74) from ewin/pronouns.cc:fix/gotosocial-login into main
Reviewed-on: https://codeberg.org/pronounscc/pronouns.cc/pulls/74
2023-06-16 13:20:05 +00:00
sam
4c4037fafc Merge pull request 'Add .env.example files and clarify dev setup docs' (#75) from ewin/pronouns.cc:better-dev-env-docs into main
Reviewed-on: https://codeberg.org/pronounscc/pronouns.cc/pulls/75
2023-06-16 13:19:33 +00:00
Erin
37e5c78e35 fix: typo 2023-06-15 23:31:15 -04:00
Erin
dad6bc042d clarify how to generate HMAC_KEY 2023-06-15 23:13:43 -04:00
Erin
0140265912 clarify note on MINIO_ENDPOINT 2023-06-15 23:13:29 -04:00
Erin
054582103b Add example env files and fixup setup instructions 2023-06-15 23:12:42 -04:00
erin
fd58773472 fix: treat 'gotosocial' software as mastodon 2023-06-15 18:38:11 -04:00
Sam
0a012d75af
fix: treat 'glitchcafe' software as mastodon 2023-06-15 17:23:24 +02:00
Sam
0fb6ac64a6
merge: 'feat: add unlisted member indicator to member list' (#69) 2023-06-10 16:47:01 +02:00
Jake
7094a52166 Merge branch 'main' of codeberg.org:pronounscc/pronouns.cc 2023-06-07 09:49:10 +02:00
Sam
fad5bd5e4a feat: add admin badge on profiles 2023-06-07 09:48:11 +02:00
Jake
045bfa5981 add this member is hidden tooltip 2023-06-07 09:48:11 +02:00
Sam
9bba037eab
feat: add admin badge on profiles 2023-06-05 16:29:18 +02:00
Jake
a6526b7f00 feat: add unlisted member indicator to member list 2023-06-05 12:43:33 +02:00
Sam
d25545fa03
change import url 2023-06-03 16:18:47 +02:00
Sam
4c1ef1c8d6
fix: readd auth link in settings page i accidentally removed 2023-06-03 03:36:21 +02:00
Sam
3fe654a220
fix: svelte didn't work the way i thought it did 2023-06-03 03:17:24 +02:00
Sam
8d0c2cce73
update changelog 2023-06-03 03:11:15 +02:00
Sam
10dc59d3d4
feat: add short IDs + link shortener 2023-06-03 03:06:26 +02:00
Sam
7c94c088e0
raise flag limit from 100 to 500 2023-06-02 16:48:32 +02:00
Sam
e98d86bd2e
fix(frontend): make totalPages in user page reactive 2023-05-31 16:36:41 +02:00
Sam
c866cbb939
feat: add snapshot to /edit/profile and /edit/member/:id 2023-05-29 17:00:09 +02:00
Sam
3442f7a518
fix: work around cloudflare r2 not conforming to s3 api standards 2023-05-29 04:11:38 +02:00
Sam
05cba6937f
update changelog 2023-05-29 04:05:45 +02:00
Sam
52f9cfe881
fix: fix error in merge 2023-05-29 04:04:24 +02:00
Sam
e993d2a89e
merge branch 'feature/flags' 2023-05-29 03:27:26 +02:00
Sam
5b954778cf
feat: add flags to profile pages 2023-05-29 03:26:56 +02:00
Sam
c28df5fc7f
fix: enforce maximum number of flags on profile 2023-05-29 03:26:56 +02:00
Sam
8f1d1fc87c
feat: add flags to edit member page 2023-05-29 03:26:55 +02:00
Sam
4ebc5d5003
feat: add flags to edit profile page 2023-05-29 03:26:55 +02:00
Sam
21cce9c5af
fix: don't delete flag *objects* because flags can share hashes 2023-05-29 03:26:55 +02:00
Sam
67d275f15b
feat: add editing and deleting flags 2023-05-29 03:26:55 +02:00
Sam
8b03521382
feat: add list/upload flag UI 2023-05-29 03:26:54 +02:00
Sam
a4698e179a
feat: add DELETE /users/@me/flags/{id} 2023-05-29 03:26:54 +02:00
Sam
1360a52488
add PATCH /users/@me/flags/{id} 2023-05-29 03:26:54 +02:00
Sam
ea2ae94742
feat: add flags to PATCH /members/{id} 2023-05-29 03:26:53 +02:00
Sam
1b78462f50
feat: add flags to PATCH /users/@me 2023-05-29 03:26:53 +02:00
Sam
c69c777fc8
feat: GET /users/@me/flags, POST /users/@me/flags 2023-05-29 03:26:53 +02:00
Sam
7435604dab
add Caddyfile to docs 2023-05-27 23:46:12 +02:00
Sam
1f138bee16
Revert "announce server migration"
This reverts commit 2cf5473a06.
2023-05-27 04:22:33 +02:00
Sam
2cf5473a06
announce server migration 2023-05-26 16:18:16 +02:00
Sam
5bac691d15
fix: allow all methods in CORS 2023-05-25 00:33:54 +02:00
Sam
8f8daaa331
fix: don't error out if API is not running during frontend build 2023-05-24 16:17:30 +02:00
Sam
b7e0286cc7
feat: add custom error page + link to status page 2023-05-24 00:23:11 +02:00
Sam
9a70245c2d
feat: add /users/@me/members/{memberRef} route (closes #62) 2023-05-23 16:32:02 +02:00
Sam
23f79b0fec
fix: i missed one path 2023-05-22 16:02:00 +02:00
Sam
bf34c77269
fix: remove leading / from s3 paths 2023-05-22 15:59:49 +02:00
Sam
295b76aad2
fix cloudflare r2 support? 2023-05-22 15:00:05 +02:00
Sam
71ae1b1aa5
feat: allow separate domain for media 2023-05-22 14:48:48 +02:00
Sam
4123f957f0
fix: silence libvips 2023-05-22 00:36:21 +02:00
Sam
ed4882b817
feat: add link to API docs 2023-05-19 04:52:58 +02:00
Sam
e0d08270bf
feat: add OpenAPI definition 2023-05-19 04:50:11 +02:00
Sam
c3291edd4f
feat: expose some more info in /settings 2023-05-19 03:13:46 +02:00
Sam
130a1996d7
feat: improve report ui 2023-05-17 13:26:23 +02:00
Sam
a72546658f
feat: add plausible analytics 2023-05-17 11:38:17 +02:00
Sam
1319366637
feat(backend): switch to libvips for avatar resizing 2023-05-17 00:49:35 +02:00
Sam
9c4e29e64f
fix(backend): mention disallowed names in error messages 2023-05-12 01:39:02 +02:00
Sam
4f43e32fdb
fix(backend): disallow '.' and '..' in user and member names 2023-05-12 01:09:02 +02:00
Sam
0e9ac347c0
update changelog 2023-05-11 01:38:11 +02:00
Sam
7c7f948ad6
feat: move remaining go scripts to main executable 2023-05-11 01:13:32 +02:00
Sam
4e056632c8
fix(backend): return display_name in GET /users/:id/members 2023-05-10 00:46:25 +02:00
Sam
ee25781f2b
feat: default to dark theme while loading pages 2023-05-09 14:27:26 +02:00
Sam
1c5fe1e25d
feat: make 'dev' indicator less intrusive 2023-05-08 23:14:27 +02:00
Sam
f252340b3a
feat: fetch full members list when navigating to own user page 2023-05-08 23:07:50 +02:00
Sam
26b0d297ab
feat: add warning on edit member page if member list is private 2023-05-08 22:59:25 +02:00
Sam
6decfab248
feat: improve icon picker 2023-05-08 17:30:27 +02:00
Sam
48b41c7135
feat: move 'beta' text out of logo svg 2023-05-08 15:56:20 +02:00
Sam
9a36e4ec24
feat: show active link in navbar 2023-05-08 15:38:27 +02:00
Sam
de460720da
feat: expose active user counts in API 2023-05-06 15:59:52 +02:00
Sam
e8d9ccb1ac
fix: don't log captcha responses 2023-05-06 15:53:07 +02:00
Sam
cf95e69349
feat: add last active time per user 2023-05-02 02:54:08 +02:00
Sam
90c7dcf891
feat: add database latency metric 2023-05-02 02:26:51 +02:00
Sam
3f003b5353
feat: disallow {} in member names 2023-05-02 02:18:35 +02:00
Sam
a33f39afe5
chore: update pnpm lock file 2023-05-01 03:07:45 +02:00
Sam
136edee506
feat: rename PATCH /users/@me 'username' param to 'name' for consistency 2023-04-26 13:15:57 +02:00
Sam
e5ec3dcbeb
fix: don't show report button for own members 2023-04-26 09:19:58 +02:00
Sam
80a0257832
Merge pull request 'use npm package for self-hosting fonts' (#57) from lucrnz/pronouns.cc:fontsource into main 2023-04-25 00:55:31 +02:00
lucdev
15d2f4acba
delete download-fonts.sh 2023-04-24 14:03:57 -03:00
lucdev
181d33517e
use npm package to self-host fonts 2023-04-24 13:59:52 -03:00
Sam
95e7951c76
update changelog 2023-04-24 17:04:48 +02:00
Sam
6ae6ea5496
feat: add page buttons below member list too 2023-04-24 17:03:05 +02:00
Sam
6f7eb5eeee
feat: add captcha when signing up (closes #53) 2023-04-24 16:51:55 +02:00
Sam
bb3d56f548
fix(frontend): fix save button not showing up when deleting custom preferences (fixes #55) 2023-04-23 23:06:53 +02:00
Sam
848d0787a5
feat(frontend): add move buttons to links (fixes #54) 2023-04-23 04:01:02 +02:00
Sam
21c1c5b0d9
feat(frontend): add "copy link" button to profiles 2023-04-22 15:04:38 +02:00
Sam
e656316e19
feat(frontend): tweak member list rows 2023-04-22 04:40:45 +02:00
Sam
1dc4089cef
fix(frontend): use new but unsaved custom preferences on edit profile page 2023-04-21 16:50:27 +02:00
Sam
bd279a7dae
fix(frontend): use 'treat as favourite' preferences as favourites in member list 2023-04-21 16:37:26 +02:00
Sam
5594463a09
fix(backend): use to-be-set custom preferences when validating fields, remove constants 2023-04-21 16:35:13 +02:00
Sam
6dd3478ff9
fix: abort if oauth user info is invalid 2023-04-21 00:07:02 +02:00
Sam
61b69d1026
feat: add changelog 2023-04-20 10:28:07 +02:00
Sam
75abe1a897
tweak wording 2023-04-20 10:27:57 +02:00
Sam
0a36fd5703
feat(frontend): add donate message to user settings page
Running a website is expensive :( and the existing link is a *little*
buried
2023-04-20 10:07:10 +02:00
sam
2a15c519f3 Merge pull request 'add custom name/label/pronoun preferences (closes #42)' (#51) from feature/custom-preferences into main
Reviewed-on: https://codeberg.org/u1f320/pronouns.cc/pulls/51
2023-04-20 07:33:23 +00:00
Sam
70b4417128 fix(frontend): fix eslint errors 2023-04-20 07:33:02 +00:00
Sam
d691d4b151 feat(frontend): show favourite preferences in user and member page descriptions 2023-04-20 07:33:02 +00:00
Sam
b4501f5ede fix(frontend): search icons by prefix, not contains 2023-04-20 07:33:02 +00:00
Sam
cd8f165a17 fix(backend): check number of custom preferences in patch 2023-04-20 07:33:02 +00:00
Sam
9a80bb2e9b feat(frontend): allow editing + using custom preferences 2023-04-20 07:33:02 +00:00
Sam
8bda5f9860 feat(frontend): show custom preferences 2023-04-20 07:33:02 +00:00
Sam
2c71741d7c feat(backend): add custom preferences 2023-04-20 07:33:01 +00:00
Sam
e8ea642260 save frontend icons as ts instead 2023-04-20 07:33:01 +00:00
Sam
7ea5efae93 feat: start custom preferences on backend 2023-04-20 07:33:01 +00:00
Sam
86a1841f4f
fix(backend): don't use redis GETDEL 2023-04-20 01:30:33 +02:00
Sam
661f0254fd
fix: log user out if token is invalid (fixes #50) 2023-04-19 00:41:20 +02:00
Sam
f5d7bc4095
feat: only show auth providers if they're enabled 2023-04-18 23:31:57 +02:00
Sam
17f2552c6a
Merge branch 'main' of codeberg.org:u1f320/pronouns.cc 2023-04-18 22:53:18 +02:00
Sam
488544dd5f
feat: add google oauth 2023-04-18 22:52:58 +02:00
Sam
e6c7954a88
fix: add unlink tumblr modal 2023-04-18 22:44:37 +02:00
sam
8588da8b80 fix: switch arguments in UpdateFromTumblr 2023-04-18 15:48:09 +00:00
Sam
716c1283e7
feat: add tumblr oauth 2023-04-18 03:49:37 +02:00
Sam
6131884ba7
fix: reject instance domains with @ in them 2023-04-18 02:15:45 +02:00
Sam
5c8c6eed63
feat: add prometheus metrics 2023-04-17 23:44:21 +02:00
Sam
b4c331daa0
fix: fix tokens to expire after 3 months and always inherit admin perms from user 2023-04-17 23:43:04 +02:00
Sam
e8f502073d
disable sentry for a bit; most errors are 404s anyway 2023-04-17 23:10:59 +02:00
Sam
7a550dc624
feat(frontend): expose user/member count 2023-04-17 16:43:06 +02:00
Sam
94cd4cd6d3
fix(backend): don't count deleted users + unlisted members in meta endpoint
This technically leaked the *existence* of these users and members,
but there's never been any way to enumerate users or unlisted members,
so this is unlikely to have *actually* leaked any information. Still,
for consistency's sake, this commit hides them from the user/member
count.
2023-04-17 16:33:05 +02:00
Sam
ec6b048501
fix(frontend): fall back to full pronoun set if it's a malformed set 2023-04-17 16:22:45 +02:00
Sam
3ef4c715e7
fix(frontend): encode pronoun links 2023-04-17 16:16:32 +02:00
Sam
30c086f0f3
feat(frontend): add toast if avatar is too big 2023-04-13 23:34:19 +02:00
Sam
a9463896d4
feat(backend): add cors 2023-04-13 23:33:48 +02:00
Sam
2a4195ac03
start rust exporter 2023-04-09 23:27:40 +02:00
Sam
041913675b
fix: add noindex tag to user/member pages in addition to robots.txt 2023-04-08 14:48:03 +02:00
Sam
893244dc16
fix: fix internal server error in POST /members 2023-04-08 01:25:27 +02:00
Sam
62b9c1dbd6
feat(exporter): use indentation, export some new fields 2023-04-08 01:00:37 +02:00
Sam
97ffb2eab5
fix: check correct array for fields notice in edit member page 2023-04-08 00:29:56 +02:00
Sam
28db9acc81
feat: hide display text box by default 2023-04-07 23:41:49 +02:00
Sam
b9f150f38f
fix: add aria-label to IconButton 2023-04-07 17:18:59 +02:00
Sam
5584d8c601
fix: wrap long member names on user page 2023-04-07 17:06:28 +02:00
Sam
d17b43d2a7
chore: update backend dependencies 2023-04-04 04:11:03 +02:00
Sam
3e901ae10f
chore: update frontend dependencies 2023-04-04 03:48:39 +02:00
Sam
634530ba06
feat: add markdown help on edit page rather than linking out 2023-04-03 23:49:02 +02:00
Sam
033b9b5904
fix: fix some markdown rendering bugs (closes #46) 2023-04-03 23:32:34 +02:00
Sam
691711d542
fix: return u.ListPrivate in PATCH /users/@me response 2023-04-02 23:10:57 +02:00
Sam
5bdb25866c
feat: add hidden member list 2023-04-02 23:08:44 +02:00
Sam
8433a1523a
feat: count characters consistently 2023-04-02 22:50:22 +02:00
Sam
80ca1cae00
feat(frontend): also rework edit member page 2023-04-02 22:33:09 +02:00
Sam
c4ba4ef3d3
feat(frontend): tweak warning for hidden member list 2023-04-02 02:12:07 +02:00
Sam
321bbe8499
feat(frontend): redesign edit profile page, add member list title + hide list options 2023-04-02 01:29:19 +02:00
Sam
ef9b186e66
feat(backend): add unlisted members, private member list, custom members header 2023-04-01 17:20:59 +02:00
Sam
ba48ba0eb2
feat: add names/pronouns/field entries on enter 2023-03-31 00:11:28 +02:00
Sam
b1e267cb29
feat: differentiate okay/avoid preferences more 2023-03-30 23:10:13 +02:00
Sam
86f272a365
fix: redirect settings pages to login if the user is not logged in 2023-03-30 17:08:53 +02:00
Sam
ff75075b81
feat: restrict certain endpoints from API tokens and/or read-only tokens 2023-03-30 16:58:35 +02:00
Sam
2716471fa9
feat: add API tokens + force log out button 2023-03-30 16:50:30 +02:00
Sam
9c8b6a8f91
feat(!): return 204 instead of useless json responses, add fastFetch 2023-03-30 16:05:40 +02:00
Sam
abc78f3a9a
feat(backend): change DELETE /auth/tokens to invalidate *all* tokens 2023-03-30 16:05:10 +02:00
Sam
92243d58ac
feat: accept short versions of traditional pronouns 2023-03-30 15:30:34 +02:00
Sam
0ce6453bf7
chore: remove lines left over from imagemagick 2023-03-30 15:19:44 +02:00
Sam
ab39f64ad5
feat: switch to Go libraries for avatar conversion instead of ImageMagick 2023-03-30 14:44:32 +02:00
Sam
65fa7f6d46
feat: add dev badge in navigation for local builds 2023-03-30 01:20:46 +02:00
Sam
a49dd09013
feat: move toasts to top center to not obstruct menu 2023-03-30 00:31:23 +02:00
Sam
c98e4390b1
fix: validate WordStatus correctly, raise field entry length limit to 100 2023-03-30 00:15:21 +02:00
Sam
96376516b0
feat(!): use strings for WordStatus enum instead of ints 2023-03-29 23:59:51 +02:00
Sam
11ef4d548a
feat: use member's favourite pronouns in delete member modal 2023-03-29 12:30:51 +02:00
Sam
7764f0f80c
fix: don't use userStore in edit profile pages so they can be used after refresh 2023-03-29 12:30:10 +02:00
Jake
ce214d2066
fix(frontend): correctly remove entries 2023-03-29 11:38:22 +02:00
Sam
bca0404b71
fix: fix avatars losing transparency (closes #39) 2023-03-29 11:36:20 +02:00
Sam
7d25d12722
feat: validate names when *changing* them, too 2023-03-28 10:25:54 +02:00
Sam
c60429d884
feat: redirect /[username] to /@[username] if no page matches 2023-03-28 10:02:20 +02:00
Sam
56c0d40a11
feat: add meta description to more pages 2023-03-28 00:52:44 +02:00
Sam
83c90248e4
fix: make profile links bigger tap targets 2023-03-27 23:46:06 +02:00
Sam
030ebfdb10
fix: appease pagespeed insights some more 2023-03-27 16:41:08 +02:00
Sam
32266cb014
fix: use name=description not property=description in meta tag 2023-03-27 16:21:07 +02:00
Sam
239c9e240f
docs: add fonts cache header to nginx config 2023-03-27 16:08:38 +02:00
Sam
bcdcb1e0d5
feat(frontend): appease pagespeed insights 2023-03-27 15:48:03 +02:00
Sam
4a17611766
feat(frontend): specify height as well as width for avatars 2023-03-27 15:40:23 +02:00
Sam
99ab8b6864
feat(frontend): self host fonts 2023-03-27 15:40:03 +02:00
Sam
83e38bb320
fix(backend): use correct ID in patch member endpoint 2023-03-27 05:00:16 +02:00
Sam
80ac218376
feat(frontend): don't make link a link if it's not a link 2023-03-27 04:43:39 +02:00
Sam
ed60340969
fix(frontend): clarify required format for fediverse instance domain 2023-03-27 04:37:32 +02:00
Sam
5be0b168c5
feat(frontend): validate username and member name client-side too 2023-03-27 01:23:04 +02:00
Sam
6532393578
feat: show prompt to fill profile/members if profile/member list is empty 2023-03-27 00:44:55 +02:00
Sam
256a14a922
feat: rel=me for profile links, don't show http:// prefix 2023-03-26 00:07:51 +01:00
Sam
0c187aaf84
feat(frontend): self host avatar placeholders 2023-03-25 22:14:04 +01:00
Sam
67dae103d8
fix(frontend): remove extra space in footer 2023-03-25 17:08:17 +01:00
Sam
8da83b3f8d
fix(frontend): don't show login button if user is logged in, redirect to profile on login 2023-03-25 17:07:11 +01:00
Sam
2cb735a0db
aaaa i messed up the terms link on the sign up page 2023-03-25 16:19:33 +01:00
Sam
d86d4f5540
feat: fix foundkey support (closes #26) 2023-03-25 16:14:06 +01:00
Sam
75407827bc
feat: better but not perfect misskey auth support 2023-03-25 15:54:09 +01:00
Sam
d9aa6e4fae
feat(frontend): add basic error pages (fixes #32) 2023-03-25 14:49:06 +01:00
Sam
987ff47704
feat: misskey oauth (fixes #26) 2023-03-25 03:27:40 +01:00
Sam
ef6aa3ee5f
feat(frontend): actual front page 2023-03-25 03:02:44 +01:00
Sam
0ca31a3062
feat(frontend): add terms + privacy policy 2023-03-25 01:49:11 +01:00
Sam
e264f44fcd
feat(frontend): add about page 2023-03-25 01:24:54 +01:00
Sam
ee8fcecd85
feat(frontend): add about page 2023-03-25 01:24:05 +01:00
Sam
c66db2bb6c
feat(frontend): use custom font 2023-03-25 01:23:11 +01:00
Sam
40fb2d5dd1
feat(frontend): always show footer at bottom of page 2023-03-24 01:25:29 +01:00
sam
84b87790ee merge: merge pull request 'add reports and moderation' (#31) from reports into main
Reviewed-on: https://codeberg.org/u1f320/pronouns.cc/pulls/31
2023-03-23 16:14:24 +00:00
288 changed files with 24343 additions and 5265 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

44
.env.example Normal file
View file

@ -0,0 +1,44 @@
# Key used to sign tokens. Generate this with `go run . generate key`
HMAC_KEY=
# PostgreSQL connection URL (postgresql://user:pass@host:port/dbname)
DATABASE_URL=
# Redis connection URL (redis://user:pass@host:port)
REDIS=
# Port for the backend to listen on; frontend assumes this will be 8080 for dev
PORT=8080
# Frontend base URL, used to construct URLs that point back to the frontend
BASE_URL=http://localhost:5173
# S3/MinIO configuration, required for avatars, pride flags, and data exports
# Note: MINIO_ENDPOINT must be set and look like a minio endpoint, but doesn't
# have to actually point to anything real
MINIO_ENDPOINT=example.com
MINIO_BUCKET=
MINIO_ACCESS_KEY_ID=
MINIO_ACCESS_KEY_SECRET=
MINIO_SSL=
# IP address of the frontend; requests from here will never be ratelimited
FRONTEND_IP=
# Auth providers - fill in OAuth app info to enable OAuth login for each
# https://discord.com/developers/applications
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
# https://developers.google.com/identity/protocols/oauth2#basicsteps
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# https://www.tumblr.com/oauth/apps
TUMBLR_CLIENT_ID=
TUMBLR_CLIENT_SECRET=
# Discord bot config - provide the app's public key in addition to client ID/
# secret above to let the bot respond to command interactions over HTTP
DISCORD_PUBLIC_KEY=

3
.gitignore vendored
View file

@ -11,3 +11,6 @@ build
package
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
target
tmp
seed.yaml

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

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

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

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

2299
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

5
Cargo.toml Normal file
View file

@ -0,0 +1,5 @@
[workspace]
members = [
"pronounslib",
"prns",
]

View file

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

View file

@ -25,17 +25,25 @@ Requirements:
- PostgreSQL (any currently supported version should work)
- Redis 6.0 or later
- Node.js (latest version)
- MinIO **if using avatars 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
1. Create a PostgreSQL user and database (the user should own the database)
1. Create a PostgreSQL user and database (the user should own the database).
For example: `create user pronouns with password 'password'; create database pronouns with owner pronouns;`
2. Create a `.env` file in the repository root containing at least `HMAC_KEY`, `DATABASE_URL`, `REDIS`, `PORT`, and `MINIO_ENDPOINT` keys.
3. Run `go run -v . database migrate` to initialize the database, then optionally `go run -v . database seed` to insert a test user.
4. Run `go run -v . web` to run the backend.
5. Create `frontend/.env` with the following content: `PUBLIC_BASE_URL=http://localhost:5173`
6. cd into the `frontend` directory and run `pnpm dev` to run the frontend.
2. Copy `.env.example` in the repository root to a new file named `.env` and fill out the required options.
3. Copy `frontend/.env.example` to `frontend/env` and fill out the required options.
4. Run `go run -v . database migrate` to initialize the database, then optionally `go run -v . database seed` to insert a test user.
5. Run `pnpm dev`. Alternatively, if you don't want the backend to live reload, run `go run -v . web`,
then change to the `frontend/` directory and run `pnpm dev`.
See [`docs/production.md`](/docs/production.md#configuration) for more information about keys in the backend and frontend `.env` files.
### Seeding
To seed the database with some data, create a `seed.yaml` file, then use `go run -v . database seed`.
For the file format, refer to the `Seed` struct in `scripts/seeddb`.
## License

11
backend/common/common.go Normal file
View file

@ -0,0 +1,11 @@
// Package common contains functions and types common to all (or most) packages.
package common
import "unicode/utf8"
func StringLength(s *string) int {
if s == nil {
return -1
}
return utf8.RuneCountInString(*s)
}

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

@ -6,141 +6,67 @@ import (
"crypto/sha256"
"encoding/base64"
"encoding/hex"
_ "image/gif"
_ "image/png"
"io"
"os/exec"
"strings"
"emperror.dev/errors"
"github.com/davidbyttow/govips/v2/vips"
"github.com/minio/minio-go/v7"
"github.com/rs/xid"
)
var (
webpArgs = []string{"-thumbnail", "512x512^", "-gravity", "center", "-extent", "512x512", "-quality", "90", "webp:-"}
jpgArgs = []string{"-thumbnail", "512x512^", "-gravity", "center", "-extent", "512x512", "-quality", "80", "jpg:-"}
)
const ErrInvalidDataURI = errors.Sentinel("invalid data URI")
const ErrInvalidContentType = errors.Sentinel("invalid avatar content type")
const ErrFileTooLarge = errors.Sentinel("file to be converted exceeds maximum size")
// ConvertAvatar parses an avatar from a data URI, converts it to WebP and JPEG, and returns the results.
func (db *DB) ConvertAvatar(data string) (
webp *bytes.Buffer,
jpg *bytes.Buffer,
webpOut *bytes.Buffer,
jpgOut *bytes.Buffer,
err error,
) {
defer vips.ShutdownThread()
data = strings.TrimSpace(data)
if !strings.Contains(data, ",") || !strings.Contains(data, ":") || !strings.Contains(data, ";") {
return nil, nil, ErrInvalidDataURI
}
split := strings.Split(data, ",")
rest, b64 := split[0], split[1]
rest = strings.Split(rest, ":")[1]
contentType := strings.Split(rest, ";")[0]
var contentArg []string
switch contentType {
case "image/png":
contentArg = []string{"png:-"}
case "image/jpeg":
contentArg = []string{"jpg:-"}
case "image/gif":
contentArg = []string{"gif:-"}
case "image/webp":
contentArg = []string{"webp:-"}
default:
return nil, nil, ErrInvalidContentType
}
rawData, err := base64.StdEncoding.DecodeString(b64)
rawData, err := base64.StdEncoding.DecodeString(split[1])
if err != nil {
return nil, nil, errors.Wrap(err, "invalid base64 data")
}
// create webp convert command and get its pipes
webpConvert := exec.Command("convert", append(contentArg, webpArgs...)...)
stdIn, err := webpConvert.StdinPipe()
image, err := vips.LoadImageFromBuffer(rawData, nil)
if err != nil {
return nil, nil, errors.Wrap(err, "getting webp stdin")
}
stdOut, err := webpConvert.StdoutPipe()
if err != nil {
return nil, nil, errors.Wrap(err, "getting webp stdout")
return nil, nil, errors.Wrap(err, "decoding image")
}
// start webp command
err = webpConvert.Start()
err = image.ThumbnailWithSize(512, 512, vips.InterestingCentre, vips.SizeBoth)
if err != nil {
return nil, nil, errors.Wrap(err, "starting webp command")
return nil, nil, errors.Wrap(err, "resizing image")
}
// write data
_, err = stdIn.Write(rawData)
webpExport := vips.NewWebpExportParams()
webpExport.Quality = 90
webpB, _, err := image.ExportWebp(webpExport)
if err != nil {
return nil, nil, errors.Wrap(err, "writing webp data")
}
err = stdIn.Close()
if err != nil {
return nil, nil, errors.Wrap(err, "closing webp stdin")
return nil, nil, errors.Wrap(err, "exporting webp image")
}
webpOut = bytes.NewBuffer(webpB)
// read webp output
webpBuffer := new(bytes.Buffer)
_, err = io.Copy(webpBuffer, stdOut)
jpegExport := vips.NewJpegExportParams()
jpegExport.Quality = 80
jpegB, _, err := image.ExportJpeg(jpegExport)
if err != nil {
return nil, nil, errors.Wrap(err, "reading webp data")
return nil, nil, errors.Wrap(err, "exporting jpeg image")
}
webp = webpBuffer
jpgOut = bytes.NewBuffer(jpegB)
// finish webp command
err = webpConvert.Wait()
if err != nil {
return nil, nil, errors.Wrap(err, "running webp command")
}
// create jpg convert command and get its pipes
jpgConvert := exec.Command("convert", append(contentArg, jpgArgs...)...)
stdIn, err = jpgConvert.StdinPipe()
if err != nil {
return nil, nil, errors.Wrap(err, "getting jpg stdin")
}
stdOut, err = jpgConvert.StdoutPipe()
if err != nil {
return nil, nil, errors.Wrap(err, "getting jpg stdout")
}
// start jpg command
err = jpgConvert.Start()
if err != nil {
return nil, nil, errors.Wrap(err, "starting jpg command")
}
// write data
_, err = stdIn.Write(rawData)
if err != nil {
return nil, nil, errors.Wrap(err, "writing jpg data")
}
err = stdIn.Close()
if err != nil {
return nil, nil, errors.Wrap(err, "closing jpg stdin")
}
// read jpg output
jpgBuffer := new(bytes.Buffer)
_, err = io.Copy(jpgBuffer, stdOut)
if err != nil {
return nil, nil, errors.Wrap(err, "reading jpg data")
}
jpg = jpgBuffer
// finish jpg command
err = jpgConvert.Wait()
if err != nil {
return nil, nil, errors.Wrap(err, "running jpg command")
}
return webp, jpg, nil
return webpOut, jpgOut, nil
}
func (db *DB) WriteUserAvatar(ctx context.Context,
@ -155,15 +81,17 @@ func (db *DB) WriteUserAvatar(ctx context.Context,
}
hash = hex.EncodeToString(hasher.Sum(nil))
_, err = db.minio.PutObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{
ContentType: "image/webp",
_, err = db.minio.PutObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{
ContentType: "image/webp",
SendContentMd5: true,
})
if err != nil {
return "", errors.Wrap(err, "uploading webp avatar")
}
_, err = db.minio.PutObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{
ContentType: "image/jpeg",
_, err = db.minio.PutObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{
ContentType: "image/jpeg",
SendContentMd5: true,
})
if err != nil {
return "", errors.Wrap(err, "uploading jpeg avatar")
@ -184,15 +112,17 @@ func (db *DB) WriteMemberAvatar(ctx context.Context,
}
hash = hex.EncodeToString(hasher.Sum(nil))
_, err = db.minio.PutObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{
ContentType: "image/webp",
_, err = db.minio.PutObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{
ContentType: "image/webp",
SendContentMd5: true,
})
if err != nil {
return "", errors.Wrap(err, "uploading webp avatar")
}
_, err = db.minio.PutObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{
ContentType: "image/jpeg",
_, err = db.minio.PutObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{
ContentType: "image/jpeg",
SendContentMd5: true,
})
if err != nil {
return "", errors.Wrap(err, "uploading jpeg avatar")
@ -202,12 +132,12 @@ func (db *DB) WriteMemberAvatar(ctx context.Context,
}
func (db *DB) DeleteUserAvatar(ctx context.Context, userID xid.ID, hash string) error {
err := db.minio.RemoveObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{})
err := db.minio.RemoveObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{})
if err != nil {
return errors.Wrap(err, "deleting webp avatar")
}
err = db.minio.RemoveObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".jpg", minio.RemoveObjectOptions{})
err = db.minio.RemoveObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".jpg", minio.RemoveObjectOptions{})
if err != nil {
return errors.Wrap(err, "deleting jpeg avatar")
}
@ -216,12 +146,12 @@ func (db *DB) DeleteUserAvatar(ctx context.Context, userID xid.ID, hash string)
}
func (db *DB) DeleteMemberAvatar(ctx context.Context, memberID xid.ID, hash string) error {
err := db.minio.RemoveObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{})
err := db.minio.RemoveObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".webp", minio.RemoveObjectOptions{})
if err != nil {
return errors.Wrap(err, "deleting webp avatar")
}
err = db.minio.RemoveObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".jpg", minio.RemoveObjectOptions{})
err = db.minio.RemoveObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".jpg", minio.RemoveObjectOptions{})
if err != nil {
return errors.Wrap(err, "deleting jpeg avatar")
}
@ -230,7 +160,7 @@ func (db *DB) DeleteMemberAvatar(ctx context.Context, memberID xid.ID, hash stri
}
func (db *DB) UserAvatar(ctx context.Context, userID xid.ID, hash string) (io.ReadCloser, error) {
obj, err := db.minio.GetObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".webp", minio.GetObjectOptions{})
obj, err := db.minio.GetObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".webp", minio.GetObjectOptions{})
if err != nil {
return nil, errors.Wrap(err, "getting object")
}
@ -238,7 +168,7 @@ func (db *DB) UserAvatar(ctx context.Context, userID xid.ID, hash string) (io.Re
}
func (db *DB) MemberAvatar(ctx context.Context, memberID xid.ID, hash string) (io.ReadCloser, error) {
obj, err := db.minio.GetObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".webp", minio.GetObjectOptions{})
obj, err := db.minio.GetObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".webp", minio.GetObjectOptions{})
if err != nil {
return nil, errors.Wrap(err, "getting object")
}

View file

@ -6,20 +6,28 @@ import (
"fmt"
"net/url"
"os"
"sync"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors"
"github.com/Masterminds/squirrel"
"github.com/jackc/pgconn"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/mediocregopher/radix/v4"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"github.com/prometheus/client_golang/prometheus"
)
var sq = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
const ErrNothingToUpdate = errors.Sentinel("nothing to update")
const (
uniqueViolation = "23505"
foreignKeyViolation = "23503"
)
type Execer interface {
Exec(ctx context.Context, sql string, arguments ...interface{}) (commandTag pgconn.CommandTag, err error)
}
@ -32,19 +40,28 @@ type DB struct {
minio *minio.Client
minioBucket string
baseURL *url.URL
TotalRequests prometheus.Counter
activeUsersDay, activeUsersWeek, activeUsersMonth int64
usersTotal, membersTotal int64
countMu sync.RWMutex
}
func New() (*DB, error) {
pool, err := pgxpool.Connect(context.Background(), os.Getenv("DATABASE_URL"))
log.Debug("creating postgres client")
pool, err := pgxpool.New(context.Background(), os.Getenv("DATABASE_URL"))
if err != nil {
return nil, errors.Wrap(err, "creating postgres client")
}
log.Debug("creating redis client")
redis, err := (&radix.PoolConfig{}).New(context.Background(), "tcp", os.Getenv("REDIS"))
if err != nil {
return nil, errors.Wrap(err, "creating redis client")
}
log.Debug("creating minio client")
minioClient, err := minio.New(os.Getenv("MINIO_ENDPOINT"), &minio.Options{
Creds: credentials.NewStaticV4(os.Getenv("MINIO_ACCESS_KEY_ID"), os.Getenv("MINIO_ACCESS_KEY_SECRET"), ""),
Secure: os.Getenv("MINIO_SSL") == "true",
@ -67,6 +84,12 @@ func New() (*DB, error) {
baseURL: baseURL,
}
log.Debug("initializing metrics")
err = db.initMetrics()
if err != nil {
return nil, errors.Wrap(err, "initializing metrics")
}
return db, nil
}
@ -124,30 +147,6 @@ func (db *DB) GetJSON(ctx context.Context, key string, v any) error {
return nil
}
// GetDelJSON gets the given key as a JSON object and deletes it.
func (db *DB) GetDelJSON(ctx context.Context, key string, v any) error {
var b []byte
err := db.Redis.Do(ctx, radix.Cmd(&b, "GETDEL", key))
if err != nil {
return errors.Wrap(err, "reading from Redis")
}
if b == nil {
return nil
}
if v == nil {
return fmt.Errorf("nil pointer passed into GetDelJSON")
}
err = json.Unmarshal(b, v)
if err != nil {
return errors.Wrap(err, "unmarshaling json")
}
return nil
}
// NotNull is a little helper that returns an *empty slice* when the slice's length is 0.
// This is to prevent nil slices from being marshaled as JSON null
func NotNull[T any](slice []T) []T {

View file

@ -5,24 +5,51 @@ import (
"strings"
)
type WordStatus int
type WordStatus string
const (
StatusUnknown WordStatus = 0
StatusFavourite WordStatus = 1
StatusOkay WordStatus = 2
StatusJokingly WordStatus = 3
StatusFriendsOnly WordStatus = 4
StatusAvoid WordStatus = 5
wordStatusMax WordStatus = 6
)
func (w *WordStatus) UnmarshalJSON(src []byte) error {
if string(src) == "null" {
return nil
}
s := strings.Trim(string(src), `"`)
switch s {
case "1":
*w = "favourite"
case "2":
*w = "okay"
case "3":
*w = "jokingly"
case "4":
*w = "friends_only"
case "5":
*w = "avoid"
default:
*w = WordStatus(s)
}
return nil
}
func (w WordStatus) Valid(extra CustomPreferences) bool {
if w == "favourite" || w == "okay" || w == "jokingly" || w == "friends_only" || w == "avoid" {
return true
}
for k := range extra {
if string(w) == k {
return true
}
}
return false
}
type FieldEntry struct {
Value string `json:"value"`
Status WordStatus `json:"status"`
}
func (fe FieldEntry) Validate() string {
func (fe FieldEntry) Validate(custom CustomPreferences) string {
if fe.Value == "" {
return "value cannot be empty"
}
@ -31,8 +58,8 @@ func (fe FieldEntry) Validate() string {
return fmt.Sprintf("name must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(fe.Value)))
}
if fe.Status == StatusUnknown || fe.Status >= wordStatusMax {
return fmt.Sprintf("status is invalid, must be between 1 and %d, is %d", wordStatusMax-1, fe.Status)
if !fe.Status.Valid(custom) {
return "status is invalid"
}
return ""
@ -44,7 +71,7 @@ type PronounEntry struct {
Status WordStatus `json:"status"`
}
func (p PronounEntry) Validate() string {
func (p PronounEntry) Validate(custom CustomPreferences) string {
if p.Pronouns == "" {
return "pronouns cannot be empty"
}
@ -59,8 +86,8 @@ func (p PronounEntry) Validate() string {
return fmt.Sprintf("pronouns must be %d characters or less, is %d", FieldEntryMaxLength, len([]rune(p.Pronouns)))
}
if p.Status == StatusUnknown || p.Status >= wordStatusMax {
return fmt.Sprintf("status is invalid, must be between 1 and %d, is %d", wordStatusMax-1, p.Status)
if !p.Status.Valid(custom) {
return "status is invalid"
}
return ""

View file

@ -6,8 +6,8 @@ import (
"time"
"emperror.dev/errors"
"github.com/georgysavva/scany/pgxscan"
"github.com/jackc/pgx/v4"
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
"github.com/minio/minio-go/v7"
"github.com/rs/xid"
)
@ -20,7 +20,7 @@ type DataExport struct {
}
func (de DataExport) Path() string {
return "/exports/" + de.UserID.String() + "/" + de.Filename + ".zip"
return "exports/" + de.UserID.String() + "/" + de.Filename + ".zip"
}
const ErrNoExport = errors.Sentinel("no data export exists")
@ -67,7 +67,8 @@ func (db *DB) CreateExport(ctx context.Context, userID xid.ID, filename string,
}
_, err = db.minio.PutObject(ctx, db.minioBucket, de.Path(), file, int64(file.Len()), minio.PutObjectOptions{
ContentType: "application/zip",
ContentType: "application/zip",
SendContentMd5: true,
})
if err != nil {
return de, errors.Wrap(err, "writing export file")
@ -78,7 +79,7 @@ func (db *DB) CreateExport(ctx context.Context, userID xid.ID, filename string,
return de, errors.Wrap(err, "building query")
}
pgxscan.Get(ctx, db, &de, sql, args...)
err = pgxscan.Get(ctx, db, &de, sql, args...)
if err != nil {
return de, errors.Wrap(err, "executing sql")
}

View file

@ -5,8 +5,8 @@ import (
"os"
"emperror.dev/errors"
"github.com/georgysavva/scany/pgxscan"
"github.com/jackc/pgx/v4"
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
"golang.org/x/oauth2"
)
@ -20,25 +20,39 @@ type FediverseApp struct {
}
func (f FediverseApp) ClientConfig() *oauth2.Config {
// if f.MastodonCompatible() {
if f.MastodonCompatible() {
return &oauth2.Config{
ClientID: f.ClientID,
ClientSecret: f.ClientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: "https://" + f.Instance + "/oauth/authorize",
TokenURL: "https://" + f.Instance + "/oauth/token",
AuthStyle: oauth2.AuthStyleInParams,
},
Scopes: []string{"read:accounts"},
RedirectURL: os.Getenv("BASE_URL") + "/auth/login/mastodon/" + f.Instance,
}
}
return &oauth2.Config{
ClientID: f.ClientID,
ClientSecret: f.ClientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: "https://" + f.Instance + "/oauth/authorize",
TokenURL: "https://" + f.Instance + "/oauth/token",
AuthStyle: oauth2.AuthStyleInParams,
AuthURL: "https://" + f.Instance + "/auth",
TokenURL: "https://" + f.Instance + "/api/auth/session/oauth",
AuthStyle: oauth2.AuthStyleInHeader,
},
Scopes: []string{"read:accounts"},
RedirectURL: os.Getenv("BASE_URL") + "/auth/login/mastodon/" + f.Instance,
Scopes: []string{"read:account"},
RedirectURL: os.Getenv("BASE_URL") + "/auth/login/misskey/" + f.Instance,
}
// }
// TODO: misskey, assuming i can even find english API documentation, that is
}
func (f FediverseApp) MastodonCompatible() bool {
return f.InstanceType == "mastodon" || f.InstanceType == "pleroma" || f.InstanceType == "akkoma" || f.InstanceType == "pixelfed"
return f.InstanceType == "mastodon" || f.InstanceType == "pleroma" || f.InstanceType == "akkoma" || f.InstanceType == "incestoma" || f.InstanceType == "pixelfed" || f.InstanceType == "gotosocial"
}
func (f FediverseApp) Misskey() bool {
return f.InstanceType == "misskey" || f.InstanceType == "foundkey" || f.InstanceType == "calckey" || f.InstanceType == "firefish" || f.InstanceType == "sharkey"
}
const ErrNoInstanceApp = errors.Sentinel("instance doesn't have an app")

View file

@ -5,8 +5,8 @@ import (
"fmt"
"emperror.dev/errors"
"github.com/georgysavva/scany/pgxscan"
"github.com/jackc/pgx/v4"
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
"github.com/rs/xid"
)
@ -14,7 +14,7 @@ const (
MaxFields = 25
FieldNameMaxLength = 100
FieldEntriesLimit = 100
FieldEntryMaxLength = 50
FieldEntryMaxLength = 100
)
type Field struct {
@ -24,7 +24,7 @@ type Field struct {
}
// Validate validates this field. If it is invalid, a non-empty string is returned as error message.
func (f Field) Validate() string {
func (f Field) Validate(custom CustomPreferences) string {
if f.Name == "" {
return "name cannot be empty"
}
@ -42,8 +42,11 @@ func (f Field) Validate() string {
return fmt.Sprintf("entries.%d: max length is %d characters, length is %d", i, FieldEntryMaxLength, length)
}
if entry.Status == StatusUnknown || entry.Status >= wordStatusMax {
return fmt.Sprintf("entries.%d: status is invalid, must be between 1 and %d, is %d", i, wordStatusMax-1, entry.Status)
if !entry.Status.Valid(custom) {
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)
}
}

326
backend/db/flags.go Normal file
View file

@ -0,0 +1,326 @@
package db
import (
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"io"
"strings"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors"
"github.com/davidbyttow/govips/v2/vips"
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/minio/minio-go/v7"
"github.com/rs/xid"
)
type PrideFlag struct {
ID xid.ID `json:"id"`
SnowflakeID common.FlagID `json:"id_new"`
UserID xid.ID `json:"-"`
Hash string `json:"hash"`
Name string `json:"name"`
Description *string `json:"description"`
}
type UserFlag struct {
ID int64 `json:"-"`
UserID xid.ID `json:"-"`
FlagID xid.ID `json:"id"`
Hash string `json:"hash"`
Name string `json:"name"`
Description *string `json:"description"`
}
type MemberFlag struct {
ID int64 `json:"-"`
MemberID xid.ID `json:"-"`
FlagID xid.ID `json:"id"`
Hash string `json:"hash"`
Name string `json:"name"`
Description *string `json:"description"`
}
const (
MaxPrideFlags = 500
MaxPrideFlagTitleLength = 100
MaxPrideFlagDescLength = 500
)
const (
ErrInvalidFlagID = errors.Sentinel("invalid flag ID")
ErrFlagNotFound = errors.Sentinel("flag not found")
)
func (db *DB) AccountFlags(ctx context.Context, userID xid.ID) (fs []PrideFlag, err error) {
sql, args, err := sq.Select("*").From("pride_flags").Where("user_id = ?", userID).OrderBy("lower(name)", "id").ToSql()
if err != nil {
return nil, errors.Wrap(err, "building query")
}
err = pgxscan.Select(ctx, db, &fs, sql, args...)
if err != nil {
return nil, errors.Wrap(err, "executing query")
}
return NotNull(fs), nil
}
func (db *DB) UserFlag(ctx context.Context, flagID xid.ID) (f PrideFlag, err error) {
sql, args, err := sq.Select("*").From("pride_flags").Where("id = ?", flagID).ToSql()
if err != nil {
return f, errors.Wrap(err, "building query")
}
err = pgxscan.Get(ctx, db, &f, sql, args...)
if err != nil {
if errors.Cause(err) == pgx.ErrNoRows {
return f, ErrFlagNotFound
}
return f, errors.Wrap(err, "executing query")
}
return f, nil
}
func (db *DB) UserFlags(ctx context.Context, userID xid.ID) (fs []UserFlag, err error) {
sql, args, err := sq.Select("u.id", "u.flag_id", "f.user_id", "f.hash", "f.name", "f.description").
From("user_flags AS u").
Where("u.user_id = $1", userID).
Join("pride_flags AS f ON u.flag_id = f.id").
OrderBy("u.id ASC").
ToSql()
if err != nil {
return nil, errors.Wrap(err, "building query")
}
err = pgxscan.Select(ctx, db, &fs, sql, args...)
if err != nil {
return nil, errors.Wrap(err, "executing query")
}
return NotNull(fs), nil
}
func (db *DB) MemberFlags(ctx context.Context, memberID xid.ID) (fs []MemberFlag, err error) {
sql, args, err := sq.Select("m.id", "m.flag_id", "m.member_id", "f.hash", "f.name", "f.description").
From("member_flags AS m").
Where("m.member_id = $1", memberID).
Join("pride_flags AS f ON m.flag_id = f.id").
OrderBy("m.id ASC").
ToSql()
if err != nil {
return nil, errors.Wrap(err, "building query")
}
err = pgxscan.Select(ctx, db, &fs, sql, args...)
if err != nil {
return nil, errors.Wrap(err, "executing query")
}
return NotNull(fs), nil
}
func (db *DB) SetUserFlags(ctx context.Context, tx pgx.Tx, userID xid.ID, flags []xid.ID) (err error) {
sql, args, err := sq.Delete("user_flags").Where("user_id = ?", userID).ToSql()
if err != nil {
return errors.Wrap(err, "building sql")
}
_, err = tx.Exec(ctx, sql, args...)
if err != nil {
return errors.Wrap(err, "deleting existing flags")
}
n, err := tx.CopyFrom(ctx, pgx.Identifier{"user_flags"}, []string{"user_id", "flag_id"},
pgx.CopyFromSlice(len(flags), func(i int) ([]any, error) {
return []any{userID, flags[i]}, nil
}))
if err != nil {
pge := &pgconn.PgError{}
if errors.As(err, &pge) {
if pge.Code == foreignKeyViolation {
return ErrInvalidFlagID
}
}
return errors.Wrap(err, "copying new flags")
}
if n > 0 {
log.Debugf("set %v flags for user %v", n, userID)
}
return nil
}
func (db *DB) SetMemberFlags(ctx context.Context, tx pgx.Tx, memberID xid.ID, flags []xid.ID) (err error) {
sql, args, err := sq.Delete("member_flags").Where("member_id = ?", memberID).ToSql()
if err != nil {
return errors.Wrap(err, "building sql")
}
_, err = tx.Exec(ctx, sql, args...)
if err != nil {
return errors.Wrap(err, "deleting existing flags")
}
n, err := tx.CopyFrom(ctx, pgx.Identifier{"member_flags"}, []string{"member_id", "flag_id"},
pgx.CopyFromSlice(len(flags), func(i int) ([]any, error) {
return []any{memberID, flags[i]}, nil
}))
if err != nil {
pge := &pgconn.PgError{}
if errors.As(err, &pge) {
if pge.Code == foreignKeyViolation {
return ErrInvalidFlagID
}
}
return errors.Wrap(err, "copying new flags")
}
if n > 0 {
log.Debugf("set %v flags for member %v", n, memberID)
}
return nil
}
func (db *DB) CreateFlag(ctx context.Context, tx pgx.Tx, userID xid.ID, name, desc string) (f PrideFlag, err error) {
description := &desc
if desc == "" {
description = nil
}
sql, args, err := sq.Insert("pride_flags").
SetMap(map[string]any{
"id": xid.New(),
"snowflake_id": common.GenerateID(),
"hash": "",
"user_id": userID.String(),
"name": name,
"description": description,
}).Suffix("RETURNING *").ToSql()
if err != nil {
return f, errors.Wrap(err, "building query")
}
err = pgxscan.Get(ctx, tx, &f, sql, args...)
if err != nil {
return f, errors.Wrap(err, "executing query")
}
return f, nil
}
func (db *DB) EditFlag(ctx context.Context, tx pgx.Tx, flagID xid.ID, name, desc, hash *string) (f PrideFlag, err error) {
b := sq.Update("pride_flags").
Where("id = ?", flagID)
if name != nil {
b = b.Set("name", *name)
}
if desc != nil {
if *desc == "" {
b = b.Set("description", nil)
} else {
b = b.Set("description", *desc)
}
}
if hash != nil {
b = b.Set("hash", *hash)
}
sql, args, err := b.Suffix("RETURNING *").ToSql()
if err != nil {
return f, errors.Wrap(err, "building sql")
}
err = pgxscan.Get(ctx, tx, &f, sql, args...)
if err != nil {
return f, errors.Wrap(err, "executing query")
}
return f, nil
}
func (db *DB) WriteFlag(ctx context.Context, flagID xid.ID, flag *bytes.Buffer) (hash string, err error) {
hasher := sha256.New()
_, err = hasher.Write(flag.Bytes())
if err != nil {
return "", errors.Wrap(err, "hashing flag")
}
hash = hex.EncodeToString(hasher.Sum(nil))
_, err = db.minio.PutObject(ctx, db.minioBucket, "flags/"+hash+".webp", flag, -1, minio.PutObjectOptions{
ContentType: "image/webp",
SendContentMd5: true,
})
if err != nil {
return "", errors.Wrap(err, "uploading flag")
}
return hash, nil
}
func (db *DB) DeleteFlag(ctx context.Context, flagID xid.ID, hash string) error {
sql, args, err := sq.Delete("pride_flags").Where("id = ?", flagID).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
}
func (db *DB) FlagObject(ctx context.Context, flagID xid.ID, hash string) (io.ReadCloser, error) {
obj, err := db.minio.GetObject(ctx, db.minioBucket, "/flags/"+flagID.String()+"/"+hash+".webp", minio.GetObjectOptions{})
if err != nil {
return nil, errors.Wrap(err, "getting object")
}
return obj, nil
}
const MaxFlagInputSize = 512_000
// ConvertFlag parses a flag from a data URI, converts it to WebP, and returns the result.
func (db *DB) ConvertFlag(data string) (webpOut *bytes.Buffer, err error) {
defer vips.ShutdownThread()
data = strings.TrimSpace(data)
if !strings.Contains(data, ",") || !strings.Contains(data, ":") || !strings.Contains(data, ";") {
return nil, ErrInvalidDataURI
}
split := strings.Split(data, ",")
rawData, err := base64.StdEncoding.DecodeString(split[1])
if err != nil {
return nil, errors.Wrap(err, "invalid base64 data")
}
if len(rawData) > MaxFlagInputSize {
return nil, ErrFileTooLarge
}
image, err := vips.LoadImageFromBuffer(rawData, nil)
if err != nil {
return nil, errors.Wrap(err, "decoding image")
}
err = image.ThumbnailWithSize(256, 256, vips.InterestingNone, vips.SizeBoth)
if err != nil {
return nil, errors.Wrap(err, "resizing image")
}
webpExport := vips.NewWebpExportParams()
webpExport.Lossless = true
webpB, _, err := image.ExportWebp(webpExport)
if err != nil {
return nil, errors.Wrap(err, "exporting webp image")
}
webpOut = bytes.NewBuffer(webpB)
return webpOut, nil
}

View file

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

View file

@ -3,11 +3,16 @@ package db
import (
"context"
"regexp"
"strings"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors"
"github.com/georgysavva/scany/pgxscan"
"github.com/jackc/pgconn"
"github.com/jackc/pgx/v4"
"github.com/Masterminds/squirrel"
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/rs/xid"
)
@ -19,6 +24,8 @@ const (
type Member struct {
ID xid.ID
UserID xid.ID
SnowflakeID common.MemberID
SID string `db:"sid"`
Name string
DisplayName *string
Bio *string
@ -26,6 +33,7 @@ type Member struct {
Links []string
Names []FieldEntry
Pronouns []PronounEntry
Unlisted bool
}
const (
@ -34,9 +42,24 @@ const (
)
// member names must match this regex
var memberNameRegex = regexp.MustCompile("^[^@\\?!#/\\\\[\\]\"'$%&()+<=>^|~`,]{1,100}$")
var memberNameRegex = regexp.MustCompile("^[^@\\?!#/\\\\[\\]\"\\{\\}'$%&()+<=>^|~`,\\*]{1,100}$")
// List of member names that cannot be used because they would break routing or be inaccessible due to page conflicts.
var invalidMemberNames = []string{
// these break routing outright
".",
"..",
// the user edit page lives at `/@{username}/edit`, so a member named "edit" would be inaccessible
"edit",
}
func MemberNameValid(name string) bool {
for i := range invalidMemberNames {
if strings.EqualFold(name, invalidMemberNames[i]) {
return false
}
}
return memberNameRegex.MatchString(name)
}
@ -53,9 +76,8 @@ func (db *DB) Member(ctx context.Context, id xid.ID) (m Member, err error) {
return m, nil
}
// UserMember returns a member scoped by user.
func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) (m Member, err error) {
sql, args, err := sq.Select("*").From("members").Where("user_id = ?", userID).Where("(id = ? or name = ?)", memberRef, memberRef).ToSql()
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")
}
@ -67,11 +89,50 @@ func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) (
return m, nil
}
// UserMember returns a member scoped by user.
func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) (m Member, err error) {
sf, _ := common.ParseSnowflake(memberRef) // error can be ignored as the zero value will never be used as an ID
sql, args, err := sq.Select("*").From("members").Where("user_id = ?", userID).Where("(id = ? or snowflake_id = ? or name = ?)", memberRef, sf, memberRef).ToSql()
if err != nil {
return m, errors.Wrap(err, "building sql")
}
err = pgxscan.Get(ctx, db, &m, sql, args...)
if err != nil {
return m, errors.Wrap(err, "executing query")
}
return m, nil
}
// MemberBySID gets a user by their short ID.
func (db *DB) MemberBySID(ctx context.Context, sid string) (u Member, err error) {
sql, args, err := sq.Select("*").From("members").Where("sid = ?", sid).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, ErrMemberNotFound
}
return u, errors.Wrap(err, "getting members from db")
}
return u, nil
}
// UserMembers returns all of a user's members, sorted by name.
func (db *DB) UserMembers(ctx context.Context, userID xid.ID) (ms []Member, err error) {
sql, args, err := sq.Select("*").
func (db *DB) UserMembers(ctx context.Context, userID xid.ID, showHidden bool) (ms []Member, err error) {
builder := sq.Select("*").
From("members").Where("user_id = ?", userID).
OrderBy("name", "id").ToSql()
OrderBy("name", "id")
if !showHidden {
builder = builder.Where("unlisted = ?", false)
}
sql, args, err := builder.ToSql()
if err != nil {
return nil, errors.Wrap(err, "building sql")
}
@ -93,8 +154,8 @@ func (db *DB) CreateMember(
name string, displayName *string, bio string, links []string,
) (m Member, err error) {
sql, args, err := sq.Insert("members").
Columns("user_id", "id", "name", "display_name", "bio", "links").
Values(userID, xid.New(), name, displayName, bio, links).
Columns("user_id", "snowflake_id", "id", "sid", "name", "display_name", "bio", "links").
Values(userID, common.GenerateID(), xid.New(), squirrel.Expr("find_free_member_sid()"), name, displayName, bio, links).
Suffix("RETURNING *").ToSql()
if err != nil {
return m, errors.Wrap(err, "building sql")
@ -105,7 +166,7 @@ func (db *DB) CreateMember(
pge := &pgconn.PgError{}
if errors.As(err, &pge) {
// unique constraint violation
if pge.Code == "23505" {
if pge.Code == uniqueViolation {
return m, ErrMemberNameInUse
}
}
@ -148,6 +209,7 @@ func (db *DB) UpdateMember(
ctx context.Context,
tx pgx.Tx, id xid.ID,
name, displayName, bio *string,
unlisted *bool,
links *[]string,
avatar *string,
) (m Member, err error) {
@ -190,6 +252,9 @@ func (db *DB) UpdateMember(
if links != nil {
builder = builder.Set("links", *links)
}
if unlisted != nil {
builder = builder.Set("unlisted", *unlisted)
}
if avatar != nil {
if *avatar == "" {
@ -208,7 +273,7 @@ func (db *DB) UpdateMember(
if err != nil {
pge := &pgconn.PgError{}
if errors.As(err, &pge) {
if pge.Code == "23505" {
if pge.Code == uniqueViolation {
return m, ErrMemberNameInUse
}
}
@ -217,3 +282,48 @@ func (db *DB) UpdateMember(
}
return m, nil
}
func (db *DB) RerollMemberSID(ctx context.Context, userID, memberID xid.ID) (newID string, err error) {
tx, err := db.Begin(ctx)
if err != nil {
return "", errors.Wrap(err, "beginning transaction")
}
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
sql, args, err := sq.Update("members").
Set("sid", squirrel.Expr("find_free_member_sid()")).
Where("id = ?", memberID).
Suffix("RETURNING sid").ToSql()
if err != nil {
return "", errors.Wrap(err, "building sql")
}
err = tx.QueryRow(ctx, sql, args...).Scan(&newID)
if err != nil {
return "", errors.Wrap(err, "executing query")
}
sql, args, err = sq.Update("users").
Set("last_sid_reroll", time.Now()).
Where("id = ?", userID).ToSql()
if err != nil {
return "", errors.Wrap(err, "building sql")
}
_, err = tx.Exec(ctx, sql, args...)
if err != nil {
return "", errors.Wrap(err, "executing query")
}
err = tx.Commit(ctx)
if err != nil {
return "", errors.Wrap(err, "committing transaction")
}
return newID, nil
}

198
backend/db/metrics.go Normal file
View file

@ -0,0 +1,198 @@
package db
import (
"context"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors"
"github.com/jackc/pgx/v5/pgconn"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/rs/xid"
)
func (db *DB) initMetrics() (err error) {
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "pronouns_users_total",
Help: "The total number of registered users",
}, func() float64 {
count, err := db.TotalUserCount(context.Background())
if err != nil {
log.Errorf("getting user count for metrics: %v", err)
}
db.countMu.Lock()
db.usersTotal = count
db.countMu.Unlock()
return float64(count)
}))
if err != nil {
return errors.Wrap(err, "registering user count gauge")
}
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "pronouns_members_total",
Help: "The total number of registered members",
}, func() float64 {
count, err := db.TotalMemberCount(context.Background())
if err != nil {
log.Errorf("getting member count for metrics: %v", err)
}
db.countMu.Lock()
db.membersTotal = count
db.countMu.Unlock()
return float64(count)
}))
if err != nil {
return errors.Wrap(err, "registering member count gauge")
}
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "pronouns_users_active",
Help: "The number of users active in the past 30 days",
}, func() float64 {
count, err := db.ActiveUsers(context.Background(), ActiveMonth)
if err != nil {
log.Errorf("getting active user count for metrics: %v", err)
}
db.countMu.Lock()
db.activeUsersMonth = count
db.countMu.Unlock()
return float64(count)
}))
if err != nil {
return errors.Wrap(err, "registering active user count gauge")
}
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "pronouns_users_active_week",
Help: "The number of users active in the past 7 days",
}, func() float64 {
count, err := db.ActiveUsers(context.Background(), ActiveWeek)
if err != nil {
log.Errorf("getting active user count for metrics: %v", err)
}
db.countMu.Lock()
db.activeUsersWeek = count
db.countMu.Unlock()
return float64(count)
}))
if err != nil {
return errors.Wrap(err, "registering active user count gauge")
}
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "pronouns_users_active_day",
Help: "The number of users active in the past 1 day",
}, func() float64 {
count, err := db.ActiveUsers(context.Background(), ActiveDay)
if err != nil {
log.Errorf("getting active user count for metrics: %v", err)
}
db.countMu.Lock()
db.activeUsersDay = count
db.countMu.Unlock()
return float64(count)
}))
if err != nil {
return errors.Wrap(err, "registering active user count gauge")
}
err = prometheus.Register(prometheus.NewGaugeFunc(prometheus.GaugeOpts{
Name: "pronouns_database_latency",
Help: "The latency to the database in nanoseconds",
}, func() float64 {
start := time.Now()
_, err = db.Exec(context.Background(), "SELECT 1")
if err != nil {
log.Errorf("pinging database: %v", err)
return -1
}
return float64(time.Since(start))
}))
if err != nil {
return errors.Wrap(err, "registering database latency gauge")
}
db.TotalRequests = promauto.NewCounter(prometheus.CounterOpts{
Name: "pronouns_api_requests_total",
Help: "The total number of API requests since the last restart",
})
return nil
}
func (db *DB) Counts(ctx context.Context) (numUsers, numMembers, usersDay, usersWeek, usersMonth int64) {
db.countMu.Lock()
if db.usersTotal != 0 {
defer db.countMu.Unlock()
return db.usersTotal, db.membersTotal, db.activeUsersDay, db.activeUsersWeek, db.activeUsersMonth
}
db.countMu.Unlock()
numUsers, _ = db.TotalUserCount(ctx)
numMembers, _ = db.TotalMemberCount(ctx)
usersDay, _ = db.ActiveUsers(ctx, ActiveDay)
usersWeek, _ = db.ActiveUsers(ctx, ActiveWeek)
usersMonth, _ = db.ActiveUsers(ctx, ActiveMonth)
return numUsers, numMembers, usersDay, usersWeek, usersMonth
}
func (db *DB) TotalUserCount(ctx context.Context) (numUsers int64, err error) {
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL").Scan(&numUsers)
if err != nil {
return 0, errors.Wrap(err, "querying user count")
}
return numUsers, nil
}
func (db *DB) TotalMemberCount(ctx context.Context) (numMembers int64, err error) {
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM members WHERE unlisted = false AND user_id = ANY(SELECT id FROM users WHERE deleted_at IS NULL)").Scan(&numMembers)
if err != nil {
return 0, errors.Wrap(err, "querying member count")
}
return numMembers, nil
}
const (
ActiveMonth = 30 * 24 * time.Hour
ActiveWeek = 7 * 24 * time.Hour
ActiveDay = 24 * time.Hour
)
func (db *DB) ActiveUsers(ctx context.Context, dur time.Duration) (numUsers int64, err error) {
t := time.Now().Add(-dur)
err = db.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL AND last_active > $1", t).Scan(&numUsers)
if err != nil {
return 0, errors.Wrap(err, "querying active user count")
}
return numUsers, nil
}
type connOrTx interface {
Exec(ctx context.Context, sql string, arguments ...any) (commandTag pgconn.CommandTag, err error)
}
// UpdateActiveTime is called on create and update endpoints (PATCH /users/@me, POST/PATCH/DELETE /members)
func (db *DB) UpdateActiveTime(ctx context.Context, tx connOrTx, userID xid.ID) (err error) {
sql, args, err := sq.Update("users").Set("last_active", time.Now().UTC()).Where("id = ?", userID).ToSql()
if err != nil {
return errors.Wrap(err, "building sql")
}
_, err = tx.Exec(ctx, sql, args...)
if err != nil {
return errors.Wrap(err, "executing query")
}
return nil
}

View file

@ -4,7 +4,7 @@ import (
"context"
"emperror.dev/errors"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v5"
"github.com/rs/xid"
)

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

@ -5,8 +5,8 @@ import (
"time"
"emperror.dev/errors"
"github.com/georgysavva/scany/pgxscan"
"github.com/jackc/pgx/v4"
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
"github.com/rs/xid"
)
@ -59,7 +59,13 @@ func (db *DB) Reports(ctx context.Context, closed bool, before int) (rs []Report
}
func (db *DB) ReportsByUser(ctx context.Context, userID xid.ID, before int) (rs []Report, err error) {
builder := sq.Select("*").From("reports").Where("user_id = ?", userID).Limit(ReportPageSize).OrderBy("id DESC")
builder := sq.Select("*",
"(SELECT username FROM users WHERE id = reports.user_id) AS user_name",
"(SELECT name FROM members WHERE id = reports.member_id) AS member_name").
From("reports").
Where("user_id = ?", userID).
Limit(ReportPageSize).
OrderBy("id DESC")
if before != 0 {
builder = builder.Where("id < ?", before)
}
@ -79,7 +85,13 @@ func (db *DB) ReportsByUser(ctx context.Context, userID xid.ID, before int) (rs
}
func (db *DB) ReportsByReporter(ctx context.Context, reporterID xid.ID, before int) (rs []Report, err error) {
builder := sq.Select("*").From("reports").Where("reporter_id = ?", reporterID).Limit(ReportPageSize).OrderBy("id DESC")
builder := sq.Select("*",
"(SELECT username FROM users WHERE id = reports.user_id) AS user_name",
"(SELECT name FROM members WHERE id = reports.member_id) AS member_name").
From("reports").
Where("reporter_id = ?", reporterID).
Limit(ReportPageSize).
OrderBy("id DESC")
if before != 0 {
builder = builder.Where("id < ?", before)
}

View file

@ -5,8 +5,8 @@ import (
"time"
"emperror.dev/errors"
"github.com/georgysavva/scany/pgxscan"
"github.com/jackc/pgx/v4"
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
"github.com/rs/xid"
)
@ -14,6 +14,8 @@ type Token struct {
UserID xid.ID
TokenID xid.ID
Invalidated bool
APIOnly bool `db:"api_only"`
ReadOnly bool
Created time.Time
Expires time.Time
}
@ -59,13 +61,18 @@ func (db *DB) Tokens(ctx context.Context, userID xid.ID) (ts []Token, err error)
}
// 3 months, might be customizable later
const ExpiryTime = 3 * 30 * 24 * time.Hour
const TokenExpiryTime = 3 * 30 * 24 * time.Hour
// SaveToken saves a token to the database.
func (db *DB) SaveToken(ctx context.Context, userID xid.ID, tokenID xid.ID) (t Token, err error) {
func (db *DB) SaveToken(ctx context.Context, userID xid.ID, tokenID xid.ID, apiOnly, readOnly bool) (t Token, err error) {
sql, args, err := sq.Insert("tokens").
Columns("user_id", "token_id", "expires").
Values(userID, tokenID, time.Now().Add(ExpiryTime)).
SetMap(map[string]any{
"user_id": userID,
"token_id": tokenID,
"expires": time.Now().Add(TokenExpiryTime),
"api_only": apiOnly,
"read_only": readOnly,
}).
Suffix("RETURNING *").
ToSql()
if err != nil {

View file

@ -4,22 +4,31 @@ import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"regexp"
"strings"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/icons"
"emperror.dev/errors"
"github.com/Masterminds/squirrel"
"github.com/bwmarrin/discordgo"
"github.com/georgysavva/scany/pgxscan"
"github.com/jackc/pgconn"
"github.com/jackc/pgx/v4"
"github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/rs/xid"
)
type User struct {
ID xid.ID
SnowflakeID common.UserID
SID string `db:"sid"`
Username string
DisplayName *string
Bio *string
MemberTitle *string
LastActive time.Time
Avatar *string
Links []string
@ -35,14 +44,63 @@ type User struct {
FediverseAppID *int64
FediverseInstance *string
MaxInvites int
IsAdmin bool
Tumblr *string
TumblrUsername *string
Google *string
GoogleUsername *string
MaxInvites int
IsAdmin bool
ListPrivate bool
LastSIDReroll time.Time `db:"last_sid_reroll"`
Timezone *string
Settings UserSettings
DeletedAt *time.Time
SelfDelete *bool
DeleteReason *string
CustomPreferences CustomPreferences
}
type CustomPreferences = map[string]CustomPreference
type CustomPreference struct {
Icon string `json:"icon"`
Tooltip string `json:"tooltip"`
Size PreferenceSize `json:"size"`
Muted bool `json:"muted"`
Favourite bool `json:"favourite"`
}
func (c CustomPreference) Validate() string {
if !icons.IsValid(c.Icon) {
return fmt.Sprintf("custom preference icon %q is invalid", c.Icon)
}
if c.Tooltip == "" {
return "custom preference tooltip is empty"
}
if common.StringLength(&c.Tooltip) > FieldEntryMaxLength {
return fmt.Sprintf("custom preference tooltip is too long, max %d characters, is %d characters", FieldEntryMaxLength, common.StringLength(&c.Tooltip))
}
if c.Size != PreferenceSizeLarge && c.Size != PreferenceSizeNormal && c.Size != PreferenceSizeSmall {
return fmt.Sprintf("custom preference size %q is invalid", string(c.Size))
}
return ""
}
type PreferenceSize string
const (
PreferenceSizeLarge PreferenceSize = "large"
PreferenceSizeNormal PreferenceSize = "normal"
PreferenceSizeSmall PreferenceSize = "small"
)
func (u User) NumProviders() (numProviders int) {
if u.Discord != nil {
numProviders++
@ -50,12 +108,74 @@ func (u User) NumProviders() (numProviders int) {
if u.Fediverse != nil {
numProviders++
}
if u.Tumblr != nil {
numProviders++
}
if u.Google != nil {
numProviders++
}
return numProviders
}
// UTCOffset returns the user's UTC offset in seconds. If the user does not have a timezone set, `ok` is false.
func (u User) UTCOffset() (offset int, ok bool) {
if u.Timezone == nil {
return 0, false
}
loc, err := time.LoadLocation(*u.Timezone)
if err != nil {
return 0, false
}
_, offset = time.Now().In(loc).Zone()
return offset, true
}
type Badge int32
const (
BadgeAdmin Badge = 1 << 0
)
// usernames must match this regex
var usernameRegex = regexp.MustCompile(`^[\w-.]{2,40}$`)
// List of usernames that cannot be used, because they could create confusion, conflict with other pages, or cause bugs.
var invalidUsernames = []string{
"..",
"admin",
"administrator",
"mod",
"moderator",
"api",
"page",
"pronouns",
"settings",
"pronouns.cc",
"pronounscc",
}
func UsernameValid(username string) (err error) {
if !usernameRegex.MatchString(username) {
if len(username) < 2 {
return ErrUsernameTooShort
} else if len(username) > 40 {
return ErrUsernameTooLong
}
return ErrInvalidUsername
}
for i := range invalidUsernames {
if strings.EqualFold(username, invalidUsernames[i]) {
return ErrBannedUsername
}
}
return nil
}
const (
ErrUserNotFound = errors.Sentinel("user not found")
@ -63,6 +183,7 @@ const (
ErrInvalidUsername = errors.Sentinel("username contains invalid characters")
ErrUsernameTooShort = errors.Sentinel("username is too short")
ErrUsernameTooLong = errors.Sentinel("username is too long")
ErrBannedUsername = errors.Sentinel("username is banned")
)
const (
@ -82,17 +203,11 @@ const (
func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u User, err error) {
// check if the username is valid
// if not, return an error depending on what failed
if !usernameRegex.MatchString(username) {
if len(username) < 2 {
return u, ErrUsernameTooShort
} else if len(username) > 40 {
return u, ErrUsernameTooLong
}
return u, ErrInvalidUsername
if err := UsernameValid(username); err != nil {
return u, err
}
sql, args, err := sq.Insert("users").Columns("id", "username").Values(xid.New(), username).Suffix("RETURNING *").ToSql()
sql, args, err := sq.Insert("users").Columns("id", "snowflake_id", "username", "sid").Values(xid.New(), common.GenerateID(), username, squirrel.Expr("find_free_user_sid()")).Suffix("RETURNING *").ToSql()
if err != nil {
return u, errors.Wrap(err, "building sql")
}
@ -102,7 +217,7 @@ func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u Use
pge := &pgconn.PgError{}
if errors.As(err, &pge) {
// unique constraint violation
if pge.Code == "23505" {
if pge.Code == uniqueViolation {
return u, ErrUsernameTaken
}
}
@ -238,6 +353,128 @@ func (u *User) UnlinkDiscord(ctx context.Context, ex Execer) error {
return nil
}
// TumblrUser fetches a user by Tumblr user ID.
func (db *DB) TumblrUser(ctx context.Context, tumblrID string) (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("tumblr = ?", tumblrID).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, "executing query")
}
return u, nil
}
func (u *User) UpdateFromTumblr(ctx context.Context, ex Execer, tumblrID, tumblrUsername string) error {
sql, args, err := sq.Update("users").
Set("tumblr", tumblrID).
Set("tumblr_username", tumblrUsername).
Where("id = ?", u.ID).
ToSql()
if err != nil {
return errors.Wrap(err, "building sql")
}
_, err = ex.Exec(ctx, sql, args...)
if err != nil {
return errors.Wrap(err, "executing query")
}
u.Tumblr = &tumblrID
u.TumblrUsername = &tumblrUsername
return nil
}
func (u *User) UnlinkTumblr(ctx context.Context, ex Execer) error {
sql, args, err := sq.Update("users").
Set("tumblr", nil).
Set("tumblr_username", nil).
Where("id = ?", u.ID).
ToSql()
if err != nil {
return errors.Wrap(err, "building sql")
}
_, err = ex.Exec(ctx, sql, args...)
if err != nil {
return errors.Wrap(err, "executing query")
}
u.Tumblr = nil
u.TumblrUsername = nil
return nil
}
// GoogleUser fetches a user by Google user ID.
func (db *DB) GoogleUser(ctx context.Context, googleID string) (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("google = ?", googleID).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, "executing query")
}
return u, nil
}
func (u *User) UpdateFromGoogle(ctx context.Context, ex Execer, googleID, googleUsername string) error {
sql, args, err := sq.Update("users").
Set("google", googleID).
Set("google_username", googleUsername).
Where("id = ?", u.ID).
ToSql()
if err != nil {
return errors.Wrap(err, "building sql")
}
_, err = ex.Exec(ctx, sql, args...)
if err != nil {
return errors.Wrap(err, "executing query")
}
u.Google = &googleID
u.GoogleUsername = &googleUsername
return nil
}
func (u *User) UnlinkGoogle(ctx context.Context, ex Execer) error {
sql, args, err := sq.Update("users").
Set("google", nil).
Set("google_username", nil).
Where("id = ?", u.ID).
ToSql()
if err != nil {
return errors.Wrap(err, "building sql")
}
_, err = ex.Exec(ctx, sql, args...)
if err != nil {
return errors.Wrap(err, "executing query")
}
u.Google = nil
u.GoogleUsername = nil
return nil
}
// User gets a user by ID.
func (db *DB) User(ctx context.Context, id xid.ID) (u User, err error) {
sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
@ -258,6 +495,26 @@ func (db *DB) User(ctx context.Context, id xid.ID) (u User, err error) {
return u, nil
}
// UserBySnowflake gets a user by their snowflake ID.
func (db *DB) UserBySnowflake(ctx context.Context, id common.UserID) (u User, err error) {
sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
From("users").Where("snowflake_id = ?", id).ToSql()
if err != nil {
return u, errors.Wrap(err, "building sql")
}
err = pgxscan.Get(ctx, db, &u, sql, args...)
if err != nil {
if errors.Cause(err) == pgx.ErrNoRows {
return u, ErrUserNotFound
}
return u, errors.Wrap(err, "getting user from db")
}
return u, nil
}
// Username gets a user by username.
func (db *DB) Username(ctx context.Context, name string) (u User, err error) {
sql, args, err := sq.Select("*").From("users").Where("username = ?", name).ToSql()
@ -277,9 +534,28 @@ func (db *DB) Username(ctx context.Context, name string) (u User, err error) {
return u, nil
}
// UserBySID gets a user by their short ID.
func (db *DB) UserBySID(ctx context.Context, sid string) (u User, err error) {
sql, args, err := sq.Select("*").From("users").Where("sid = ?", sid).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
}
// UsernameTaken checks if the given username is already taken.
func (db *DB) UsernameTaken(ctx context.Context, username string) (valid, taken bool, err error) {
if !usernameRegex.MatchString(username) {
if err := UsernameValid(username); err != nil {
return false, false, nil
}
@ -289,8 +565,8 @@ func (db *DB) UsernameTaken(ctx context.Context, username string) (valid, taken
// UpdateUsername validates the given username, then updates the given user's name to it if valid.
func (db *DB) UpdateUsername(ctx context.Context, tx pgx.Tx, id xid.ID, newName string) error {
if !usernameRegex.MatchString(newName) {
return ErrInvalidUsername
if err := UsernameValid(newName); err != nil {
return err
}
sql, args, err := sq.Update("users").Set("username", newName).Where("id = ?", id).ToSql()
@ -303,7 +579,7 @@ func (db *DB) UpdateUsername(ctx context.Context, tx pgx.Tx, id xid.ID, newName
pge := &pgconn.PgError{}
if errors.As(err, &pge) {
// unique constraint violation
if pge.Code == "23505" {
if pge.Code == uniqueViolation {
return ErrUsernameTaken
}
}
@ -317,10 +593,13 @@ func (db *DB) UpdateUser(
ctx context.Context,
tx pgx.Tx, id xid.ID,
displayName, bio *string,
memberTitle *string, listPrivate *bool,
links *[]string,
avatar *string,
timezone *string,
customPreferences *CustomPreferences,
) (u User, err error) {
if displayName == nil && bio == nil && links == nil && avatar == nil {
if displayName == nil && bio == nil && links == nil && avatar == nil && memberTitle == nil && listPrivate == nil && timezone == nil && customPreferences == nil {
sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql()
if err != nil {
return u, errors.Wrap(err, "building sql")
@ -349,9 +628,29 @@ func (db *DB) UpdateUser(
builder = builder.Set("bio", *bio)
}
}
if memberTitle != nil {
if *memberTitle == "" {
builder = builder.Set("member_title", nil)
} else {
builder = builder.Set("member_title", *memberTitle)
}
}
if timezone != nil {
if *timezone == "" {
builder = builder.Set("timezone", nil)
} else {
builder = builder.Set("timezone", *timezone)
}
}
if links != nil {
builder = builder.Set("links", *links)
}
if listPrivate != nil {
builder = builder.Set("list_private", *listPrivate)
}
if customPreferences != nil {
builder = builder.Set("custom_preferences", *customPreferences)
}
if avatar != nil {
if *avatar == "" {
@ -390,6 +689,23 @@ func (db *DB) DeleteUser(ctx context.Context, tx pgx.Tx, id xid.ID, selfDelete b
return nil
}
func (db *DB) RerollUserSID(ctx context.Context, id xid.ID) (newID string, err error) {
sql, args, err := sq.Update("users").
Set("sid", squirrel.Expr("find_free_user_sid()")).
Set("last_sid_reroll", time.Now()).
Where("id = ?", id).
Suffix("RETURNING sid").ToSql()
if err != nil {
return "", errors.Wrap(err, "building sql")
}
err = db.QueryRow(ctx, sql, args...).Scan(&newID)
if err != nil {
return "", errors.Wrap(err, "executing query")
}
return newID, nil
}
func (db *DB) UndoDeleteUser(ctx context.Context, id xid.ID) error {
sql, args, err := sq.Update("users").
Set("deleted_at", nil).
@ -492,7 +808,7 @@ func (db *DB) CleanUser(ctx context.Context, id xid.ID) error {
}
}
members, err := db.UserMembers(ctx, u.ID)
members, err := db.UserMembers(ctx, u.ID, true)
if err != nil {
return errors.Wrap(err, "getting members")
}
@ -509,3 +825,24 @@ func (db *DB) CleanUser(ctx context.Context, id xid.ID) error {
}
return nil
}
const inactiveUsersSQL = `select id, snowflake_id from users
where last_active < now() - '30 days'::interval
and display_name is null and bio is null and timezone is null
and links is null and avatar is null and member_title is null
and names = '[]' and pronouns = '[]'
and (select count(m.id) from members m where user_id = users.id) = 0
and (select count(f.id) from user_fields f where user_id = users.id) = 0;`
// InactiveUsers gets the list of inactive users from the database.
// "Inactive" is defined as:
// - not logged in for 30 days or more
// - no display name, bio, avatar, names, pronouns, profile links, or profile fields
// - no members
func (db *DB) InactiveUsers(ctx context.Context, tx pgx.Tx) (us []User, err error) {
err = pgxscan.Select(ctx, tx, &us, inactiveUsersSQL)
if err != nil {
return nil, errors.Wrap(err, "executing query")
}
return us, nil
}

View file

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

View file

@ -13,8 +13,8 @@ import (
"os/signal"
"sync"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/rs/xid"
@ -118,6 +118,11 @@ func (s *server) doExport(u db.User) {
log.Debugf("[%v] starting export of user", u.ID)
jsonBuffer := new(bytes.Buffer)
encoder := json.NewEncoder(jsonBuffer)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
outBuffer := new(bytes.Buffer)
zw := zip.NewWriter(outBuffer)
defer zw.Close()
@ -136,19 +141,28 @@ func (s *server) doExport(u db.User) {
return
}
log.Debugf("[%v] getting user warnings", u.ID)
warnings, err := s.DB.Warnings(ctx, u.ID, false)
if err != nil {
log.Errorf("[%v] getting warnings: %v", u.ID, err)
return
}
log.Debugf("[%v] writing user json", u.ID)
ub, err := json.Marshal(dbUserToExport(u, fields))
err = encoder.Encode(dbUserToExport(u, fields, warnings))
if err != nil {
log.Errorf("[%v] marshaling user: %v", u.ID, err)
return
}
_, err = w.Write(ub)
_, err = io.Copy(w, jsonBuffer)
if err != nil {
log.Errorf("[%v] writing user: %v", u.ID, err)
return
}
jsonBuffer.Reset()
if u.Avatar != nil {
log.Debugf("[%v] getting user avatar", u.ID)
@ -175,7 +189,7 @@ func (s *server) doExport(u db.User) {
log.Debugf("[%v] exported user avatar", u.ID)
}
members, err := s.DB.UserMembers(ctx, u.ID)
members, err := s.DB.UserMembers(ctx, u.ID, true)
if err != nil {
log.Errorf("[%v] getting user members: %v", u.ID, err)
return
@ -196,17 +210,18 @@ func (s *server) doExport(u db.User) {
return
}
mb, err := json.Marshal(dbMemberToExport(m, fields))
err = encoder.Encode(dbMemberToExport(m, fields))
if err != nil {
log.Errorf("[%v] marshaling member %v: %v", u.ID, m.ID, err)
return
}
_, err = w.Write(mb)
_, err = io.Copy(w, jsonBuffer)
if err != nil {
log.Errorf("[%v] writing member %v json: %v", u.ID, m.ID, err)
return
}
jsonBuffer.Reset()
if m.Avatar != nil {
log.Debugf("[%v] getting member %v avatar", u.ID, m.ID)

View file

@ -1,7 +1,7 @@
package exporter
import (
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"github.com/rs/xid"
)
@ -17,25 +17,45 @@ type userExport struct {
Pronouns []db.PronounEntry `json:"pronouns"`
Fields []db.Field `json:"fields"`
Fediverse *string `json:"fediverse"`
FediverseUsername *string `json:"fediverse_username"`
FediverseInstance *string `json:"fediverse_instance"`
Discord *string `json:"discord"`
DiscordUsername *string `json:"discord_username"`
Tumblr *string `json:"tumblr"`
TumblrUsername *string `json:"tumblr_username"`
Google *string `json:"google"`
GoogleUsername *string `json:"google_username"`
MaxInvites int `json:"max_invites"`
Warnings []db.Warning `json:"warnings"`
}
func dbUserToExport(u db.User, fields []db.Field) userExport {
func dbUserToExport(u db.User, fields []db.Field, warnings []db.Warning) userExport {
return userExport{
ID: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Bio: u.Bio,
Links: u.Links,
Names: u.Names,
Pronouns: u.Pronouns,
Fields: fields,
Discord: u.Discord,
DiscordUsername: u.DiscordUsername,
MaxInvites: u.MaxInvites,
ID: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Bio: u.Bio,
Links: db.NotNull(u.Links),
Names: db.NotNull(u.Names),
Pronouns: db.NotNull(u.Pronouns),
Fields: db.NotNull(fields),
Discord: u.Discord,
DiscordUsername: u.DiscordUsername,
Tumblr: u.Tumblr,
TumblrUsername: u.TumblrUsername,
Google: u.Google,
GoogleUsername: u.GoogleUsername,
MaxInvites: u.MaxInvites,
Fediverse: u.Fediverse,
FediverseUsername: u.FediverseUsername,
FediverseInstance: u.FediverseInstance,
Warnings: db.NotNull(warnings),
}
}
@ -48,6 +68,7 @@ type memberExport struct {
Names []db.FieldEntry `json:"names"`
Pronouns []db.PronounEntry `json:"pronouns"`
Fields []db.Field `json:"fields"`
Unlisted bool `json:"unlisted"`
}
func dbMemberToExport(m db.Member, fields []db.Field) memberExport {
@ -56,9 +77,10 @@ func dbMemberToExport(m db.Member, fields []db.Field) memberExport {
Name: m.Name,
DisplayName: m.DisplayName,
Bio: m.Bio,
Links: m.Links,
Names: m.Names,
Pronouns: m.Pronouns,
Fields: fields,
Links: db.NotNull(m.Links),
Names: db.NotNull(m.Names),
Pronouns: db.NotNull(m.Pronouns),
Fields: db.NotNull(fields),
Unlisted: m.Unlisted,
}
}

1964
backend/icons/icons.go Normal file

File diff suppressed because it is too large Load diff

View file

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

99
backend/prns/main.go Normal file
View file

@ -0,0 +1,99 @@
package prns
import (
"context"
"net/http"
"os"
"os/signal"
"strings"
dbpkg "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"github.com/urfave/cli/v2"
)
var Command = &cli.Command{
Name: "shortener",
Usage: "URL shortener service",
Action: run,
}
func run(c *cli.Context) error {
port := ":" + os.Getenv("PRNS_PORT")
baseURL := os.Getenv("BASE_URL")
db, err := dbpkg.New()
if err != nil {
log.Fatalf("creating database: %v", err)
return err
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
defer func() {
if r := recover(); r != nil {
log.Errorf("recovered from panic: %v", err)
}
}()
id := strings.TrimPrefix(r.URL.Path, "/")
if len(id) == 5 {
u, err := db.UserBySID(r.Context(), id)
if err != nil {
if err != dbpkg.ErrUserNotFound {
log.Errorf("getting user: %v", err)
}
http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect)
return
}
http.Redirect(w, r, baseURL+"/@"+u.Username, http.StatusTemporaryRedirect)
return
}
if len(id) == 6 {
m, err := db.MemberBySID(r.Context(), id)
if err != nil {
if err != dbpkg.ErrMemberNotFound {
log.Errorf("getting member: %v", err)
}
http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect)
return
}
u, err := db.User(r.Context(), m.UserID)
if err != nil {
log.Errorf("getting user for member %v: %v", m.ID, err)
http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect)
return
}
http.Redirect(w, r, baseURL+"/@"+u.Username+"/"+m.Name, http.StatusTemporaryRedirect)
return
}
http.Redirect(w, r, baseURL, http.StatusTemporaryRedirect)
})
e := make(chan error)
go func() {
e <- http.ListenAndServe(port, nil)
}()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
log.Infof("API server running at %v!", port)
select {
case <-ctx.Done():
log.Info("Interrupt signal received, shutting down...")
db.Close()
return nil
case err := <-e:
log.Fatalf("Error running server: %v", err)
}
return nil
}

View file

@ -1,26 +1,30 @@
package backend
import (
"codeberg.org/u1f320/pronouns.cc/backend/routes/auth"
"codeberg.org/u1f320/pronouns.cc/backend/routes/bot"
"codeberg.org/u1f320/pronouns.cc/backend/routes/member"
"codeberg.org/u1f320/pronouns.cc/backend/routes/meta"
"codeberg.org/u1f320/pronouns.cc/backend/routes/mod"
"codeberg.org/u1f320/pronouns.cc/backend/routes/user"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/auth"
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/member"
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/meta"
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/mod"
"codeberg.org/pronounscc/pronouns.cc/backend/routes/v1/user"
user2 "codeberg.org/pronounscc/pronouns.cc/backend/routes/v2/user"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5"
_ "embed"
)
// mountRoutes mounts all API routes on the server's router.
// they are all mounted under /v1/
func mountRoutes(s *server.Server) {
// future-proofing for API versions
s.Router.Route("/v1", func(r chi.Router) {
auth.Mount(s, r)
user.Mount(s, r)
member.Mount(s, r)
bot.Mount(s, r)
meta.Mount(s, r)
mod.Mount(s, r)
})
s.Router.Route("/v2", func(r chi.Router) {
user2.Mount(s, r)
})
}

View file

@ -1,83 +0,0 @@
package auth
import (
"net/http"
"time"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/jackc/pgx/v4"
"github.com/rs/xid"
)
type getTokenResponse struct {
TokenID xid.ID `json:"id"`
Created time.Time `json:"created"`
Expires time.Time `json:"expires"`
}
func dbTokenToGetResponse(t db.Token) getTokenResponse {
return getTokenResponse{
TokenID: t.TokenID,
Created: t.Created,
Expires: t.Expires,
}
}
func (s *Server) getTokens(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
tokens, err := s.DB.Tokens(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting tokens")
}
resps := make([]getTokenResponse, len(tokens))
for i := range tokens {
resps[i] = dbTokenToGetResponse(tokens[i])
}
render.JSON(w, r, resps)
return nil
}
type deleteTokenResponse struct {
TokenID xid.ID `json:"id"`
Invalidated bool `json:"invalidated"`
Created time.Time `json:"time"`
}
func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
tokenID, err := xid.FromString(chi.URLParam(r, "id"))
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
t, err := s.DB.InvalidateToken(ctx, claims.UserID, tokenID)
if err != nil {
if errors.Cause(err) == pgx.ErrNoRows {
return server.APIError{Code: server.ErrNotFound}
}
return errors.Wrap(err, "invalidating token")
}
render.JSON(w, r, deleteTokenResponse{
TokenID: t.TokenID,
Invalidated: t.Invalidated,
Created: t.Created,
})
return nil
}
func (s *Server) createToken(w http.ResponseWriter, r *http.Request) error {
// unimplemented right now
return server.APIError{Code: server.ErrForbidden}
}

View file

@ -1,183 +0,0 @@
package bot
import (
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"os"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/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 == db.StatusFavourite {
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: uint64(discordgo.MessageFlagsEphemeral),
},
})
}

View file

@ -1,52 +0,0 @@
package member
import (
"net/http"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/server"
)
func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
id, err := xid.FromString(chi.URLParam(r, "memberRef"))
if 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}
}
return errors.Wrap(err, "getting member")
}
if m.UserID != claims.UserID {
return server.APIError{Code: server.ErrNotOwnMember}
}
err = s.DB.DeleteMember(ctx, m.ID)
if err != nil {
return errors.Wrap(err, "deleting member")
}
if m.Avatar != nil {
err = s.DB.DeleteMemberAvatar(ctx, m.ID, *m.Avatar)
if err != nil {
return errors.Wrap(err, "deleting member avatar")
}
}
render.JSON(w, r, map[string]any{"deleted": true})
return nil
}

View file

@ -1,132 +0,0 @@
package member
import (
"context"
"net/http"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
)
type GetMemberResponse struct {
ID xid.ID `json:"id"`
Name string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
Avatar *string `json:"avatar"`
Links []string `json:"links"`
Names []db.FieldEntry `json:"names"`
Pronouns []db.PronounEntry `json:"pronouns"`
Fields []db.Field `json:"fields"`
User PartialUser `json:"user"`
}
func dbMemberToMember(u db.User, m db.Member, fields []db.Field) GetMemberResponse {
return GetMemberResponse{
ID: m.ID,
Name: m.Name,
DisplayName: m.DisplayName,
Bio: m.Bio,
Avatar: m.Avatar,
Links: db.NotNull(m.Links),
Names: db.NotNull(m.Names),
Pronouns: db.NotNull(m.Pronouns),
Fields: db.NotNull(fields),
User: PartialUser{
ID: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Avatar: u.Avatar,
},
}
}
type PartialUser struct {
ID xid.ID `json:"id"`
Username string `json:"name"`
DisplayName *string `json:"display_name"`
Avatar *string `json:"avatar"`
}
func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
id, err := xid.FromString(chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{
Code: server.ErrMemberNotFound,
}
}
m, err := s.DB.Member(ctx, id)
if err != nil {
return server.APIError{
Code: server.ErrMemberNotFound,
}
}
u, err := s.DB.User(ctx, m.UserID)
if err != nil {
return err
}
if u.DeletedAt != nil {
return server.APIError{Code: server.ErrMemberNotFound}
}
fields, err := s.DB.MemberFields(ctx, m.ID)
if err != nil {
return err
}
render.JSON(w, r, dbMemberToMember(u, m, fields))
return nil
}
func (s *Server) getUserMember(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
u, err := s.parseUser(ctx, chi.URLParam(r, "userRef"))
if err != nil {
return server.APIError{
Code: server.ErrUserNotFound,
}
}
if u.DeletedAt != nil {
return server.APIError{Code: server.ErrUserNotFound}
}
m, err := s.DB.UserMember(ctx, u.ID, chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{
Code: server.ErrMemberNotFound,
}
}
fields, err := s.DB.MemberFields(ctx, m.ID)
if err != nil {
return err
}
render.JSON(w, r, dbMemberToMember(u, m, fields))
return nil
}
func (s *Server) parseUser(ctx context.Context, userRef string) (u db.User, err error) {
if id, err := xid.FromString(userRef); err != nil {
u, err := s.DB.User(ctx, id)
if err == nil {
return u, nil
}
}
return s.DB.Username(ctx, userRef)
}

View file

@ -1,75 +0,0 @@
package member
import (
"net/http"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
)
type memberListResponse struct {
ID xid.ID `json:"id"`
Name string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
Avatar *string `json:"avatar"`
Links []string `json:"links"`
Names []db.FieldEntry `json:"names"`
Pronouns []db.PronounEntry `json:"pronouns"`
}
func membersToMemberList(ms []db.Member) []memberListResponse {
resps := make([]memberListResponse, len(ms))
for i := range ms {
resps[i] = memberListResponse{
ID: ms[i].ID,
Name: ms[i].Name,
Bio: ms[i].Bio,
Avatar: ms[i].Avatar,
Links: db.NotNull(ms[i].Links),
Names: db.NotNull(ms[i].Names),
Pronouns: db.NotNull(ms[i].Pronouns),
}
}
return resps
}
func (s *Server) getUserMembers(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
u, err := s.parseUser(ctx, chi.URLParam(r, "userRef"))
if err != nil {
return server.APIError{
Code: server.ErrUserNotFound,
}
}
if u.DeletedAt != nil {
return server.APIError{Code: server.ErrUserNotFound}
}
ms, err := s.DB.UserMembers(ctx, u.ID)
if err != nil {
return err
}
render.JSON(w, r, membersToMemberList(ms))
return nil
}
func (s *Server) getMeMembers(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
ms, err := s.DB.UserMembers(ctx, claims.UserID)
if err != nil {
return err
}
render.JSON(w, r, membersToMemberList(ms))
return nil
}

View file

@ -1,275 +0,0 @@
package member
import (
"fmt"
"net/http"
"strings"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
)
type PatchMemberRequest struct {
Name *string `json:"name"`
Bio *string `json:"bio"`
DisplayName *string `json:"display_name"`
Links *[]string `json:"links"`
Names *[]db.FieldEntry `json:"names"`
Pronouns *[]db.PronounEntry `json:"pronouns"`
Fields *[]db.Field `json:"fields"`
Avatar *string `json:"avatar"`
}
func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
id, err := xid.FromString(chi.URLParam(r, "memberRef"))
if 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}
}
return errors.Wrap(err, "getting member")
}
if m.UserID != claims.UserID {
return server.APIError{Code: server.ErrNotOwnMember}
}
var req PatchMemberRequest
err = render.Decode(r, &req)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
// validate that *something* is set
if req.DisplayName == nil &&
req.Name == nil &&
req.Bio == nil &&
req.Links == nil &&
req.Fields == nil &&
req.Names == nil &&
req.Pronouns == nil &&
req.Avatar == nil {
return server.APIError{
Code: server.ErrBadRequest,
Details: "Data must not be empty",
}
}
// trim whitespace from strings
if req.Name != nil {
*req.Name = strings.TrimSpace(*req.Name)
}
if req.DisplayName != nil {
*req.DisplayName = strings.TrimSpace(*req.DisplayName)
}
if req.Bio != nil {
*req.Bio = strings.TrimSpace(*req.Bio)
}
if req.Name != nil && *req.Name == "" {
return server.APIError{
Code: server.ErrBadRequest,
Details: "Name must not be empty",
}
} else if req.Name != nil && len(*req.Name) > 100 {
return server.APIError{
Code: server.ErrBadRequest,
Details: "Name may not be longer than 100 characters",
}
}
// validate member name
if req.Name != nil {
if !db.MemberNameValid(*req.Name) {
return server.APIError{
Code: server.ErrBadRequest,
Details: "Member name cannot contain any of the following: @, \\, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), +, <, =, >, ^, |, ~, `, ,",
}
}
}
// validate display name/bio
if req.Name != nil && len(*req.Name) > db.MaxMemberNameLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Name name too long (max %d, current %d)", db.MaxMemberNameLength, len(*req.Name)),
}
}
if req.DisplayName != nil && len(*req.DisplayName) > db.MaxDisplayNameLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Display name too long (max %d, current %d)", db.MaxDisplayNameLength, len(*req.DisplayName)),
}
}
if req.Bio != nil && len(*req.Bio) > db.MaxUserBioLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, len(*req.Bio)),
}
}
// validate links
if req.Links != nil {
if len(*req.Links) > db.MaxUserLinksLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Too many links (max %d, current %d)", db.MaxUserLinksLength, len(*req.Links)),
}
}
for i, link := range *req.Links {
if len(link) > db.MaxLinkLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Link %d too long (max %d, current %d)", i, db.MaxLinkLength, len(link)),
}
}
}
}
if err := validateSlicePtr("name", req.Names); err != nil {
return *err
}
if err := validateSlicePtr("pronoun", req.Pronouns); err != nil {
return *err
}
if err := validateSlicePtr("field", req.Fields); err != nil {
return *err
}
// update avatar
var avatarHash *string = nil
if req.Avatar != nil {
if *req.Avatar == "" {
if m.Avatar != nil {
err = s.DB.DeleteMemberAvatar(ctx, m.ID, *m.Avatar)
if err != nil {
log.Errorf("deleting member avatar: %v", err)
return errors.Wrap(err, "deleting avatar")
}
}
avatarHash = req.Avatar
} else {
webp, jpg, err := s.DB.ConvertAvatar(*req.Avatar)
if err != nil {
if err == db.ErrInvalidDataURI {
return server.APIError{
Code: server.ErrBadRequest,
Details: "invalid avatar data URI",
}
} else if err == db.ErrInvalidContentType {
return server.APIError{
Code: server.ErrBadRequest,
Details: "invalid avatar content type",
}
}
log.Errorf("converting member avatar: %v", err)
return err
}
hash, err := s.DB.WriteMemberAvatar(ctx, id, webp, jpg)
if err != nil {
log.Errorf("uploading member avatar: %v", err)
return err
}
avatarHash = &hash
// delete current avatar if member has one
if m.Avatar != nil {
err = s.DB.DeleteMemberAvatar(ctx, claims.UserID, *m.Avatar)
if err != nil {
log.Errorf("deleting existing avatar: %v", err)
}
}
}
}
// start transaction
tx, err := s.DB.Begin(ctx)
if err != nil {
log.Errorf("creating transaction: %v", err)
return err
}
defer tx.Rollback(ctx)
m, err = s.DB.UpdateMember(ctx, tx, id, req.Name, req.DisplayName, req.Bio, req.Links, avatarHash)
if err != nil {
switch errors.Cause(err) {
case db.ErrNothingToUpdate:
case db.ErrMemberNameInUse:
return server.APIError{Code: server.ErrMemberNameInUse}
default:
log.Errorf("updating member: %v", err)
return errors.Wrap(err, "updating member in db")
}
}
if req.Names != nil || req.Pronouns != nil {
names := m.Names
pronouns := m.Pronouns
if req.Names != nil {
names = *req.Names
}
if req.Pronouns != nil {
pronouns = *req.Pronouns
}
err = s.DB.SetMemberNamesPronouns(ctx, tx, id, names, pronouns)
if err != nil {
log.Errorf("setting names for member %v: %v", id, err)
return err
}
m.Names = names
m.Pronouns = pronouns
}
var fields []db.Field
if req.Fields != nil {
err = s.DB.SetMemberFields(ctx, tx, id, *req.Fields)
if err != nil {
log.Errorf("setting fields for member %v: %v", id, err)
return err
}
fields = *req.Fields
} else {
fields, err = s.DB.MemberFields(ctx, id)
if err != nil {
log.Errorf("getting fields for member %v: %v", id, err)
return err
}
}
err = tx.Commit(ctx)
if err != nil {
log.Errorf("committing transaction: %v", err)
return err
}
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting user")
}
// echo the updated member back on success
render.JSON(w, r, dbMemberToMember(u, m, fields))
return nil
}

View file

@ -1,53 +0,0 @@
package meta
import (
"net/http"
"os"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
type Server struct {
*server.Server
}
func Mount(srv *server.Server, r chi.Router) {
s := &Server{Server: srv}
r.Get("/meta", server.WrapHandler(s.meta))
}
type MetaResponse struct {
GitRepository string `json:"git_repository"`
GitCommit string `json:"git_commit"`
Users int64 `json:"users"`
Members int64 `json:"members"`
RequireInvite bool `json:"require_invite"`
}
func (s *Server) meta(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
var numUsers, numMembers int64
err := s.DB.QueryRow(ctx, "SELECT COUNT(*) FROM users").Scan(&numUsers)
if err != nil {
return errors.Wrap(err, "querying user count")
}
err = s.DB.QueryRow(ctx, "SELECT COUNT(*) FROM members").Scan(&numMembers)
if err != nil {
return errors.Wrap(err, "querying user count")
}
render.JSON(w, r, MetaResponse{
GitRepository: server.Repository,
GitCommit: server.Revision,
Users: numUsers,
Members: numMembers,
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
})
return nil
}

View file

@ -1,111 +0,0 @@
package mod
import (
"net/http"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
)
const MaxReasonLength = 2000
type CreateReportRequest struct {
Reason string `json:"reason"`
}
func (s *Server) createUserReport(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
userID, err := xid.FromString(chi.URLParam(r, "id"))
if err != nil {
return server.APIError{Code: server.ErrBadRequest, Details: "Invalid user ID"}
}
u, err := s.DB.User(ctx, userID)
if err != nil {
if err == db.ErrUserNotFound {
return server.APIError{Code: server.ErrUserNotFound}
}
log.Errorf("getting user %v: %v", userID, err)
return errors.Wrap(err, "getting user")
}
if u.DeletedAt != nil {
return server.APIError{Code: server.ErrUserNotFound}
}
var req CreateReportRequest
err = render.Decode(r, &req)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
if len(req.Reason) > MaxReasonLength {
return server.APIError{Code: server.ErrBadRequest, Details: "Reason cannot exceed 2000 characters"}
}
report, err := s.DB.CreateReport(ctx, claims.UserID, u.ID, nil, req.Reason)
if err != nil {
log.Errorf("creating report for %v: %v", u.ID, err)
return errors.Wrap(err, "creating report")
}
render.JSON(w, r, map[string]any{"created": true, "created_at": report.CreatedAt})
return nil
}
func (s *Server) createMemberReport(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
memberID, err := xid.FromString(chi.URLParam(r, "id"))
if err != nil {
return server.APIError{Code: server.ErrBadRequest, Details: "Invalid member ID"}
}
m, err := s.DB.Member(ctx, memberID)
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
log.Errorf("getting member %v: %v", memberID, err)
return errors.Wrap(err, "getting member")
}
u, err := s.DB.User(ctx, m.UserID)
if err != nil {
log.Errorf("getting user %v: %v", m.UserID, err)
return errors.Wrap(err, "getting user")
}
if u.DeletedAt != nil {
return server.APIError{Code: server.ErrMemberNotFound}
}
var req CreateReportRequest
err = render.Decode(r, &req)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
if len(req.Reason) > MaxReasonLength {
return server.APIError{Code: server.ErrBadRequest, Details: "Reason cannot exceed 2000 characters"}
}
report, err := s.DB.CreateReport(ctx, claims.UserID, u.ID, &m.ID, req.Reason)
if err != nil {
log.Errorf("creating report for %v: %v", m.ID, err)
return errors.Wrap(err, "creating report")
}
render.JSON(w, r, map[string]any{"created": true, "created_at": report.CreatedAt})
return nil
}

View file

@ -1,175 +0,0 @@
package user
import (
"net/http"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
)
type GetUserResponse struct {
ID xid.ID `json:"id"`
Username string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
Avatar *string `json:"avatar"`
Links []string `json:"links"`
Names []db.FieldEntry `json:"names"`
Pronouns []db.PronounEntry `json:"pronouns"`
Members []PartialMember `json:"members"`
Fields []db.Field `json:"fields"`
}
type GetMeResponse struct {
GetUserResponse
MaxInvites int `json:"max_invites"`
IsAdmin bool `json:"is_admin"`
Discord *string `json:"discord"`
DiscordUsername *string `json:"discord_username"`
Fediverse *string `json:"fediverse"`
FediverseUsername *string `json:"fediverse_username"`
FediverseInstance *string `json:"fediverse_instance"`
}
type PartialMember struct {
ID xid.ID `json:"id"`
Name string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
Avatar *string `json:"avatar"`
Links []string `json:"links"`
Names []db.FieldEntry `json:"names"`
Pronouns []db.PronounEntry `json:"pronouns"`
}
func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUserResponse {
resp := GetUserResponse{
ID: u.ID,
Username: u.Username,
DisplayName: u.DisplayName,
Bio: u.Bio,
Avatar: u.Avatar,
Links: db.NotNull(u.Links),
Names: db.NotNull(u.Names),
Pronouns: db.NotNull(u.Pronouns),
Fields: db.NotNull(fields),
}
resp.Members = make([]PartialMember, len(members))
for i := range members {
resp.Members[i] = PartialMember{
ID: members[i].ID,
Name: members[i].Name,
DisplayName: members[i].DisplayName,
Bio: members[i].Bio,
Avatar: members[i].Avatar,
Links: db.NotNull(members[i].Links),
Names: db.NotNull(members[i].Names),
Pronouns: db.NotNull(members[i].Pronouns),
}
}
return resp
}
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
userRef := chi.URLParamFromCtx(ctx, "userRef")
if id, err := xid.FromString(userRef); err == nil {
u, err := s.DB.User(ctx, id)
if err == nil {
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
log.Errorf("Error getting user fields: %v", err)
return err
}
members, err := s.DB.UserMembers(ctx, u.ID)
if err != nil {
log.Errorf("Error getting user members: %v", err)
return err
}
render.JSON(w, r, dbUserToResponse(u, fields, members))
return nil
} else if err != db.ErrUserNotFound {
log.Errorf("Error getting user by ID: %v", err)
return err
}
// otherwise, we fall back to checking usernames
}
u, err := s.DB.Username(ctx, userRef)
if err == db.ErrUserNotFound {
return server.APIError{
Code: server.ErrUserNotFound,
}
} else if err != nil {
log.Errorf("Error getting user by username: %v", err)
return err
}
if u.DeletedAt != nil {
return server.APIError{Code: server.ErrUserNotFound}
}
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
log.Errorf("Error getting user fields: %v", err)
return err
}
members, err := s.DB.UserMembers(ctx, u.ID)
if err != nil {
log.Errorf("Error getting user members: %v", err)
return err
}
render.JSON(w, r, dbUserToResponse(u, fields, members))
return nil
}
func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
log.Errorf("Error getting user: %v", err)
return err
}
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
log.Errorf("Error getting user fields: %v", err)
return err
}
members, err := s.DB.UserMembers(ctx, u.ID)
if err != nil {
log.Errorf("Error getting user members: %v", err)
return err
}
render.JSON(w, r, GetMeResponse{
GetUserResponse: dbUserToResponse(u, fields, members),
MaxInvites: u.MaxInvites,
IsAdmin: u.IsAdmin,
Discord: u.Discord,
DiscordUsername: u.DiscordUsername,
Fediverse: u.Fediverse,
FediverseUsername: u.FediverseUsername,
FediverseInstance: u.FediverseInstance,
})
return nil
}

View file

@ -1,280 +0,0 @@
package user
import (
"fmt"
"net/http"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
)
type PatchUserRequest struct {
Username *string `json:"username"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
Links *[]string `json:"links"`
Names *[]db.FieldEntry `json:"names"`
Pronouns *[]db.PronounEntry `json:"pronouns"`
Fields *[]db.Field `json:"fields"`
Avatar *string `json:"avatar"`
}
// patchUser parses a PatchUserRequest and updates the user with the given ID.
func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
var req PatchUserRequest
err := render.Decode(r, &req)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
// get existing user, for comparison later
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting existing user")
}
// validate that *something* is set
if req.Username == nil &&
req.DisplayName == nil &&
req.Bio == nil &&
req.Links == nil &&
req.Fields == nil &&
req.Names == nil &&
req.Pronouns == nil &&
req.Avatar == nil {
return server.APIError{
Code: server.ErrBadRequest,
Details: "Data must not be empty",
}
}
// validate display name/bio
if req.DisplayName != nil && len(*req.DisplayName) > db.MaxDisplayNameLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Display name too long (max %d, current %d)", db.MaxDisplayNameLength, len(*req.DisplayName)),
}
}
if req.Bio != nil && len(*req.Bio) > db.MaxUserBioLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, len(*req.Bio)),
}
}
// validate links
if req.Links != nil {
if len(*req.Links) > db.MaxUserLinksLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Too many links (max %d, current %d)", db.MaxUserLinksLength, len(*req.Links)),
}
}
for i, link := range *req.Links {
if len(link) > db.MaxLinkLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Link %d too long (max %d, current %d)", i, db.MaxLinkLength, len(link)),
}
}
}
}
if err := validateSlicePtr("name", req.Names); err != nil {
return *err
}
if err := validateSlicePtr("pronoun", req.Pronouns); err != nil {
return *err
}
if err := validateSlicePtr("field", req.Fields); err != nil {
return *err
}
// update avatar
var avatarHash *string = nil
if req.Avatar != nil {
if *req.Avatar == "" {
if u.Avatar != nil {
err = s.DB.DeleteUserAvatar(ctx, u.ID, *u.Avatar)
if err != nil {
log.Errorf("deleting user avatar: %v", err)
return errors.Wrap(err, "deleting avatar")
}
}
avatarHash = req.Avatar
} else {
webp, jpg, err := s.DB.ConvertAvatar(*req.Avatar)
if err != nil {
if err == db.ErrInvalidDataURI {
return server.APIError{
Code: server.ErrBadRequest,
Details: "invalid avatar data URI",
}
} else if err == db.ErrInvalidContentType {
return server.APIError{
Code: server.ErrBadRequest,
Details: "invalid avatar content type",
}
}
log.Errorf("converting user avatar: %v", err)
return err
}
hash, err := s.DB.WriteUserAvatar(ctx, claims.UserID, webp, jpg)
if err != nil {
log.Errorf("uploading user avatar: %v", err)
return err
}
avatarHash = &hash
// delete current avatar if user has one
if u.Avatar != nil {
err = s.DB.DeleteUserAvatar(ctx, claims.UserID, *u.Avatar)
if err != nil {
log.Errorf("deleting existing avatar: %v", err)
}
}
}
}
// start transaction
tx, err := s.DB.Begin(ctx)
if err != nil {
log.Errorf("creating transaction: %v", err)
return err
}
defer tx.Rollback(ctx)
// update username
if req.Username != nil && *req.Username != u.Username {
err = s.DB.UpdateUsername(ctx, tx, claims.UserID, *req.Username)
if err != nil {
switch err {
case db.ErrUsernameTaken:
return server.APIError{Code: server.ErrUsernameTaken}
case db.ErrInvalidUsername:
return server.APIError{Code: server.ErrInvalidUsername}
default:
return errors.Wrap(err, "updating username")
}
}
}
u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.Links, avatarHash)
if err != nil && errors.Cause(err) != db.ErrNothingToUpdate {
log.Errorf("updating user: %v", err)
return err
}
if req.Names != nil || req.Pronouns != nil {
names := u.Names
pronouns := u.Pronouns
if req.Names != nil {
names = *req.Names
}
if req.Pronouns != nil {
pronouns = *req.Pronouns
}
err = s.DB.SetUserNamesPronouns(ctx, tx, claims.UserID, names, pronouns)
if err != nil {
log.Errorf("setting names for member %v: %v", claims.UserID, err)
return err
}
u.Names = names
u.Pronouns = pronouns
}
var fields []db.Field
if req.Fields != nil {
err = s.DB.SetUserFields(ctx, tx, claims.UserID, *req.Fields)
if err != nil {
log.Errorf("setting fields for user %v: %v", claims.UserID, err)
return err
}
fields = *req.Fields
} else {
fields, err = s.DB.UserFields(ctx, claims.UserID)
if err != nil {
log.Errorf("getting fields for user %v: %v", claims.UserID, err)
return err
}
}
err = tx.Commit(ctx)
if err != nil {
log.Errorf("committing transaction: %v", err)
return err
}
// get fedi instance name if the user has a linked fedi account
var fediInstance *string
if u.FediverseAppID != nil {
app, err := s.DB.FediverseAppByID(ctx, *u.FediverseAppID)
if err == nil {
fediInstance = &app.Instance
}
}
// echo the updated user back on success
render.JSON(w, r, GetMeResponse{
GetUserResponse: dbUserToResponse(u, fields, nil),
MaxInvites: u.MaxInvites,
IsAdmin: u.IsAdmin,
Discord: u.Discord,
DiscordUsername: u.DiscordUsername,
Fediverse: u.Fediverse,
FediverseUsername: u.FediverseUsername,
FediverseInstance: fediInstance,
})
return nil
}
type validator interface {
Validate() string
}
// validateSlicePtr validates a slice of validators.
// If the slice is nil, a nil error is returned (assuming that the field is not required)
func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError {
if slice == nil {
return nil
}
max := db.MaxFields
if typ != "field" {
max = db.FieldEntriesLimit
}
// max 25 fields
if len(*slice) > max {
return &server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Too many %ss (max %d, current %d)", typ, max, len(*slice)),
}
}
// validate all fields
for i, pronouns := range *slice {
if s := pronouns.Validate(); s != "" {
return &server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("%s %d: %s", typ, i+1, s),
}
}
}
return nil
}

View file

@ -0,0 +1,57 @@
package auth
import (
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
)
const hcaptchaURL = "https://hcaptcha.com/siteverify"
type hcaptchaResponse struct {
Success bool `json:"success"`
}
// verifyCaptcha verifies a captcha response.
func (s *Server) verifyCaptcha(ctx context.Context, response string) (ok bool, err error) {
vals := url.Values{
"response": []string{response},
"secret": []string{s.hcaptchaSecret},
"sitekey": []string{s.hcaptchaSitekey},
}
req, err := http.NewRequestWithContext(ctx, "POST", hcaptchaURL, strings.NewReader(vals.Encode()))
if err != nil {
return false, errors.Wrap(err, "creating request")
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false, errors.Wrap(err, "sending request")
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
return false, errors.Sentinel("error status code")
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return false, errors.Wrap(err, "reading body")
}
var hr hcaptchaResponse
err = json.Unmarshal(b, &hr)
if err != nil {
return false, errors.Wrap(err, "unmarshaling json")
}
return hr.Success, nil
}

View file

@ -5,12 +5,13 @@ import (
"os"
"time"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/bwmarrin/discordgo"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4"
"github.com/rs/xid"
"golang.org/x/oauth2"
@ -27,7 +28,7 @@ var discordOAuthConfig = oauth2.Config{
Scopes: []string{"identify"},
}
type discordOauthCallbackRequest struct {
type oauthCallbackRequest struct {
CallbackDomain string `json:"callback_domain"`
Code string `json:"code"`
State string `json:"state"`
@ -39,9 +40,10 @@ type discordCallbackResponse struct {
Token string `json:"token,omitempty"`
User *userResponse `json:"user,omitempty"`
Discord string `json:"discord,omitempty"` // username, for UI purposes
Ticket string `json:"ticket,omitempty"`
RequireInvite bool `json:"require_invite"` // require an invite for signing up
Discord string `json:"discord,omitempty"` // username, for UI purposes
Ticket string `json:"ticket,omitempty"`
RequireInvite bool `json:"require_invite"` // require an invite for signing up
RequireCaptcha bool `json:"require_captcha"`
IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
@ -52,7 +54,7 @@ type discordCallbackResponse struct {
func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
decoded, err := Decode[discordOauthCallbackRequest](r)
decoded, err := Decode[oauthCallbackRequest](r)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
@ -60,7 +62,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
// if the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil {
return err
return errors.Wrap(err, "validating state")
}
return server.APIError{Code: server.ErrInvalidState}
@ -78,7 +80,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
dg, _ := discordgo.New(token.Type() + " " + token.AccessToken)
du, err := dg.User("@me")
if err != nil {
return err
return errors.Wrap(err, "getting discord user")
}
u, err := s.DB.DiscordUser(ctx, du.ID)
@ -89,7 +91,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil {
log.Errorf("saving undelete token: %v", err)
return err
return errors.Wrap(err, "saving undelete token")
}
render.JSON(w, r, discordCallbackResponse{
@ -113,11 +115,11 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil {
return err
return errors.Wrap(err, "creating token")
}
// save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID)
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
if err != nil {
return errors.Wrap(err, "saving token to database")
}
@ -136,7 +138,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
return nil
} else if err != db.ErrUserNotFound { // internal error
return err
return errors.Wrap(err, "getting user")
}
// no user found, so save a ticket + save their Discord info in Redis
@ -144,14 +146,15 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetJSON(ctx, "discord:"+ticket, du, "EX", "600")
if err != nil {
log.Errorf("setting Discord user for ticket %q: %v", ticket, err)
return err
return errors.Wrap(err, "caching discord user for ticket")
}
render.JSON(w, r, discordCallbackResponse{
HasAccount: false,
Discord: du.String(),
Ticket: ticket,
RequireInvite: s.RequireInvite,
HasAccount: false,
Discord: du.String(),
Ticket: ticket,
RequireInvite: s.RequireInvite,
RequireCaptcha: s.hcaptchaSecret != "",
})
return nil
@ -167,8 +170,8 @@ func (s *Server) discordLink(w http.ResponseWriter, r *http.Request) error {
claims, _ := server.ClaimsFromContext(ctx)
// only site tokens can be used for this endpoint
if claims.APIToken || !claims.TokenWrite {
return server.APIError{Code: server.ErrInvalidToken}
if claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
}
req, err := Decode[linkRequest](r)
@ -193,6 +196,11 @@ func (s *Server) discordLink(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket}
}
if du.ID == "" {
log.Errorf("linking user with id %v: discord user ID was empty", claims.UserID)
return server.APIError{Code: server.ErrInternalServerError, Details: "Discord user ID is empty"}
}
err = u.UpdateFromDiscord(ctx, s.DB, du)
if err != nil {
return errors.Wrap(err, "updating user from discord")
@ -213,8 +221,8 @@ func (s *Server) discordUnlink(w http.ResponseWriter, r *http.Request) error {
claims, _ := server.ClaimsFromContext(ctx)
// only site tokens can be used for this endpoint
if claims.APIToken || !claims.TokenWrite {
return server.APIError{Code: server.ErrInvalidToken}
if claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
}
u, err := s.DB.User(ctx, claims.UserID)
@ -245,10 +253,11 @@ func (s *Server) discordUnlink(w http.ResponseWriter, r *http.Request) error {
return nil
}
type discordSignupRequest struct {
Ticket string `json:"ticket"`
Username string `json:"username"`
InviteCode string `json:"invite_code"`
type signupRequest struct {
Ticket string `json:"ticket"`
Username string `json:"username"`
InviteCode string `json:"invite_code"`
CaptchaResponse string `json:"captcha_response"`
}
type signupResponse struct {
@ -259,7 +268,7 @@ type signupResponse struct {
func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
req, err := Decode[discordSignupRequest](r)
req, err := Decode[signupRequest](r)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
@ -270,7 +279,7 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil {
return err
return errors.Wrap(err, "checking if username is taken")
}
if !valid {
return server.APIError{Code: server.ErrInvalidUsername}
@ -283,7 +292,12 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
du := new(discordgo.User)
err = s.DB.GetJSON(ctx, "discord:"+req.Ticket, &du)
@ -293,6 +307,19 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket}
}
// check captcha
if s.hcaptchaSecret != "" {
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
if err != nil {
log.Errorf("verifying captcha: %v", err)
return server.APIError{Code: server.ErrInternalServerError}
}
if !ok {
return server.APIError{Code: server.ErrInvalidCaptcha}
}
}
u, err := s.DB.CreateUser(ctx, tx, req.Username)
if err != nil {
if errors.Cause(err) == db.ErrUsernameTaken {
@ -302,6 +329,11 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "creating user")
}
if du.ID == "" {
log.Errorf("creating user with name %q: user ID was empty", req.Username)
return server.APIError{Code: server.ErrInternalServerError, Details: "Discord user ID is empty"}
}
err = u.UpdateFromDiscord(ctx, tx, du)
if err != nil {
return errors.Wrap(err, "updating user from discord")
@ -343,7 +375,7 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
}
// save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID)
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
if err != nil {
return errors.Wrap(err, "saving token to database")
}

View file

@ -6,11 +6,12 @@ import (
"net/http"
"time"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4"
"github.com/rs/xid"
)
@ -27,9 +28,10 @@ type fediCallbackResponse struct {
Token string `json:"token,omitempty"`
User *userResponse `json:"user,omitempty"`
Fediverse string `json:"fediverse,omitempty"` // username, for UI purposes
Ticket string `json:"ticket,omitempty"`
RequireInvite bool `json:"require_invite"` // require an invite for signing up
Fediverse string `json:"fediverse,omitempty"` // username, for UI purposes
Ticket string `json:"ticket,omitempty"`
RequireInvite bool `json:"require_invite"` // require an invite for signing up
RequireCaptcha bool `json:"require_captcha"`
IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
@ -53,7 +55,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
// if the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil {
return err
return errors.Wrap(err, "validating state")
}
return server.APIError{Code: server.ErrInvalidState}
@ -110,7 +112,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil {
log.Errorf("saving undelete token: %v", err)
return err
return errors.Wrap(err, "saving undelete token")
}
render.JSON(w, r, fediCallbackResponse{
@ -134,11 +136,11 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil {
return err
return errors.Wrap(err, "creating token")
}
// save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID)
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
if err != nil {
return errors.Wrap(err, "saving token to database")
}
@ -157,7 +159,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
return nil
} else if err != db.ErrUserNotFound { // internal error
return err
return errors.Wrap(err, "getting user")
}
// no user found, so save a ticket + save their Mastodon info in Redis
@ -165,14 +167,15 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
err = s.DB.SetJSON(ctx, "mastodon:"+ticket, mu, "EX", "600")
if err != nil {
log.Errorf("setting mastoAPI user for ticket %q: %v", ticket, err)
return err
return errors.Wrap(err, "setting user for ticket")
}
render.JSON(w, r, fediCallbackResponse{
HasAccount: false,
Fediverse: mu.Username,
Ticket: ticket,
RequireInvite: s.RequireInvite,
HasAccount: false,
Fediverse: mu.Username,
Ticket: ticket,
RequireInvite: s.RequireInvite,
RequireCaptcha: s.hcaptchaSecret != "",
})
return nil
@ -189,8 +192,8 @@ func (s *Server) mastodonLink(w http.ResponseWriter, r *http.Request) error {
claims, _ := server.ClaimsFromContext(ctx)
// only site tokens can be used for this endpoint
if claims.APIToken || !claims.TokenWrite {
return server.APIError{Code: server.ErrInvalidToken}
if claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
}
req, err := Decode[fediLinkRequest](r)
@ -220,6 +223,11 @@ func (s *Server) mastodonLink(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket}
}
if mu.ID == "" {
log.Errorf("linking user with id %v: user ID was empty", claims.UserID)
return server.APIError{Code: server.ErrInternalServerError, Details: "Mastodon user ID is empty"}
}
err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID)
if err != nil {
return errors.Wrap(err, "updating user from mastoAPI")
@ -240,8 +248,8 @@ func (s *Server) mastodonUnlink(w http.ResponseWriter, r *http.Request) error {
claims, _ := server.ClaimsFromContext(ctx)
// only site tokens can be used for this endpoint
if claims.APIToken || !claims.TokenWrite {
return server.APIError{Code: server.ErrInvalidToken}
if claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
}
u, err := s.DB.User(ctx, claims.UserID)
@ -273,10 +281,11 @@ func (s *Server) mastodonUnlink(w http.ResponseWriter, r *http.Request) error {
}
type fediSignupRequest struct {
Instance string `json:"instance"`
Ticket string `json:"ticket"`
Username string `json:"username"`
InviteCode string `json:"invite_code"`
Instance string `json:"instance"`
Ticket string `json:"ticket"`
Username string `json:"username"`
InviteCode string `json:"invite_code"`
CaptchaResponse string `json:"captcha_response"`
}
func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
@ -298,7 +307,7 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil {
return err
return errors.Wrap(err, "checking if username is taken")
}
if !valid {
return server.APIError{Code: server.ErrInvalidUsername}
@ -311,7 +320,12 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
mu := new(partialMastodonAccount)
err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu)
@ -321,6 +335,19 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket}
}
// check captcha
if s.hcaptchaSecret != "" {
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
if err != nil {
log.Errorf("verifying captcha: %v", err)
return server.APIError{Code: server.ErrInternalServerError}
}
if !ok {
return server.APIError{Code: server.ErrInvalidCaptcha}
}
}
u, err := s.DB.CreateUser(ctx, tx, req.Username)
if err != nil {
if errors.Cause(err) == db.ErrUsernameTaken {
@ -330,6 +357,11 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "creating user")
}
if mu.ID == "" {
log.Errorf("creating user with name %q: user ID was empty", req.Username)
return server.APIError{Code: server.ErrInternalServerError, Details: "Mastodon user ID is empty"}
}
err = u.UpdateFromFedi(ctx, tx, mu.ID, mu.Username, app.ID)
if err != nil {
return errors.Wrap(err, "updating user from mastoAPI")
@ -371,7 +403,7 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
}
// save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID)
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
if err != nil {
return errors.Wrap(err, "saving token to database")
}

View file

@ -0,0 +1,462 @@
package auth
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4"
"github.com/rs/xid"
)
type partialMisskeyAccount struct {
ID string `json:"id"`
Username string `json:"username"`
}
func (s *Server) misskeyCallback(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
decoded, err := Decode[fediOauthCallbackRequest](r)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
app, err := s.DB.FediverseApp(ctx, decoded.Instance)
if err != nil {
log.Errorf("getting app for instance %q: %v", decoded.Instance, err)
if err == db.ErrNoInstanceApp {
// can we get here?
return server.APIError{Code: server.ErrNotFound}
}
}
userkeyReq := struct {
AppSecret string `json:"appSecret"`
Token string `json:"token"`
}{AppSecret: app.ClientSecret, Token: decoded.Code}
b, err := json.Marshal(userkeyReq)
if err != nil {
return errors.Wrap(err, "marshaling json")
}
// make me user request
req, err := http.NewRequestWithContext(ctx, "POST", "https://"+decoded.Instance+"/api/auth/session/userkey", bytes.NewReader(b))
if err != nil {
return errors.Wrap(err, "creating userkey request")
}
req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag)
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return errors.Wrap(err, "sending i request")
}
defer resp.Body.Close()
jb, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Wrap(err, "reading i response")
}
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
log.Errorf("POST userkey for instance %q (type %v): %v", app.Instance, app.InstanceType, string(jb))
return errors.Wrap(err, "error on misskey's end")
}
var mu struct {
User partialMisskeyAccount `json:"user"`
}
err = json.Unmarshal(jb, &mu)
if err != nil {
return errors.Wrap(err, "unmarshaling userkey response")
}
u, err := s.DB.FediverseUser(ctx, mu.User.ID, app.ID)
if err == nil {
if u.DeletedAt != nil {
// store cancel delete token
token := undeleteToken()
err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil {
log.Errorf("saving undelete token: %v", err)
return errors.Wrap(err, "saving undelete token")
}
render.JSON(w, r, fediCallbackResponse{
HasAccount: true,
Token: token,
User: dbUserToUserResponse(u, []db.Field{}),
IsDeleted: true,
DeletedAt: u.DeletedAt,
SelfDelete: u.SelfDelete,
DeleteReason: u.DeleteReason,
})
return nil
}
err = u.UpdateFromFedi(ctx, s.DB, mu.User.ID, mu.User.Username, app.ID)
if err != nil {
log.Errorf("updating user %v with misskey info: %v", u.ID, err)
}
// TODO: implement user + token permissions
tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil {
return errors.Wrap(err, "creating token")
}
// save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
if err != nil {
return errors.Wrap(err, "saving token to database")
}
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
return errors.Wrap(err, "querying fields")
}
render.JSON(w, r, fediCallbackResponse{
HasAccount: true,
Token: token,
User: dbUserToUserResponse(u, fields),
})
return nil
} else if err != db.ErrUserNotFound { // internal error
return errors.Wrap(err, "getting user")
}
// no user found, so save a ticket + save their Misskey info in Redis
ticket := RandBase64(32)
err = s.DB.SetJSON(ctx, "misskey:"+ticket, mu.User, "EX", "600")
if err != nil {
log.Errorf("setting misskey user for ticket %q: %v", ticket, err)
return errors.Wrap(err, "setting user for ticket")
}
render.JSON(w, r, fediCallbackResponse{
HasAccount: false,
Fediverse: mu.User.Username,
Ticket: ticket,
RequireInvite: s.RequireInvite,
RequireCaptcha: s.hcaptchaSecret != "",
})
return nil
}
func (s *Server) misskeyLink(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
// only site tokens can be used for this endpoint
if claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
}
req, err := Decode[fediLinkRequest](r)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
app, err := s.DB.FediverseApp(ctx, req.Instance)
if err != nil {
return errors.Wrap(err, "getting instance application")
}
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting user")
}
if u.Fediverse != nil {
return server.APIError{Code: server.ErrAlreadyLinked}
}
mu := new(partialMisskeyAccount)
err = s.DB.GetJSON(ctx, "misskey:"+req.Ticket, &mu)
if err != nil {
log.Errorf("getting misskey user for ticket: %v", err)
return server.APIError{Code: server.ErrInvalidTicket}
}
if mu.ID == "" {
log.Errorf("linking user with id %v: user ID was empty", claims.UserID)
return server.APIError{Code: server.ErrInternalServerError, Details: "Misskey user ID is empty"}
}
err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID)
if err != nil {
return errors.Wrap(err, "updating user from misskey")
}
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
return errors.Wrap(err, "getting user fields")
}
render.JSON(w, r, dbUserToUserResponse(u, fields))
return nil
}
func (s *Server) misskeySignup(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
req, err := Decode[fediSignupRequest](r)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
if s.RequireInvite && req.InviteCode == "" {
return server.APIError{Code: server.ErrInviteRequired}
}
app, err := s.DB.FediverseApp(ctx, req.Instance)
if err != nil {
return errors.Wrap(err, "getting instance application")
}
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil {
return errors.Wrap(err, "checking if username is taken")
}
if !valid {
return server.APIError{Code: server.ErrInvalidUsername}
}
if taken {
return server.APIError{Code: server.ErrUsernameTaken}
}
tx, err := s.DB.Begin(ctx)
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
mu := new(partialMisskeyAccount)
err = s.DB.GetJSON(ctx, "misskey:"+req.Ticket, &mu)
if err != nil {
log.Errorf("getting misskey user for ticket: %v", err)
return server.APIError{Code: server.ErrInvalidTicket}
}
// check captcha
if s.hcaptchaSecret != "" {
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
if err != nil {
log.Errorf("verifying captcha: %v", err)
return server.APIError{Code: server.ErrInternalServerError}
}
if !ok {
return server.APIError{Code: server.ErrInvalidCaptcha}
}
}
u, err := s.DB.CreateUser(ctx, tx, req.Username)
if err != nil {
if errors.Cause(err) == db.ErrUsernameTaken {
return server.APIError{Code: server.ErrUsernameTaken}
}
return errors.Wrap(err, "creating user")
}
if mu.ID == "" {
log.Errorf("creating user with name %q: user ID was empty", req.Username)
return server.APIError{Code: server.ErrInternalServerError, Details: "Misskey user ID is empty"}
}
err = u.UpdateFromFedi(ctx, tx, mu.ID, mu.Username, app.ID)
if err != nil {
return errors.Wrap(err, "updating user from misskey")
}
if s.RequireInvite {
valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode)
if err != nil {
return errors.Wrap(err, "checking and invalidating invite")
}
if !valid {
return server.APIError{Code: server.ErrInviteRequired}
}
if used {
return server.APIError{Code: server.ErrInviteAlreadyUsed}
}
}
// delete sign up ticket
err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "misskey:"+req.Ticket))
if err != nil {
return errors.Wrap(err, "deleting signup ticket")
}
// commit transaction
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
// create token
// TODO: implement user + token permissions
tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true)
if err != nil {
return errors.Wrap(err, "creating token")
}
// save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
if err != nil {
return errors.Wrap(err, "saving token to database")
}
// return user
render.JSON(w, r, signupResponse{
User: *dbUserToUserResponse(u, nil),
Token: token,
})
return nil
}
func (s *Server) noAppMisskeyURL(ctx context.Context, w http.ResponseWriter, r *http.Request, softwareName, instance string) error {
log.Debugf("creating application on misskey-compatible instance %q", instance)
b, err := json.Marshal(misskeyAppRequest{
Name: "pronouns.cc (+" + s.BaseURL + ")",
Description: "pronouns.cc on " + s.BaseURL,
CallbackURL: s.BaseURL + "/auth/login/misskey/" + instance,
Permission: []string{"read:account"},
})
if err != nil {
log.Errorf("marshaling app json: %v", err)
return errors.Wrap(err, "marshaling json")
}
req, err := http.NewRequestWithContext(ctx, "POST", "https://"+instance+"/api/app/create", bytes.NewReader(b))
if err != nil {
log.Errorf("creating POST apps request for %q: %v", instance, err)
return errors.Wrap(err, "creating POST apps request")
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Errorf("sending POST apps request for %q: %v", instance, err)
return errors.Wrap(err, "sending POST apps request")
}
defer resp.Body.Close()
jb, err := io.ReadAll(resp.Body)
if err != nil {
log.Errorf("reading response for request: %v", err)
return errors.Wrap(err, "reading response")
}
var ma misskeyApp
err = json.Unmarshal(jb, &ma)
if err != nil {
return errors.Wrap(err, "unmarshaling misskey app")
}
app, err := s.DB.CreateFediverseApp(ctx, instance, softwareName, ma.ID, ma.Secret)
if err != nil {
log.Errorf("saving app for %q: %v", instance, err)
return errors.Wrap(err, "creating app")
}
_, url, err := s.misskeyURL(ctx, app)
if err != nil {
log.Errorf("generating URL for misskey %q: %v", instance, err)
return errors.Wrap(err, "generating URL")
}
render.JSON(w, r, FediResponse{
URL: url,
})
return nil
}
type misskeyAppRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Permission []string `json:"permission"`
CallbackURL string `json:"callbackUrl"`
}
type misskeyApp struct {
ID string `json:"id"`
Secret string `json:"secret"`
}
func (s *Server) misskeyURL(ctx context.Context, app db.FediverseApp) (token, url string, err error) {
genSession := struct {
AppSecret string `json:"appSecret"`
}{AppSecret: app.ClientSecret}
b, err := json.Marshal(genSession)
if err != nil {
return token, url, errors.Wrap(err, "marshaling json")
}
req, err := http.NewRequestWithContext(ctx, "POST", "https://"+app.Instance+"/api/auth/session/generate", bytes.NewReader(b))
if err != nil {
log.Errorf("creating POST session request for %q: %v", app.Instance, err)
return token, url, errors.Wrap(err, "creating POST apps request")
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "pronouns.cc/"+server.Tag)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Errorf("sending POST session request for %q: %v", app.Instance, err)
return token, url, errors.Wrap(err, "sending POST apps request")
}
defer resp.Body.Close()
jb, err := io.ReadAll(resp.Body)
if err != nil {
log.Errorf("reading response for request: %v", err)
return token, url, errors.Wrap(err, "reading response")
}
var genSessionResp struct {
Token string `json:"token"`
URL string `json:"url"`
}
err = json.Unmarshal(jb, &genSessionResp)
if err != nil {
return token, url, errors.Wrap(err, "unmarshaling misskey response")
}
return genSessionResp.Token, genSessionResp.URL, nil
}

View file

@ -6,7 +6,7 @@ import (
"io"
"net/http"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
)

View file

@ -8,8 +8,8 @@ import (
"net/url"
"strings"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
)
@ -25,11 +25,28 @@ func (s *Server) getFediverseURL(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrBadRequest, Details: "Instance URL is empty"}
}
// Too many people tried using @username@fediverse.example despite the warning
if strings.Contains(instance, "@") {
return server.APIError{Code: server.ErrBadRequest, Details: "Instance URL should only be the base URL, without username"}
}
app, err := s.DB.FediverseApp(ctx, instance)
if err != nil {
return s.noAppFediverseURL(ctx, w, r, instance)
}
if app.Misskey() {
_, url, err := s.misskeyURL(ctx, app)
if err != nil {
return errors.Wrap(err, "generating misskey URL")
}
render.JSON(w, r, FediResponse{
URL: url,
})
return nil
}
state, err := s.setCSRFState(r.Context())
if err != nil {
return errors.Wrap(err, "setting CSRF state")
@ -48,9 +65,15 @@ func (s *Server) noAppFediverseURL(ctx context.Context, w http.ResponseWriter, r
}
switch softwareName {
case "mastodon", "pleroma", "akkoma", "pixelfed", "calckey":
case "iceshrimp":
softwareName = "firefish"
fallthrough
case "misskey", "foundkey", "calckey", "firefish", "sharkey":
return s.noAppMisskeyURL(ctx, w, r, softwareName, instance)
case "mastodon", "pleroma", "akkoma", "incestoma", "pixelfed", "gotosocial":
case "glitchcafe", "hometown":
softwareName = "mastodon"
default:
// sorry, misskey :( TODO: support misskey
return server.APIError{Code: server.ErrUnsupportedInstance}
}

View file

@ -0,0 +1,392 @@
package auth
import (
"net/http"
"os"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4"
"github.com/rs/xid"
"golang.org/x/oauth2"
"google.golang.org/api/idtoken"
)
var googleOAuthConfig = oauth2.Config{
ClientID: os.Getenv("GOOGLE_CLIENT_ID"),
ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
Endpoint: oauth2.Endpoint{
AuthURL: "https://accounts.google.com/o/oauth2/auth",
TokenURL: "https://oauth2.googleapis.com/token",
AuthStyle: oauth2.AuthStyleInParams,
},
Scopes: []string{"openid", "https://www.googleapis.com/auth/userinfo.email"},
}
type googleCallbackResponse struct {
HasAccount bool `json:"has_account"` // if true, Token and User will be set. if false, Ticket and Google will be set
Token string `json:"token,omitempty"`
User *userResponse `json:"user,omitempty"`
Google string `json:"google,omitempty"` // username, for UI purposes
Ticket string `json:"ticket,omitempty"`
RequireInvite bool `json:"require_invite"` // require an invite for signing up
RequireCaptcha bool `json:"require_captcha"`
IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
SelfDelete *bool `json:"self_delete,omitempty"`
DeleteReason *string `json:"delete_reason,omitempty"`
}
type partialGoogleUser struct {
ID string `json:"id"`
Email string `json:"email"`
}
func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
decoded, err := Decode[oauthCallbackRequest](r)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
// if the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil {
return errors.Wrap(err, "validating state")
}
return server.APIError{Code: server.ErrInvalidState}
}
cfg := googleOAuthConfig
cfg.RedirectURL = decoded.CallbackDomain + "/auth/login/google"
token, err := cfg.Exchange(r.Context(), decoded.Code)
if err != nil {
log.Errorf("exchanging oauth code: %v", err)
return server.APIError{Code: server.ErrInvalidOAuthCode}
}
rawToken := token.Extra("id_token")
if rawToken == nil {
log.Debug("id_token was nil")
return server.APIError{Code: server.ErrInternalServerError}
}
idToken, ok := rawToken.(string)
if !ok {
log.Debug("id_token was not a string")
return server.APIError{Code: server.ErrInternalServerError}
}
payload, err := idtoken.Validate(ctx, idToken, "")
if err != nil {
log.Errorf("getting id token payload: %v", err)
return server.APIError{Code: server.ErrInternalServerError}
}
googleID, ok := payload.Claims["sub"].(string)
if !ok {
log.Debug("id_token.claims.sub was not a string")
return server.APIError{Code: server.ErrInternalServerError}
}
googleUsername, ok := payload.Claims["email"].(string)
if !ok {
log.Debug("id_token.claims.email was not a string")
return server.APIError{Code: server.ErrInternalServerError}
}
u, err := s.DB.GoogleUser(ctx, googleID)
if err == nil {
if u.DeletedAt != nil {
// store cancel delete token
token := undeleteToken()
err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil {
log.Errorf("saving undelete token: %v", err)
return errors.Wrap(err, "saving undelete token")
}
render.JSON(w, r, googleCallbackResponse{
HasAccount: true,
Token: token,
User: dbUserToUserResponse(u, []db.Field{}),
IsDeleted: true,
DeletedAt: u.DeletedAt,
SelfDelete: u.SelfDelete,
DeleteReason: u.DeleteReason,
})
return nil
}
err = u.UpdateFromGoogle(ctx, s.DB, googleID, googleUsername)
if err != nil {
log.Errorf("updating user %v with Google info: %v", u.ID, err)
}
// TODO: implement user + token permissions
tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil {
return errors.Wrap(err, "creating token")
}
// save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
if err != nil {
return errors.Wrap(err, "saving token to database")
}
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
return errors.Wrap(err, "querying fields")
}
render.JSON(w, r, googleCallbackResponse{
HasAccount: true,
Token: token,
User: dbUserToUserResponse(u, fields),
})
return nil
} else if err != db.ErrUserNotFound { // internal error
return errors.Wrap(err, "getting user")
}
// no user found, so save a ticket + save their Google info in Redis
ticket := RandBase64(32)
err = s.DB.SetJSON(ctx, "google:"+ticket, partialGoogleUser{ID: googleID, Email: googleUsername}, "EX", "600")
if err != nil {
log.Errorf("setting Google user for ticket %q: %v", ticket, err)
return errors.Wrap(err, "setting user for ticket")
}
render.JSON(w, r, googleCallbackResponse{
HasAccount: false,
Google: googleUsername,
Ticket: ticket,
RequireInvite: s.RequireInvite,
RequireCaptcha: s.hcaptchaSecret != "",
})
return nil
}
func (s *Server) googleLink(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
// only site tokens can be used for this endpoint
if claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
}
req, err := Decode[linkRequest](r)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting user")
}
if u.Google != nil {
return server.APIError{Code: server.ErrAlreadyLinked}
}
gu := new(partialGoogleUser)
err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu)
if err != nil {
log.Errorf("getting google user for ticket: %v", err)
return server.APIError{Code: server.ErrInvalidTicket}
}
if gu.ID == "" {
log.Errorf("linking user with id %v: user ID was empty", claims.UserID)
return server.APIError{Code: server.ErrInternalServerError, Details: "Google user ID is empty"}
}
err = u.UpdateFromGoogle(ctx, s.DB, gu.ID, gu.Email)
if err != nil {
return errors.Wrap(err, "updating user from google")
}
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
return errors.Wrap(err, "getting user fields")
}
render.JSON(w, r, dbUserToUserResponse(u, fields))
return nil
}
func (s *Server) googleUnlink(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
// only site tokens can be used for this endpoint
if claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
}
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting user")
}
if u.Google == nil {
return server.APIError{Code: server.ErrNotLinked}
}
// cannot unlink last auth provider
if u.NumProviders() <= 1 {
return server.APIError{Code: server.ErrLastProvider}
}
err = u.UnlinkGoogle(ctx, s.DB)
if err != nil {
return errors.Wrap(err, "updating user in db")
}
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
return errors.Wrap(err, "getting user fields")
}
render.JSON(w, r, dbUserToUserResponse(u, fields))
return nil
}
func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
req, err := Decode[signupRequest](r)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
if s.RequireInvite && req.InviteCode == "" {
return server.APIError{Code: server.ErrInviteRequired}
}
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil {
return errors.Wrap(err, "checking if username is taken")
}
if !valid {
return server.APIError{Code: server.ErrInvalidUsername}
}
if taken {
return server.APIError{Code: server.ErrUsernameTaken}
}
tx, err := s.DB.Begin(ctx)
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
gu := new(partialGoogleUser)
err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu)
if err != nil {
log.Errorf("getting google user for ticket: %v", err)
return server.APIError{Code: server.ErrInvalidTicket}
}
// check captcha
if s.hcaptchaSecret != "" {
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
if err != nil {
log.Errorf("verifying captcha: %v", err)
return server.APIError{Code: server.ErrInternalServerError}
}
if !ok {
return server.APIError{Code: server.ErrInvalidCaptcha}
}
}
u, err := s.DB.CreateUser(ctx, tx, req.Username)
if err != nil {
if errors.Cause(err) == db.ErrUsernameTaken {
return server.APIError{Code: server.ErrUsernameTaken}
}
return errors.Wrap(err, "creating user")
}
if gu.ID == "" {
log.Errorf("creating user with name %q: user ID was empty", req.Username)
return server.APIError{Code: server.ErrInternalServerError, Details: "Google user ID is empty"}
}
err = u.UpdateFromGoogle(ctx, tx, gu.ID, gu.Email)
if err != nil {
return errors.Wrap(err, "updating user from google")
}
if s.RequireInvite {
valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode)
if err != nil {
return errors.Wrap(err, "checking and invalidating invite")
}
if !valid {
return server.APIError{Code: server.ErrInviteRequired}
}
if used {
return server.APIError{Code: server.ErrInviteAlreadyUsed}
}
}
// delete sign up ticket
err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "google:"+req.Ticket))
if err != nil {
return errors.Wrap(err, "deleting signup ticket")
}
// commit transaction
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
// create token
// TODO: implement user + token permissions
tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true)
if err != nil {
return errors.Wrap(err, "creating token")
}
// save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
if err != nil {
return errors.Wrap(err, "saving token to database")
}
// return user
render.JSON(w, r, signupResponse{
User: *dbUserToUserResponse(u, nil),
Token: token,
})
return nil
}

View file

@ -4,8 +4,8 @@ import (
"net/http"
"time"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
)
@ -32,6 +32,10 @@ func (s *Server) getInvites(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
}
is, err := s.DB.UserInvites(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting user invites")
@ -54,6 +58,10 @@ func (s *Server) createInvite(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
}
inv, err := s.DB.CreateInvite(ctx, claims.UserID)
if err != nil {
if err == db.ErrTooManyInvites {

View file

@ -4,9 +4,10 @@ import (
"net/http"
"os"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
@ -18,10 +19,14 @@ type Server struct {
RequireInvite bool
BaseURL string
hcaptchaSitekey string
hcaptchaSecret string
}
type userResponse struct {
ID xid.ID `json:"id"`
SnowflakeID common.UserID `json:"id_new"`
Username string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
@ -34,6 +39,12 @@ type userResponse struct {
Discord *string `json:"discord"`
DiscordUsername *string `json:"discord_username"`
Tumblr *string `json:"tumblr"`
TumblrUsername *string `json:"tumblr_username"`
Google *string `json:"google"`
GoogleUsername *string `json:"google_username"`
Fediverse *string `json:"fediverse"`
FediverseUsername *string `json:"fediverse_username"`
FediverseInstance *string `json:"fediverse_instance"`
@ -42,6 +53,7 @@ type userResponse struct {
func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse {
return &userResponse{
ID: u.ID,
SnowflakeID: u.SnowflakeID,
Username: u.Username,
DisplayName: u.DisplayName,
Bio: u.Bio,
@ -52,6 +64,10 @@ func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse {
Fields: db.NotNull(fields),
Discord: u.Discord,
DiscordUsername: u.DiscordUsername,
Tumblr: u.Tumblr,
TumblrUsername: u.TumblrUsername,
Google: u.Google,
GoogleUsername: u.GoogleUsername,
Fediverse: u.Fediverse,
FediverseUsername: u.FediverseUsername,
FediverseInstance: u.FediverseInstance,
@ -60,9 +76,11 @@ func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse {
func Mount(srv *server.Server, r chi.Router) {
s := &Server{
Server: srv,
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
BaseURL: os.Getenv("BASE_URL"),
Server: srv,
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
BaseURL: os.Getenv("BASE_URL"),
hcaptchaSitekey: os.Getenv("HCAPTCHA_SITEKEY"),
hcaptchaSecret: os.Getenv("HCAPTCHA_SECRET"),
}
r.Route("/auth", func(r chi.Router) {
@ -84,6 +102,20 @@ func Mount(srv *server.Server, r chi.Router) {
r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.discordUnlink))
})
r.Route("/tumblr", func(r chi.Router) {
r.Post("/callback", server.WrapHandler(s.tumblrCallback))
r.Post("/signup", server.WrapHandler(s.tumblrSignup))
r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.tumblrLink))
r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.tumblrUnlink))
})
r.Route("/google", func(r chi.Router) {
r.Post("/callback", server.WrapHandler(s.googleCallback))
r.Post("/signup", server.WrapHandler(s.googleSignup))
r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.googleLink))
r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.googleUnlink))
})
r.Route("/mastodon", func(r chi.Router) {
r.Post("/callback", server.WrapHandler(s.mastodonCallback))
r.Post("/signup", server.WrapHandler(s.mastodonSignup))
@ -91,6 +123,12 @@ func Mount(srv *server.Server, r chi.Router) {
r.With(server.MustAuth).Post("/remove-provider", server.WrapHandler(s.mastodonUnlink))
})
r.Route("/misskey", func(r chi.Router) {
r.Post("/callback", server.WrapHandler(s.misskeyCallback))
r.Post("/signup", server.WrapHandler(s.misskeySignup))
r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.misskeyLink))
})
// invite routes
r.With(server.MustAuth).Get("/invites", server.WrapHandler(s.getInvites))
r.With(server.MustAuth).Post("/invites", server.WrapHandler(s.createInvite))
@ -98,7 +136,7 @@ func Mount(srv *server.Server, r chi.Router) {
// tokens
r.With(server.MustAuth).Get("/tokens", server.WrapHandler(s.getTokens))
r.With(server.MustAuth).Post("/tokens", server.WrapHandler(s.createToken))
r.With(server.MustAuth).Delete("/tokens/{id}", server.WrapHandler(s.deleteToken))
r.With(server.MustAuth).Delete("/tokens", server.WrapHandler(s.deleteToken))
// cancel user delete
// uses a special token, so handled in the function itself
@ -114,7 +152,9 @@ type oauthURLsRequest struct {
}
type oauthURLsResponse struct {
Discord string `json:"discord"`
Discord string `json:"discord,omitempty"`
Tumblr string `json:"tumblr,omitempty"`
Google string `json:"google,omitempty"`
}
func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error {
@ -130,14 +170,25 @@ func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error {
if err != nil {
return errors.Wrap(err, "setting CSRF state")
}
var resp oauthURLsResponse
// copy Discord config and set redirect url
discordCfg := discordOAuthConfig
discordCfg.RedirectURL = req.CallbackDomain + "/auth/login/discord"
if discordOAuthConfig.ClientID != "" {
discordCfg := discordOAuthConfig
discordCfg.RedirectURL = req.CallbackDomain + "/auth/login/discord"
resp.Discord = discordCfg.AuthCodeURL(state) + "&prompt=none"
}
if tumblrOAuthConfig.ClientID != "" {
tumblrCfg := tumblrOAuthConfig
tumblrCfg.RedirectURL = req.CallbackDomain + "/auth/login/tumblr"
resp.Tumblr = tumblrCfg.AuthCodeURL(state)
}
if googleOAuthConfig.ClientID != "" {
googleCfg := googleOAuthConfig
googleCfg.RedirectURL = req.CallbackDomain + "/auth/login/google"
resp.Google = googleCfg.AuthCodeURL(state) + "&prompt=select_account"
}
render.JSON(w, r, oauthURLsResponse{
Discord: discordCfg.AuthCodeURL(state) + "&prompt=none",
})
render.JSON(w, r, resp)
return nil
}

View file

@ -0,0 +1,132 @@
package auth
import (
"net/http"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/rs/xid"
)
type getTokenResponse struct {
TokenID xid.ID `json:"id"`
APIOnly bool `json:"api_only"`
ReadOnly bool `json:"read_only"`
Created time.Time `json:"created"`
Expires time.Time `json:"expires"`
}
func dbTokenToGetResponse(t db.Token) getTokenResponse {
return getTokenResponse{
TokenID: t.TokenID,
APIOnly: t.APIOnly,
ReadOnly: t.ReadOnly,
Created: t.Created,
Expires: t.Expires,
}
}
func (s *Server) getTokens(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
}
tokens, err := s.DB.Tokens(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting tokens")
}
resps := make([]getTokenResponse, len(tokens))
for i := range tokens {
resps[i] = dbTokenToGetResponse(tokens[i])
}
render.JSON(w, r, resps)
return nil
}
func (s *Server) deleteToken(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
}
tx, err := s.DB.Begin(ctx)
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
err = s.DB.InvalidateAllTokens(ctx, tx, claims.UserID)
if err != nil {
return errors.Wrap(err, "invalidating tokens")
}
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
render.NoContent(w, r)
return nil
}
type createTokenResponse struct {
Token string `json:"token"`
TokenID xid.ID `json:"id"`
APIOnly bool `json:"api_only"`
ReadOnly bool `json:"read_only"`
Created time.Time `json:"created"`
Expires time.Time `json:"expires"`
}
func (s *Server) createToken(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
}
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting me user")
}
readOnly := r.FormValue("read_only") == "true"
tokenID := xid.New()
tokenStr, err := s.Auth.CreateToken(claims.UserID, tokenID, u.IsAdmin, true, !readOnly)
if err != nil {
return errors.Wrap(err, "creating token")
}
t, err := s.DB.SaveToken(ctx, claims.UserID, tokenID, true, readOnly)
if err != nil {
return errors.Wrap(err, "saving token")
}
render.JSON(w, r, createTokenResponse{
Token: tokenStr,
TokenID: t.TokenID,
APIOnly: t.APIOnly,
ReadOnly: t.ReadOnly,
Created: t.Created,
Expires: t.Expires,
})
return nil
}

View file

@ -0,0 +1,425 @@
package auth
import (
"encoding/json"
"io"
"net/http"
"os"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4"
"github.com/rs/xid"
"golang.org/x/oauth2"
)
var tumblrOAuthConfig = oauth2.Config{
ClientID: os.Getenv("TUMBLR_CLIENT_ID"),
ClientSecret: os.Getenv("TUMBLR_CLIENT_SECRET"),
Endpoint: oauth2.Endpoint{
AuthURL: "https://www.tumblr.com/oauth2/authorize",
TokenURL: "https://api.tumblr.com/v2/oauth2/token",
AuthStyle: oauth2.AuthStyleInParams,
},
Scopes: []string{"basic"},
}
type partialTumblrResponse struct {
Meta struct {
Status int `json:"status"`
Message string `json:"msg"`
} `json:"meta"`
Response struct {
User struct {
Blogs []struct {
Name string `json:"name"`
Primary bool `json:"primary"`
UUID string `json:"uuid"`
} `json:"blogs"`
} `json:"user"`
} `json:"response"`
}
type tumblrUserInfo struct {
Name string `json:"name"`
ID string `json:"id"`
}
type tumblrCallbackResponse struct {
HasAccount bool `json:"has_account"` // if true, Token and User will be set. if false, Ticket and Tumblr will be set
Token string `json:"token,omitempty"`
User *userResponse `json:"user,omitempty"`
Tumblr string `json:"tumblr,omitempty"` // username, for UI purposes
Ticket string `json:"ticket,omitempty"`
RequireInvite bool `json:"require_invite"` // require an invite for signing up
RequireCaptcha bool `json:"require_captcha"`
IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
SelfDelete *bool `json:"self_delete,omitempty"`
DeleteReason *string `json:"delete_reason,omitempty"`
}
func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
decoded, err := Decode[oauthCallbackRequest](r)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
// if the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil {
return errors.Wrap(err, "validating state")
}
return server.APIError{Code: server.ErrInvalidState}
}
cfg := tumblrOAuthConfig
cfg.RedirectURL = decoded.CallbackDomain + "/auth/login/tumblr"
token, err := cfg.Exchange(r.Context(), decoded.Code)
if err != nil {
log.Errorf("exchanging oauth code: %v", err)
return server.APIError{Code: server.ErrInvalidOAuthCode}
}
req, err := http.NewRequestWithContext(ctx, "GET", "https://api.tumblr.com/v2/user/info", nil)
if err != nil {
return errors.Wrap(err, "creating user/info request")
}
req.Header.Set("Content-Type", "application/json")
token.SetAuthHeader(req)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return errors.Wrap(err, "sending user/info request")
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 400 {
return errors.New("response had status code < 200 or >= 400")
}
jb, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Wrap(err, "reading user/info response")
}
var tr partialTumblrResponse
err = json.Unmarshal(jb, &tr)
if err != nil {
return errors.Wrap(err, "unmarshaling user/info response")
}
var tumblrName, tumblrID string
for _, blog := range tr.Response.User.Blogs {
if blog.Primary {
tumblrName = blog.Name
tumblrID = blog.UUID
break
}
}
if tumblrID == "" {
return server.APIError{Code: server.ErrInternalServerError, Details: "Your Tumblr account doesn't seem to have a primary blog"}
}
u, err := s.DB.TumblrUser(ctx, tumblrID)
if err == nil {
if u.DeletedAt != nil {
// store cancel delete token
token := undeleteToken()
err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil {
log.Errorf("saving undelete token: %v", err)
return errors.Wrap(err, "saving undelete token")
}
render.JSON(w, r, tumblrCallbackResponse{
HasAccount: true,
Token: token,
User: dbUserToUserResponse(u, []db.Field{}),
IsDeleted: true,
DeletedAt: u.DeletedAt,
SelfDelete: u.SelfDelete,
DeleteReason: u.DeleteReason,
})
return nil
}
err = u.UpdateFromTumblr(ctx, s.DB, tumblrID, tumblrName)
if err != nil {
log.Errorf("updating user %v with Tumblr info: %v", u.ID, err)
}
// TODO: implement user + token permissions
tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil {
return errors.Wrap(err, "creating token")
}
// save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
if err != nil {
return errors.Wrap(err, "saving token to database")
}
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
return errors.Wrap(err, "querying fields")
}
render.JSON(w, r, tumblrCallbackResponse{
HasAccount: true,
Token: token,
User: dbUserToUserResponse(u, fields),
})
return nil
} else if err != db.ErrUserNotFound { // internal error
return errors.Wrap(err, "getting user")
}
// no user found, so save a ticket + save their Tumblr info in Redis
ticket := RandBase64(32)
err = s.DB.SetJSON(ctx, "tumblr:"+ticket, tumblrUserInfo{ID: tumblrID, Name: tumblrName}, "EX", "600")
if err != nil {
log.Errorf("setting Tumblr user for ticket %q: %v", ticket, err)
return errors.Wrap(err, "setting user for ticket")
}
render.JSON(w, r, tumblrCallbackResponse{
HasAccount: false,
Tumblr: tumblrName,
Ticket: ticket,
RequireInvite: s.RequireInvite,
RequireCaptcha: s.hcaptchaSecret != "",
})
return nil
}
func (s *Server) tumblrLink(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
// only site tokens can be used for this endpoint
if claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
}
req, err := Decode[linkRequest](r)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting user")
}
if u.Tumblr != nil {
return server.APIError{Code: server.ErrAlreadyLinked}
}
tui := new(tumblrUserInfo)
err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui)
if err != nil {
log.Errorf("getting tumblr user for ticket: %v", err)
return server.APIError{Code: server.ErrInvalidTicket}
}
if tui.ID == "" {
log.Errorf("linking user with id %v: user ID was empty", claims.UserID)
return server.APIError{Code: server.ErrInternalServerError, Details: "Tumblr user ID is empty"}
}
err = u.UpdateFromTumblr(ctx, s.DB, tui.ID, tui.Name)
if err != nil {
return errors.Wrap(err, "updating user from tumblr")
}
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
return errors.Wrap(err, "getting user fields")
}
render.JSON(w, r, dbUserToUserResponse(u, fields))
return nil
}
func (s *Server) tumblrUnlink(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
// only site tokens can be used for this endpoint
if claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
}
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting user")
}
if u.Tumblr == nil {
return server.APIError{Code: server.ErrNotLinked}
}
// cannot unlink last auth provider
if u.NumProviders() <= 1 {
return server.APIError{Code: server.ErrLastProvider}
}
err = u.UnlinkTumblr(ctx, s.DB)
if err != nil {
return errors.Wrap(err, "updating user in db")
}
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
return errors.Wrap(err, "getting user fields")
}
render.JSON(w, r, dbUserToUserResponse(u, fields))
return nil
}
func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
req, err := Decode[signupRequest](r)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
if s.RequireInvite && req.InviteCode == "" {
return server.APIError{Code: server.ErrInviteRequired}
}
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil {
return errors.Wrap(err, "checking if username is taken")
}
if !valid {
return server.APIError{Code: server.ErrInvalidUsername}
}
if taken {
return server.APIError{Code: server.ErrUsernameTaken}
}
tx, err := s.DB.Begin(ctx)
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
tui := new(tumblrUserInfo)
err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui)
if err != nil {
log.Errorf("getting tumblr user for ticket: %v", err)
return server.APIError{Code: server.ErrInvalidTicket}
}
// check captcha
if s.hcaptchaSecret != "" {
ok, err := s.verifyCaptcha(ctx, req.CaptchaResponse)
if err != nil {
log.Errorf("verifying captcha: %v", err)
return server.APIError{Code: server.ErrInternalServerError}
}
if !ok {
return server.APIError{Code: server.ErrInvalidCaptcha}
}
}
u, err := s.DB.CreateUser(ctx, tx, req.Username)
if err != nil {
if errors.Cause(err) == db.ErrUsernameTaken {
return server.APIError{Code: server.ErrUsernameTaken}
}
return errors.Wrap(err, "creating user")
}
if tui.ID == "" {
log.Errorf("creating user with name %q: user ID was empty", req.Username)
return server.APIError{Code: server.ErrInternalServerError, Details: "Tumblr user ID is empty"}
}
err = u.UpdateFromTumblr(ctx, tx, tui.ID, tui.Name)
if err != nil {
return errors.Wrap(err, "updating user from tumblr")
}
if s.RequireInvite {
valid, used, err := s.DB.InvalidateInvite(ctx, tx, req.InviteCode)
if err != nil {
return errors.Wrap(err, "checking and invalidating invite")
}
if !valid {
return server.APIError{Code: server.ErrInviteRequired}
}
if used {
return server.APIError{Code: server.ErrInviteAlreadyUsed}
}
}
// delete sign up ticket
err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "tumblr:"+req.Ticket))
if err != nil {
return errors.Wrap(err, "deleting signup ticket")
}
// commit transaction
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
// create token
// TODO: implement user + token permissions
tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, false, false, true)
if err != nil {
return errors.Wrap(err, "creating token")
}
// save token to database
_, err = s.DB.SaveToken(ctx, u.ID, tokenID, false, false)
if err != nil {
return errors.Wrap(err, "saving token to database")
}
// return user
render.JSON(w, r, signupResponse{
User: *dbUserToUserResponse(u, nil),
Token: token,
})
return nil
}

View file

@ -6,8 +6,8 @@ import (
"encoding/base64"
"net/http"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/mediocregopher/radix/v4"
@ -42,7 +42,7 @@ func (s *Server) cancelDelete(w http.ResponseWriter, r *http.Request) error {
log.Errorf("executing undelete query: %v", err)
}
render.JSON(w, r, map[string]any{"success": true})
render.NoContent(w, r)
return nil
}
@ -67,7 +67,7 @@ func (s *Server) saveUndeleteToken(ctx context.Context, userID xid.ID, token str
func (s *Server) getUndeleteToken(ctx context.Context, token string) (userID xid.ID, err error) {
var idString string
err = s.DB.Redis.Do(ctx, radix.Cmd(&idString, "GETDEL", "undelete:"+token))
err = s.DB.Redis.Do(ctx, radix.Cmd(&idString, "GET", "undelete:"+token))
if err != nil {
return userID, errors.Wrap(err, "getting undelete key")
}
@ -76,6 +76,11 @@ func (s *Server) getUndeleteToken(ctx context.Context, token string) (userID xid
if err != nil {
return userID, errors.Wrap(err, "parsing ID")
}
err = s.DB.Redis.Do(ctx, radix.Cmd(nil, "DEL", "undelete:"+token))
if err != nil {
return userID, errors.Wrap(err, "deleting undelete key")
}
return userID, nil
}
@ -104,6 +109,6 @@ func (s *Server) forceDelete(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "deleting user")
}
render.JSON(w, r, map[string]any{"success": true})
render.NoContent(w, r)
return nil
}

View file

@ -5,11 +5,13 @@ import (
"net/http"
"strings"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
)
type CreateMemberRequest struct {
@ -27,6 +29,10 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting user")
@ -75,19 +81,38 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
if !db.MemberNameValid(cmr.Name) {
return server.APIError{
Code: server.ErrBadRequest,
Details: "Member name cannot contain any of the following: @, \\, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), +, <, =, >, ^, |, ~, `, ,",
Details: "Member name cannot contain any of the following: @, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), {, }, +, <, =, >, ^, |, ~, `, , and cannot be one or two periods.",
}
}
if err := validateSlicePtr("name", &cmr.Names); err != nil {
if common.StringLength(&cmr.Name) > db.MaxMemberNameLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Name name too long (max %d, current %d)", db.MaxMemberNameLength, common.StringLength(&cmr.Name)),
}
}
if common.StringLength(cmr.DisplayName) > db.MaxDisplayNameLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Display name too long (max %d, current %d)", db.MaxDisplayNameLength, common.StringLength(cmr.DisplayName)),
}
}
if common.StringLength(&cmr.Bio) > db.MaxUserBioLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, common.StringLength(&cmr.Bio)),
}
}
if err := validateSlicePtr("name", &cmr.Names, u.CustomPreferences); err != nil {
return *err
}
if err := validateSlicePtr("pronoun", &cmr.Pronouns); err != nil {
if err := validateSlicePtr("pronoun", &cmr.Pronouns, u.CustomPreferences); err != nil {
return *err
}
if err := validateSlicePtr("field", &cmr.Fields); err != nil {
if err := validateSlicePtr("field", &cmr.Fields, u.CustomPreferences); err != nil {
return *err
}
@ -95,7 +120,12 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
if err != nil {
return errors.Wrap(err, "starting transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
m, err := s.DB.CreateMember(ctx, tx, claims.UserID, cmr.Name, cmr.DisplayName, cmr.Bio, cmr.Links)
if err != nil {
@ -103,14 +133,14 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
return server.APIError{Code: server.ErrMemberNameInUse}
}
return err
return errors.Wrap(err, "creating member")
}
// set names, pronouns, fields
err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, cmr.Names, cmr.Pronouns)
err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, db.NotNull(cmr.Names), db.NotNull(cmr.Pronouns))
if err != nil {
log.Errorf("setting names and pronouns for member %v: %v", m.ID, err)
return err
return errors.Wrap(err, "setting names/pronouns")
}
m.Names = cmr.Names
m.Pronouns = cmr.Pronouns
@ -118,7 +148,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
err = s.DB.SetMemberFields(ctx, tx, m.ID, cmr.Fields)
if err != nil {
log.Errorf("setting fields for member %v: %v", m.ID, err)
return err
return errors.Wrap(err, "setting fields")
}
if cmr.Avatar != "" {
@ -137,13 +167,13 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
}
log.Errorf("converting member avatar: %v", err)
return err
return errors.Wrap(err, "converting avatar")
}
hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
if err != nil {
log.Errorf("uploading member avatar: %v", err)
return err
return errors.Wrap(err, "uploading avatar")
}
err = tx.QueryRow(ctx, "UPDATE members SET avatar = $1 WHERE id = $2", hash, m.ID).Scan(&m.Avatar)
@ -152,22 +182,29 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
}
}
// update last active time
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return errors.Wrap(err, "updating last active time")
}
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
render.JSON(w, r, dbMemberToMember(u, m, cmr.Fields))
render.JSON(w, r, dbMemberToMember(u, m, cmr.Fields, nil, true))
return nil
}
type validator interface {
Validate() string
Validate(custom db.CustomPreferences) string
}
// validateSlicePtr validates a slice of validators.
// If the slice is nil, a nil error is returned (assuming that the field is not required)
func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError {
func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPreferences) *server.APIError {
if slice == nil {
return nil
}
@ -187,7 +224,7 @@ func validateSlicePtr[T validator](typ string, slice *[]T) *server.APIError {
// validate all fields
for i, pronouns := range *slice {
if s := pronouns.Validate(); s != "" {
if s := pronouns.Validate(custom); s != "" {
return &server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("%s %d: %s", typ, i+1, s),

View file

@ -0,0 +1,74 @@
package member
import (
"net/http"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
)
func (s *Server) deleteMember(w http.ResponseWriter, r *http.Request) (err error) {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "this token is read-only"}
}
var m db.Member
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
m, err = s.DB.Member(ctx, id)
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
return errors.Wrap(err, "getting member")
}
} else if id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef")); err == nil {
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
return errors.Wrap(err, "getting member")
}
} else {
return server.APIError{Code: server.ErrMemberNotFound}
}
if m.UserID != claims.UserID {
return server.APIError{Code: server.ErrNotOwnMember}
}
err = s.DB.DeleteMember(ctx, m.ID)
if err != nil {
return errors.Wrap(err, "deleting member")
}
if m.Avatar != nil {
err = s.DB.DeleteMemberAvatar(ctx, m.ID, *m.Avatar)
if err != nil {
return errors.Wrap(err, "deleting member avatar")
}
}
// update last active time
err = s.DB.UpdateActiveTime(ctx, s.DB, claims.UserID)
if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return errors.Wrap(err, "updating last active time")
}
render.NoContent(w, r)
return nil
}

View file

@ -0,0 +1,223 @@
package member
import (
"context"
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
)
type GetMemberResponse struct {
ID xid.ID `json:"id"`
SnowflakeID common.MemberID `json:"id_new"`
SID string `json:"sid"`
Name string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
Avatar *string `json:"avatar"`
Links []string `json:"links"`
Names []db.FieldEntry `json:"names"`
Pronouns []db.PronounEntry `json:"pronouns"`
Fields []db.Field `json:"fields"`
Flags []db.MemberFlag `json:"flags"`
User PartialUser `json:"user"`
Unlisted *bool `json:"unlisted,omitempty"`
}
func dbMemberToMember(u db.User, m db.Member, fields []db.Field, flags []db.MemberFlag, isOwnMember bool) GetMemberResponse {
r := GetMemberResponse{
ID: m.ID,
SnowflakeID: m.SnowflakeID,
SID: m.SID,
Name: m.Name,
DisplayName: m.DisplayName,
Bio: m.Bio,
Avatar: m.Avatar,
Links: db.NotNull(m.Links),
Names: db.NotNull(m.Names),
Pronouns: db.NotNull(m.Pronouns),
Fields: db.NotNull(fields),
Flags: flags,
User: PartialUser{
ID: u.ID,
SnowflakeID: u.SnowflakeID,
Username: u.Username,
DisplayName: u.DisplayName,
Avatar: u.Avatar,
CustomPreferences: u.CustomPreferences,
},
}
if isOwnMember {
r.Unlisted = &m.Unlisted
}
return r
}
type PartialUser struct {
ID xid.ID `json:"id"`
SnowflakeID common.UserID `json:"id_new"`
Username string `json:"name"`
DisplayName *string `json:"display_name"`
Avatar *string `json:"avatar"`
CustomPreferences db.CustomPreferences `json:"custom_preferences"`
}
func (s *Server) getMember(w http.ResponseWriter, r *http.Request) (err error) {
ctx := r.Context()
var m db.Member
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
m, err = s.DB.Member(ctx, id)
if err != nil {
log.Errorf("getting member by xid: %v", err)
}
}
// xid was not valid
if !m.SnowflakeID.IsValid() {
id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{
Code: server.ErrMemberNotFound,
}
}
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
if err != nil {
return server.APIError{
Code: server.ErrMemberNotFound,
}
}
}
u, err := s.DB.User(ctx, m.UserID)
if err != nil {
return errors.Wrap(err, "getting user")
}
if u.DeletedAt != nil {
return server.APIError{Code: server.ErrMemberNotFound}
}
isOwnMember := false
if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID {
isOwnMember = true
}
fields, err := s.DB.MemberFields(ctx, m.ID)
if err != nil {
return errors.Wrap(err, "getting member fields")
}
flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil {
return errors.Wrap(err, "getting member flags")
}
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember))
return nil
}
func (s *Server) getUserMember(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
u, err := s.parseUser(ctx, chi.URLParam(r, "userRef"))
if err != nil {
return server.APIError{
Code: server.ErrUserNotFound,
}
}
if u.DeletedAt != nil {
return server.APIError{Code: server.ErrUserNotFound}
}
isOwnMember := false
if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID {
isOwnMember = true
}
m, err := s.DB.UserMember(ctx, u.ID, chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{
Code: server.ErrMemberNotFound,
}
}
fields, err := s.DB.MemberFields(ctx, m.ID)
if err != nil {
return errors.Wrap(err, "getting member fields")
}
flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil {
return errors.Wrap(err, "getting member flags")
}
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, isOwnMember))
return nil
}
func (s *Server) getMeMember(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting me user")
}
m, err := s.DB.UserMember(ctx, claims.UserID, chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{
Code: server.ErrMemberNotFound,
}
}
fields, err := s.DB.MemberFields(ctx, m.ID)
if err != nil {
return errors.Wrap(err, "getting member fields")
}
flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil {
return errors.Wrap(err, "getting member flags")
}
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true))
return nil
}
func (s *Server) parseUser(ctx context.Context, userRef string) (u db.User, err error) {
// check xid first
if id, err := xid.FromString(userRef); err == nil {
u, err := s.DB.User(ctx, id)
if err == nil {
return u, nil
}
}
// if not an xid, check by snowflake
if id, err := common.ParseSnowflake(userRef); err == nil {
u, err := s.DB.UserBySnowflake(ctx, common.UserID(id))
if err == nil {
return u, nil
}
}
// else, use username
return s.DB.Username(ctx, userRef)
}

View file

@ -0,0 +1,96 @@
package member
import (
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
)
type memberListResponse struct {
ID xid.ID `json:"id"`
SnowflakeID common.MemberID `json:"id_new"`
SID string `json:"sid"`
Name string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
Avatar *string `json:"avatar"`
Links []string `json:"links"`
Names []db.FieldEntry `json:"names"`
Pronouns []db.PronounEntry `json:"pronouns"`
Unlisted bool `json:"unlisted"`
}
func membersToMemberList(ms []db.Member, isSelf bool) []memberListResponse {
resps := make([]memberListResponse, len(ms))
for i := range ms {
resps[i] = memberListResponse{
ID: ms[i].ID,
SnowflakeID: ms[i].SnowflakeID,
SID: ms[i].SID,
Name: ms[i].Name,
DisplayName: ms[i].DisplayName,
Bio: ms[i].Bio,
Avatar: ms[i].Avatar,
Links: db.NotNull(ms[i].Links),
Names: db.NotNull(ms[i].Names),
Pronouns: db.NotNull(ms[i].Pronouns),
}
if isSelf {
resps[i].Unlisted = ms[i].Unlisted
}
}
return resps
}
func (s *Server) getUserMembers(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
u, err := s.parseUser(ctx, chi.URLParam(r, "userRef"))
if err != nil {
return server.APIError{
Code: server.ErrUserNotFound,
}
}
if u.DeletedAt != nil {
return server.APIError{Code: server.ErrUserNotFound}
}
isSelf := false
if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID {
isSelf = true
}
if u.ListPrivate && !isSelf {
return server.APIError{Code: server.ErrMemberListPrivate}
}
ms, err := s.DB.UserMembers(ctx, u.ID, isSelf)
if err != nil {
return errors.Wrap(err, "getting members")
}
render.JSON(w, r, membersToMemberList(ms, isSelf))
return nil
}
func (s *Server) getMeMembers(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
ms, err := s.DB.UserMembers(ctx, claims.UserID, true)
if err != nil {
return errors.Wrap(err, "getting members")
}
render.JSON(w, r, membersToMemberList(ms, true))
return nil
}

View file

@ -0,0 +1,402 @@
package member
import (
"fmt"
"net/http"
"strings"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/rs/xid"
)
type PatchMemberRequest struct {
Name *string `json:"name"`
Bio *string `json:"bio"`
DisplayName *string `json:"display_name"`
Links *[]string `json:"links"`
Names *[]db.FieldEntry `json:"names"`
Pronouns *[]db.PronounEntry `json:"pronouns"`
Fields *[]db.Field `json:"fields"`
Avatar *string `json:"avatar"`
Unlisted *bool `json:"unlisted"`
Flags *[]xid.ID `json:"flags"`
}
func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting user")
}
var m db.Member
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
log.Debugf("%v/%v is xid", chi.URLParam(r, "memberRef"), id)
m, err = s.DB.Member(ctx, id)
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
return errors.Wrap(err, "getting member")
}
} else {
id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{Code: server.ErrMemberNotFound}
}
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 {
return server.APIError{Code: server.ErrNotOwnMember}
}
var req PatchMemberRequest
err = render.Decode(r, &req)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
// validate that *something* is set
if req.DisplayName == nil &&
req.Name == nil &&
req.Bio == nil &&
req.Unlisted == nil &&
req.Links == nil &&
req.Fields == nil &&
req.Names == nil &&
req.Pronouns == nil &&
req.Avatar == nil &&
req.Flags == nil {
return server.APIError{
Code: server.ErrBadRequest,
Details: "Data must not be empty",
}
}
// trim whitespace from strings
if req.Name != nil {
*req.Name = strings.TrimSpace(*req.Name)
}
if req.DisplayName != nil {
*req.DisplayName = strings.TrimSpace(*req.DisplayName)
}
if req.Bio != nil {
*req.Bio = strings.TrimSpace(*req.Bio)
}
if req.Name != nil && *req.Name == "" {
return server.APIError{
Code: server.ErrBadRequest,
Details: "Name must not be empty",
}
} else if req.Name != nil && len(*req.Name) > 100 {
return server.APIError{
Code: server.ErrBadRequest,
Details: "Name may not be longer than 100 characters",
}
}
// validate member name
if req.Name != nil {
if !db.MemberNameValid(*req.Name) {
return server.APIError{
Code: server.ErrBadRequest,
Details: "Member name cannot contain any of the following: @, \\, ?, !, #, /, \\, [, ], \", ', $, %, &, (, ), +, <, =, >, ^, |, ~, `, , and cannot be one or two periods.",
}
}
}
// validate display name/bio
if common.StringLength(req.Name) > db.MaxMemberNameLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Name name too long (max %d, current %d)", db.MaxMemberNameLength, common.StringLength(req.Name)),
}
}
if common.StringLength(req.DisplayName) > db.MaxDisplayNameLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Display name too long (max %d, current %d)", db.MaxDisplayNameLength, common.StringLength(req.DisplayName)),
}
}
if common.StringLength(req.Bio) > db.MaxUserBioLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, common.StringLength(req.Name)),
}
}
// validate links
if req.Links != nil {
if len(*req.Links) > db.MaxUserLinksLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Too many links (max %d, current %d)", db.MaxUserLinksLength, len(*req.Links)),
}
}
for i, link := range *req.Links {
if len(link) > db.MaxLinkLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Link %d too long (max %d, current %d)", i, db.MaxLinkLength, len(link)),
}
}
}
}
// validate flag length
if req.Flags != nil {
if len(*req.Flags) > db.MaxPrideFlags {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Too many flags (max %d, current %d)", len(*req.Flags), db.MaxPrideFlags),
}
}
}
if err := validateSlicePtr("name", req.Names, u.CustomPreferences); err != nil {
return *err
}
if err := validateSlicePtr("pronoun", req.Pronouns, u.CustomPreferences); err != nil {
return *err
}
if err := validateSlicePtr("field", req.Fields, u.CustomPreferences); err != nil {
return *err
}
// update avatar
var avatarHash *string = nil
if req.Avatar != nil {
if *req.Avatar == "" {
if m.Avatar != nil {
err = s.DB.DeleteMemberAvatar(ctx, m.ID, *m.Avatar)
if err != nil {
log.Errorf("deleting member avatar: %v", err)
return errors.Wrap(err, "deleting avatar")
}
}
avatarHash = req.Avatar
} else {
webp, jpg, err := s.DB.ConvertAvatar(*req.Avatar)
if err != nil {
if err == db.ErrInvalidDataURI {
return server.APIError{
Code: server.ErrBadRequest,
Details: "invalid avatar data URI",
}
} else if err == db.ErrInvalidContentType {
return server.APIError{
Code: server.ErrBadRequest,
Details: "invalid avatar content type",
}
}
log.Errorf("converting member avatar: %v", err)
return errors.Wrap(err, "converting member avatar")
}
hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
if err != nil {
log.Errorf("uploading member avatar: %v", err)
return errors.Wrap(err, "writing member avatar")
}
avatarHash = &hash
// delete current avatar if member has one
if m.Avatar != nil {
err = s.DB.DeleteMemberAvatar(ctx, m.ID, *m.Avatar)
if err != nil {
log.Errorf("deleting existing avatar: %v", err)
}
}
}
}
// start transaction
tx, err := s.DB.Begin(ctx)
if err != nil {
log.Errorf("creating transaction: %v", err)
return errors.Wrap(err, "creating transaction")
}
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, m.ID, req.Name, req.DisplayName, req.Bio, req.Unlisted, req.Links, avatarHash)
if err != nil {
switch errors.Cause(err) {
case db.ErrNothingToUpdate:
case db.ErrMemberNameInUse:
return server.APIError{Code: server.ErrMemberNameInUse}
default:
log.Errorf("updating member: %v", err)
return errors.Wrap(err, "updating member in db")
}
}
if req.Names != nil || req.Pronouns != nil {
names := m.Names
pronouns := m.Pronouns
if req.Names != nil {
names = *req.Names
}
if req.Pronouns != nil {
pronouns = *req.Pronouns
}
err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, names, pronouns)
if err != nil {
log.Errorf("setting names for member %v: %v", m.ID, err)
return errors.Wrap(err, "setting names/pronouns")
}
m.Names = names
m.Pronouns = pronouns
}
var fields []db.Field
if req.Fields != nil {
err = s.DB.SetMemberFields(ctx, tx, m.ID, *req.Fields)
if err != nil {
log.Errorf("setting fields for member %v: %v", m.ID, err)
return errors.Wrap(err, "setting fields")
}
fields = *req.Fields
} else {
fields, err = s.DB.MemberFields(ctx, m.ID)
if err != nil {
log.Errorf("getting fields for member %v: %v", m.ID, err)
return errors.Wrap(err, "getting fields")
}
}
// update flags
if req.Flags != nil {
err = s.DB.SetMemberFlags(ctx, tx, m.ID, *req.Flags)
if err != nil {
if err == db.ErrInvalidFlagID {
return server.APIError{Code: server.ErrBadRequest, Details: "One or more flag IDs are unknown"}
}
log.Errorf("updating flags for member %v: %v", m.ID, err)
return errors.Wrap(err, "updating flags")
}
}
// update last active time
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return errors.Wrap(err, "updating last active time")
}
err = tx.Commit(ctx)
if err != nil {
log.Errorf("committing transaction: %v", err)
return errors.Wrap(err, "committing transaction")
}
// get flags to return (we need to return full flag objects, not the array of IDs in the request body)
flags, err := s.DB.MemberFlags(ctx, m.ID)
if err != nil {
log.Errorf("getting user flags: %v", err)
return errors.Wrap(err, "getting flags")
}
// echo the updated member back on success
render.JSON(w, r, dbMemberToMember(u, m, fields, flags, true))
return nil
}
func (s *Server) rerollMemberSID(w http.ResponseWriter, r *http.Request) (err error) {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
var m db.Member
if id, err := xid.FromString(chi.URLParam(r, "memberRef")); err == nil {
m, err = s.DB.Member(ctx, id)
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
log.Errorf("getting user %v: %v", id, err)
return errors.Wrap(err, "getting user")
}
} else {
id, err := common.ParseSnowflake(chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{Code: server.ErrMemberNotFound}
}
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
log.Errorf("getting user %v: %v", id, err)
return errors.Wrap(err, "getting user")
}
}
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting user")
}
if m.UserID != claims.UserID {
return server.APIError{Code: server.ErrNotOwnMember}
}
if time.Now().Add(-time.Hour).Before(u.LastSIDReroll) {
return server.APIError{Code: server.ErrRerollingTooQuickly}
}
newID, err := s.DB.RerollMemberSID(ctx, u.ID, m.ID)
if err != nil {
return errors.Wrap(err, "updating member SID")
}
m.SID = newID
render.JSON(w, r, dbMemberToMember(u, m, nil, nil, true))
return nil
}

View file

@ -3,7 +3,7 @@ package member
import (
"github.com/go-chi/chi/v5"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
)
type Server struct {
@ -19,6 +19,7 @@ func Mount(srv *server.Server, r chi.Router) {
// user-scoped member lookup (including custom urls)
r.Get("/users/{userRef}/members/{memberRef}", server.WrapHandler(s.getUserMember))
r.With(server.MustAuth).Get("/users/@me/members/{memberRef}", server.WrapHandler(s.getMeMember))
r.Route("/members", func(r chi.Router) {
// any member by ID
@ -28,5 +29,8 @@ func Mount(srv *server.Server, r chi.Router) {
r.With(server.MustAuth).Post("/", server.WrapHandler(s.createMember))
r.With(server.MustAuth).Patch("/{memberRef}", server.WrapHandler(s.patchMember))
r.With(server.MustAuth).Delete("/{memberRef}", server.WrapHandler(s.deleteMember))
// reroll member SID
r.With(server.MustAuth).Get("/{memberRef}/reroll", server.WrapHandler(s.rerollMemberSID))
})
}

View file

@ -0,0 +1,76 @@
package meta
import (
"net/http"
"os"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
type Server struct {
*server.Server
}
func Mount(srv *server.Server, r chi.Router) {
s := &Server{Server: srv}
r.Get("/meta", server.WrapHandler(s.meta))
}
type MetaResponse struct {
GitRepository string `json:"git_repository"`
GitCommit string `json:"git_commit"`
Users MetaUsers `json:"users"`
Members int64 `json:"members"`
RequireInvite bool `json:"require_invite"`
Notice *MetaNotice `json:"notice"`
}
type MetaNotice struct {
ID int `json:"id"`
Notice string `json:"notice"`
}
type MetaUsers struct {
Total int64 `json:"total"`
ActiveMonth int64 `json:"active_month"`
ActiveWeek int64 `json:"active_week"`
ActiveDay int64 `json:"active_day"`
}
func (s *Server) meta(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
numUsers, numMembers, activeDay, activeWeek, activeMonth := s.DB.Counts(ctx)
var notice *MetaNotice
if n, err := s.DB.CurrentNotice(ctx); err != nil {
if err != db.ErrNoNotice {
log.Errorf("getting notice: %v", err)
}
} else {
notice = &MetaNotice{
ID: n.ID,
Notice: n.Notice,
}
}
render.JSON(w, r, MetaResponse{
GitRepository: server.Repository,
GitCommit: server.Revision,
Users: MetaUsers{
Total: numUsers,
ActiveMonth: activeMonth,
ActiveWeek: activeWeek,
ActiveDay: activeDay,
},
Members: numMembers,
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
Notice: notice,
})
return nil
}

View file

@ -0,0 +1,146 @@
package mod
import (
"net/http"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
)
const MaxReasonLength = 2000
type CreateReportRequest struct {
Reason string `json:"reason"`
}
func (s *Server) createUserReport(w http.ResponseWriter, r *http.Request) (err error) {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
var u db.User
if id, err := xid.FromString(chi.URLParam(r, "id")); err == nil {
u, err = s.DB.User(ctx, id)
if err != nil {
if err == db.ErrUserNotFound {
return server.APIError{Code: server.ErrUserNotFound}
}
log.Errorf("getting user %v: %v", id, err)
return errors.Wrap(err, "getting user")
}
} else {
id, err := common.ParseSnowflake(chi.URLParam(r, "id"))
if err != nil {
return server.APIError{Code: server.ErrUserNotFound}
}
u, err = s.DB.UserBySnowflake(ctx, common.UserID(id))
if err != nil {
if err == db.ErrUserNotFound {
return server.APIError{Code: server.ErrUserNotFound}
}
log.Errorf("getting user %v: %v", id, err)
return errors.Wrap(err, "getting user")
}
}
if u.DeletedAt != nil {
return server.APIError{Code: server.ErrUserNotFound}
}
var req CreateReportRequest
err = render.Decode(r, &req)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
if len(req.Reason) > MaxReasonLength {
return server.APIError{Code: server.ErrBadRequest, Details: "Reason cannot exceed 2000 characters"}
}
_, err = s.DB.CreateReport(ctx, claims.UserID, u.ID, nil, req.Reason)
if err != nil {
log.Errorf("creating report for %v: %v", u.ID, err)
return errors.Wrap(err, "creating report")
}
render.NoContent(w, r)
return nil
}
func (s *Server) createMemberReport(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
var m db.Member
if id, err := xid.FromString(chi.URLParam(r, "id")); err == nil {
m, err = s.DB.Member(ctx, id)
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
log.Errorf("getting user %v: %v", id, err)
return errors.Wrap(err, "getting user")
}
} else {
id, err := common.ParseSnowflake(chi.URLParam(r, "id"))
if err != nil {
return server.APIError{Code: server.ErrUserNotFound}
}
m, err = s.DB.MemberBySnowflake(ctx, common.MemberID(id))
if err != nil {
if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound}
}
log.Errorf("getting user %v: %v", id, err)
return errors.Wrap(err, "getting user")
}
}
u, err := s.DB.User(ctx, m.UserID)
if err != nil {
log.Errorf("getting user %v: %v", m.UserID, err)
return errors.Wrap(err, "getting user")
}
if u.DeletedAt != nil {
return server.APIError{Code: server.ErrMemberNotFound}
}
var req CreateReportRequest
err = render.Decode(r, &req)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
if len(req.Reason) > MaxReasonLength {
return server.APIError{Code: server.ErrBadRequest, Details: "Reason cannot exceed 2000 characters"}
}
_, err = s.DB.CreateReport(ctx, claims.UserID, u.ID, &m.ID, req.Reason)
if err != nil {
log.Errorf("creating report for %v: %v", m.ID, err)
return errors.Wrap(err, "creating report")
}
render.NoContent(w, r)
return nil
}

View file

@ -4,8 +4,8 @@ import (
"net/http"
"strconv"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"

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

@ -4,12 +4,13 @@ import (
"net/http"
"strconv"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
)
type resolveReportRequest struct {
@ -43,7 +44,12 @@ func (s *Server) resolveReport(w http.ResponseWriter, r *http.Request) error {
log.Errorf("creating transaction: %v", err)
return errors.Wrap(err, "creating transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
report, err := s.DB.Report(ctx, tx, id)
if err != nil {
@ -108,6 +114,6 @@ func (s *Server) resolveReport(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "committing transaction")
}
render.JSON(w, r, map[string]any{"success": true})
render.NoContent(w, r)
return nil
}

View file

@ -3,9 +3,10 @@ package mod
import (
"net/http"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
type Server struct {
@ -21,8 +22,12 @@ func Mount(srv *server.Server, r chi.Router) {
r.Get("/reports/by-reporter/{id}", server.WrapHandler(s.getReportsByReporter))
r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport))
r.Post("/notices", server.WrapHandler(s.createNotice))
})
r.With(MustAdmin).Handle("/metrics", promhttp.Handler())
r.With(server.MustAuth).Post("/users/{id}/reports", server.WrapHandler(s.createUserReport))
r.With(server.MustAuth).Post("/members/{id}/reports", server.WrapHandler(s.createMemberReport))

View file

@ -4,9 +4,9 @@ import (
"net/http"
"strconv"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
@ -44,6 +44,10 @@ func (s *Server) ackWarning(w http.ResponseWriter, r *http.Request) (err error)
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
@ -58,6 +62,6 @@ func (s *Server) ackWarning(w http.ResponseWriter, r *http.Request) (err error)
return server.APIError{Code: server.ErrNotFound}
}
render.JSON(w, r, map[string]any{"ok": true})
render.NoContent(w, r)
return nil
}

View file

@ -3,24 +3,31 @@ package user
import (
"net/http"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
)
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if claims.APIToken || !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions}
if claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
}
tx, err := s.DB.Begin(ctx)
if err != nil {
return errors.Wrap(err, "creating transaction")
}
defer tx.Rollback(ctx)
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
err = s.DB.DeleteUser(ctx, tx, claims.UserID, true, "")
if err != nil {
@ -37,6 +44,6 @@ func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "committing transaction")
}
render.JSON(w, r, map[string]any{"deleted": true})
render.NoContent(w, r)
return nil
}

View file

@ -4,9 +4,10 @@ import (
"net/http"
"time"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
)
@ -14,6 +15,10 @@ func (s *Server) startExport(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
}
hasExport, err := s.DB.HasRecentExport(ctx, claims.UserID)
if err != nil {
log.Errorf("checking if user has recent export: %v", err)
@ -43,7 +48,7 @@ func (s *Server) startExport(w http.ResponseWriter, r *http.Request) error {
}
}
render.JSON(w, r, map[string]any{"started": true})
render.NoContent(w, r)
return nil
}
@ -56,6 +61,10 @@ func (s *Server) getExport(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
}
de, err := s.DB.UserExport(ctx, claims.UserID)
if err != nil {
if err == db.ErrNoExport {
@ -63,7 +72,7 @@ func (s *Server) getExport(w http.ResponseWriter, r *http.Request) error {
}
log.Errorf("getting export for user %v: %v", claims.UserID, err)
return err
return errors.Wrap(err, "getting export")
}
render.JSON(w, r, dataExportResponse{

View file

@ -0,0 +1,251 @@
package user
import (
"context"
"fmt"
"net/http"
"strings"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/rs/xid"
)
func (s *Server) getUserFlags(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
flags, err := s.DB.AccountFlags(ctx, claims.UserID)
if err != nil {
return errors.Wrapf(err, "getting flags for account %v", claims.UserID)
}
render.JSON(w, r, flags)
return nil
}
type postUserFlagRequest struct {
Flag string `json:"flag"`
Name string `json:"name"`
Description string `json:"description"`
}
func (s *Server) postUserFlag(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
flags, err := s.DB.AccountFlags(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting current user flags")
}
if len(flags) >= db.MaxPrideFlags {
return server.APIError{
Code: server.ErrFlagLimitReached,
}
}
var req postUserFlagRequest
err = render.Decode(r, &req)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
// remove whitespace from all fields
req.Name = strings.TrimSpace(req.Name)
req.Description = strings.TrimSpace(req.Description)
if s := common.StringLength(&req.Name); s > db.MaxPrideFlagTitleLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("name too long, must be %v characters or less, is %v", db.MaxPrideFlagTitleLength, s),
}
}
if s := common.StringLength(&req.Description); s > db.MaxPrideFlagDescLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("description too long, must be %v characters or less, is %v", db.MaxPrideFlagDescLength, s),
}
}
tx, err := s.DB.Begin(ctx)
if err != nil {
return errors.Wrap(err, "starting transaction")
}
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
flag, err := s.DB.CreateFlag(ctx, tx, claims.UserID, req.Name, req.Description)
if err != nil {
log.Errorf("creating flag: %v", err)
return errors.Wrap(err, "creating flag")
}
webp, err := s.DB.ConvertFlag(req.Flag)
if err != nil {
if err == db.ErrInvalidDataURI {
return server.APIError{Code: server.ErrBadRequest, Message: "invalid data URI"}
} else if err == db.ErrFileTooLarge {
return server.APIError{Code: server.ErrBadRequest, Message: "data URI exceeds 512 KB"}
}
return errors.Wrap(err, "converting flag")
}
hash, err := s.DB.WriteFlag(ctx, flag.ID, webp)
if err != nil {
return errors.Wrap(err, "writing flag")
}
flag, err = s.DB.EditFlag(ctx, tx, flag.ID, nil, nil, &hash)
if err != nil {
return errors.Wrap(err, "setting hash for flag")
}
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
render.JSON(w, r, flag)
return nil
}
type patchUserFlagRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
}
func (s *Server) parseFlag(ctx context.Context, flags []db.PrideFlag, flagRef string) (db.PrideFlag, bool) {
if id, err := xid.FromString(flagRef); err == nil {
for _, f := range flags {
if f.ID == id {
return f, true
}
}
}
if id, err := common.ParseSnowflake(flagRef); err == nil {
for _, f := range flags {
if f.SnowflakeID == common.FlagID(id) {
return f, true
}
}
}
return db.PrideFlag{}, false
}
func (s *Server) patchUserFlag(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
flags, err := s.DB.AccountFlags(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting current user flags")
}
flag, ok := s.parseFlag(ctx, flags, chi.URLParam(r, "flagID"))
if !ok {
return server.APIError{Code: server.ErrNotFound, Details: "No flag with that ID found"}
}
var req patchUserFlagRequest
err = render.Decode(r, &req)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
if req.Name != nil {
*req.Name = strings.TrimSpace(*req.Name)
}
if req.Description != nil {
*req.Description = strings.TrimSpace(*req.Description)
}
if req.Name == nil && req.Description == nil {
return server.APIError{Code: server.ErrBadRequest, Details: "Request cannot be empty"}
}
if s := common.StringLength(req.Name); s > db.MaxPrideFlagTitleLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("name too long, must be %v characters or less, is %v", db.MaxPrideFlagTitleLength, s),
}
}
if s := common.StringLength(req.Description); s > db.MaxPrideFlagDescLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("description too long, must be %v characters or less, is %v", db.MaxPrideFlagDescLength, s),
}
}
tx, err := s.DB.Begin(ctx)
if err != nil {
return errors.Wrap(err, "beginning transaction")
}
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, flag.ID, req.Name, req.Description, nil)
if err != nil {
return errors.Wrap(err, "updating flag")
}
err = tx.Commit(ctx)
if err != nil {
return errors.Wrap(err, "committing transaction")
}
render.JSON(w, r, flag)
return nil
}
func (s *Server) deleteUserFlag(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
flags, err := s.DB.AccountFlags(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting current user flags")
}
flag, ok := s.parseFlag(ctx, flags, chi.URLParam(r, "flagID"))
if !ok {
return server.APIError{Code: server.ErrNotFound, Details: "No flag with that ID found"}
}
if flag.UserID != claims.UserID {
return server.APIError{Code: server.ErrNotFound, Details: "Flag not found"}
}
err = s.DB.DeleteFlag(ctx, flag.ID, flag.Hash)
if err != nil {
return errors.Wrap(err, "deleting flag")
}
render.NoContent(w, r)
return nil
}

View file

@ -0,0 +1,235 @@
package user
import (
"net/http"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/rs/xid"
)
type GetUserResponse struct {
ID xid.ID `json:"id"`
SnowflakeID common.UserID `json:"id_new"`
SID string `json:"sid"`
Username string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
MemberTitle *string `json:"member_title"`
Avatar *string `json:"avatar"`
Links []string `json:"links"`
Names []db.FieldEntry `json:"names"`
Pronouns []db.PronounEntry `json:"pronouns"`
Members []PartialMember `json:"members"`
Fields []db.Field `json:"fields"`
CustomPreferences db.CustomPreferences `json:"custom_preferences"`
Flags []db.UserFlag `json:"flags"`
Badges db.Badge `json:"badges"`
UTCOffset *int `json:"utc_offset"`
}
type GetMeResponse struct {
GetUserResponse
CreatedAt time.Time `json:"created_at"`
Timezone *string `json:"timezone"`
MaxInvites int `json:"max_invites"`
IsAdmin bool `json:"is_admin"`
ListPrivate bool `json:"list_private"`
LastSIDReroll time.Time `json:"last_sid_reroll"`
Discord *string `json:"discord"`
DiscordUsername *string `json:"discord_username"`
Tumblr *string `json:"tumblr"`
TumblrUsername *string `json:"tumblr_username"`
Google *string `json:"google"`
GoogleUsername *string `json:"google_username"`
Fediverse *string `json:"fediverse"`
FediverseUsername *string `json:"fediverse_username"`
FediverseInstance *string `json:"fediverse_instance"`
}
type PartialMember struct {
ID xid.ID `json:"id"`
SnowflakeID common.MemberID `json:"id_new"`
SID string `json:"sid"`
Name string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
Avatar *string `json:"avatar"`
Links []string `json:"links"`
Names []db.FieldEntry `json:"names"`
Pronouns []db.PronounEntry `json:"pronouns"`
}
func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags []db.UserFlag) GetUserResponse {
resp := GetUserResponse{
ID: u.ID,
SnowflakeID: u.SnowflakeID,
SID: u.SID,
Username: u.Username,
DisplayName: u.DisplayName,
Bio: u.Bio,
MemberTitle: u.MemberTitle,
Avatar: u.Avatar,
Links: db.NotNull(u.Links),
Names: db.NotNull(u.Names),
Pronouns: db.NotNull(u.Pronouns),
Fields: db.NotNull(fields),
CustomPreferences: u.CustomPreferences,
Flags: flags,
}
if u.IsAdmin {
resp.Badges |= db.BadgeAdmin
}
if offset, ok := u.UTCOffset(); ok {
resp.UTCOffset = &offset
}
resp.Members = make([]PartialMember, len(members))
for i := range members {
resp.Members[i] = PartialMember{
ID: members[i].ID,
SnowflakeID: members[i].SnowflakeID,
SID: members[i].SID,
Name: members[i].Name,
DisplayName: members[i].DisplayName,
Bio: members[i].Bio,
Avatar: members[i].Avatar,
Links: db.NotNull(members[i].Links),
Names: db.NotNull(members[i].Names),
Pronouns: db.NotNull(members[i].Pronouns),
}
}
return resp
}
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) {
ctx := r.Context()
userRef := chi.URLParamFromCtx(ctx, "userRef")
var u db.User
if id, err := xid.FromString(userRef); err == nil {
u, err = s.DB.User(ctx, id)
if err != nil {
log.Errorf("getting user by ID: %v", err)
}
}
if u.ID.IsNil() {
if id, err := common.ParseSnowflake(userRef); err == nil {
u, err = s.DB.UserBySnowflake(ctx, common.UserID(id))
if err != nil {
log.Errorf("getting user by snowflake: %v", err)
}
}
}
if u.ID.IsNil() {
u, err = s.DB.Username(ctx, userRef)
if err == db.ErrUserNotFound {
return server.APIError{
Code: server.ErrUserNotFound,
}
} else if err != nil {
log.Errorf("Error getting user by username: %v", err)
return errors.Wrap(err, "getting user")
}
}
if u.DeletedAt != nil {
return server.APIError{Code: server.ErrUserNotFound}
}
isSelf := false
if claims, ok := server.ClaimsFromContext(ctx); ok && claims.UserID == u.ID {
isSelf = true
}
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
log.Errorf("Error getting user fields: %v", err)
return errors.Wrap(err, "getting fields")
}
flags, err := s.DB.UserFlags(ctx, u.ID)
if err != nil {
log.Errorf("getting user flags: %v", err)
return errors.Wrap(err, "getting flags")
}
var members []db.Member
if !u.ListPrivate || isSelf {
members, err = s.DB.UserMembers(ctx, u.ID, isSelf)
if err != nil {
log.Errorf("Error getting user members: %v", err)
return errors.Wrap(err, "getting user members")
}
}
render.JSON(w, r, dbUserToResponse(u, fields, members, flags))
return nil
}
func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
log.Errorf("Error getting user: %v", err)
return errors.Wrap(err, "getting users")
}
fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil {
log.Errorf("Error getting user fields: %v", err)
return errors.Wrap(err, "getting fields")
}
members, err := s.DB.UserMembers(ctx, u.ID, true)
if err != nil {
log.Errorf("Error getting user members: %v", err)
return errors.Wrap(err, "getting members")
}
flags, err := s.DB.UserFlags(ctx, u.ID)
if err != nil {
log.Errorf("getting user flags: %v", err)
return errors.Wrap(err, "getting flags")
}
render.JSON(w, r, GetMeResponse{
GetUserResponse: dbUserToResponse(u, fields, members, flags),
CreatedAt: u.ID.Time(),
Timezone: u.Timezone,
MaxInvites: u.MaxInvites,
IsAdmin: u.IsAdmin,
ListPrivate: u.ListPrivate,
LastSIDReroll: u.LastSIDReroll,
Discord: u.Discord,
DiscordUsername: u.DiscordUsername,
Tumblr: u.Tumblr,
TumblrUsername: u.TumblrUsername,
Google: u.Google,
GoogleUsername: u.GoogleUsername,
Fediverse: u.Fediverse,
FediverseUsername: u.FediverseUsername,
FediverseInstance: u.FediverseInstance,
})
return nil
}

View file

@ -0,0 +1,418 @@
package user
import (
"fmt"
"net/http"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors"
"github.com/go-chi/render"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/rs/xid"
)
type PatchUserRequest struct {
Username *string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
MemberTitle *string `json:"member_title"`
Links *[]string `json:"links"`
Names *[]db.FieldEntry `json:"names"`
Pronouns *[]db.PronounEntry `json:"pronouns"`
Fields *[]db.Field `json:"fields"`
Avatar *string `json:"avatar"`
Timezone *string `json:"timezone"`
ListPrivate *bool `json:"list_private"`
CustomPreferences *db.CustomPreferences `json:"custom_preferences"`
Flags *[]xid.ID `json:"flags"`
}
// patchUser parses a PatchUserRequest and updates the user with the given ID.
func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
var req PatchUserRequest
err := render.Decode(r, &req)
if err != nil {
return server.APIError{Code: server.ErrBadRequest}
}
// get existing user, for comparison later
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting existing user")
}
// validate that *something* is set
if req.Username == nil &&
req.DisplayName == nil &&
req.Bio == nil &&
req.MemberTitle == nil &&
req.ListPrivate == nil &&
req.Links == nil &&
req.Fields == nil &&
req.Names == nil &&
req.Pronouns == nil &&
req.Avatar == nil &&
req.CustomPreferences == nil &&
req.Flags == nil {
return server.APIError{
Code: server.ErrBadRequest,
Details: "Data must not be empty",
}
}
// validate display name/bio
if common.StringLength(req.Username) > db.MaxUsernameLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Name name too long (max %d, current %d)", db.MaxUsernameLength, common.StringLength(req.Username)),
}
}
if common.StringLength(req.DisplayName) > db.MaxDisplayNameLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Display name too long (max %d, current %d)", db.MaxDisplayNameLength, common.StringLength(req.DisplayName)),
}
}
if common.StringLength(req.Bio) > db.MaxUserBioLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Bio too long (max %d, current %d)", db.MaxUserBioLength, common.StringLength(req.Bio)),
}
}
// validate timezone
if req.Timezone != nil {
if *req.Timezone != "" {
_, err := time.LoadLocation(*req.Timezone)
if err != nil {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("%q is not a valid timezone", *req.Timezone),
}
}
}
}
// validate links
if req.Links != nil {
if len(*req.Links) > db.MaxUserLinksLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Too many links (max %d, current %d)", db.MaxUserLinksLength, len(*req.Links)),
}
}
for i, link := range *req.Links {
if len(link) > db.MaxLinkLength {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Link %d too long (max %d, current %d)", i, db.MaxLinkLength, len(link)),
}
}
}
}
// validate flag length
if req.Flags != nil {
if len(*req.Flags) > db.MaxPrideFlags {
return server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Too many flags (max %d, current %d)", len(*req.Flags), db.MaxPrideFlags),
}
}
}
// validate custom preferences
if req.CustomPreferences != nil {
if count := len(*req.CustomPreferences); count > db.MaxFields {
return server.APIError{Code: server.ErrBadRequest, Details: fmt.Sprintf("Too many custom preferences (max %d, current %d)", db.MaxFields, count)}
}
for k, v := range *req.CustomPreferences {
_, err := uuid.Parse(k)
if err != nil {
return server.APIError{Code: server.ErrBadRequest, Details: "One or more custom preference IDs is not a UUID."}
}
if s := v.Validate(); s != "" {
return server.APIError{Code: server.ErrBadRequest, Details: s}
}
}
}
customPreferences := u.CustomPreferences
if req.CustomPreferences != nil {
customPreferences = *req.CustomPreferences
}
if err := validateSlicePtr("name", req.Names, customPreferences); err != nil {
return *err
}
if err := validateSlicePtr("pronoun", req.Pronouns, customPreferences); err != nil {
return *err
}
if err := validateSlicePtr("field", req.Fields, customPreferences); err != nil {
return *err
}
// update avatar
var avatarHash *string = nil
if req.Avatar != nil {
if *req.Avatar == "" {
if u.Avatar != nil {
err = s.DB.DeleteUserAvatar(ctx, u.ID, *u.Avatar)
if err != nil {
log.Errorf("deleting user avatar: %v", err)
return errors.Wrap(err, "deleting avatar")
}
}
avatarHash = req.Avatar
} else {
webp, jpg, err := s.DB.ConvertAvatar(*req.Avatar)
if err != nil {
if err == db.ErrInvalidDataURI {
return server.APIError{
Code: server.ErrBadRequest,
Details: "invalid avatar data URI",
}
} else if err == db.ErrInvalidContentType {
return server.APIError{
Code: server.ErrBadRequest,
Details: "invalid avatar content type",
}
}
log.Errorf("converting user avatar: %v", err)
return errors.Wrap(err, "converting avatar")
}
hash, err := s.DB.WriteUserAvatar(ctx, claims.UserID, webp, jpg)
if err != nil {
log.Errorf("uploading user avatar: %v", err)
return errors.Wrap(err, "uploading avatar")
}
avatarHash = &hash
// delete current avatar if user has one
if u.Avatar != nil {
err = s.DB.DeleteUserAvatar(ctx, claims.UserID, *u.Avatar)
if err != nil {
log.Errorf("deleting existing avatar: %v", err)
}
}
}
}
// start transaction
tx, err := s.DB.Begin(ctx)
if err != nil {
log.Errorf("creating transaction: %v", err)
return errors.Wrap(err, "creating transaction")
}
defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
// update username
if req.Username != nil && *req.Username != u.Username {
err = s.DB.UpdateUsername(ctx, tx, claims.UserID, *req.Username)
if err != nil {
switch err {
case db.ErrUsernameTaken:
return server.APIError{Code: server.ErrUsernameTaken}
case db.ErrInvalidUsername:
return server.APIError{Code: server.ErrInvalidUsername}
case db.ErrBannedUsername:
return server.APIError{Code: server.ErrInvalidUsername, Details: "That username cannot be used."}
default:
return errors.Wrap(err, "updating username")
}
}
}
u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash, req.Timezone, req.CustomPreferences)
if err != nil && errors.Cause(err) != db.ErrNothingToUpdate {
log.Errorf("updating user: %v", err)
return errors.Wrap(err, "updating user")
}
if req.Names != nil || req.Pronouns != nil {
names := u.Names
pronouns := u.Pronouns
if req.Names != nil {
names = *req.Names
}
if req.Pronouns != nil {
pronouns = *req.Pronouns
}
err = s.DB.SetUserNamesPronouns(ctx, tx, claims.UserID, names, pronouns)
if err != nil {
log.Errorf("setting names for member %v: %v", claims.UserID, err)
return errors.Wrap(err, "setting names/pronouns")
}
u.Names = names
u.Pronouns = pronouns
}
var fields []db.Field
if req.Fields != nil {
err = s.DB.SetUserFields(ctx, tx, claims.UserID, *req.Fields)
if err != nil {
log.Errorf("setting fields for user %v: %v", claims.UserID, err)
return errors.Wrap(err, "setting fields")
}
fields = *req.Fields
} else {
fields, err = s.DB.UserFields(ctx, claims.UserID)
if err != nil {
log.Errorf("getting fields for user %v: %v", claims.UserID, err)
return errors.Wrap(err, "getting fields")
}
}
// update flags
if req.Flags != nil {
err = s.DB.SetUserFlags(ctx, tx, claims.UserID, *req.Flags)
if err != nil {
if err == db.ErrInvalidFlagID {
return server.APIError{Code: server.ErrBadRequest, Details: "One or more flag IDs are unknown"}
}
log.Errorf("updating flags for user %v: %v", claims.UserID, err)
return errors.Wrap(err, "updating flags")
}
}
// update last active time
err = s.DB.UpdateActiveTime(ctx, tx, claims.UserID)
if err != nil {
log.Errorf("updating last active time for user %v: %v", claims.UserID, err)
return errors.Wrap(err, "updating last active time")
}
err = tx.Commit(ctx)
if err != nil {
log.Errorf("committing transaction: %v", err)
return errors.Wrap(err, "committing transaction")
}
// get fedi instance name if the user has a linked fedi account
var fediInstance *string
if u.FediverseAppID != nil {
app, err := s.DB.FediverseAppByID(ctx, *u.FediverseAppID)
if err == nil {
fediInstance = &app.Instance
}
}
// get flags to return (we need to return full flag objects, not the array of IDs in the request body)
flags, err := s.DB.UserFlags(ctx, u.ID)
if err != nil {
log.Errorf("getting user flags: %v", err)
return errors.Wrap(err, "getting flags")
}
// echo the updated user back on success
render.JSON(w, r, GetMeResponse{
GetUserResponse: dbUserToResponse(u, fields, nil, flags),
CreatedAt: u.ID.Time(),
Timezone: u.Timezone,
MaxInvites: u.MaxInvites,
IsAdmin: u.IsAdmin,
ListPrivate: u.ListPrivate,
LastSIDReroll: u.LastSIDReroll,
Discord: u.Discord,
DiscordUsername: u.DiscordUsername,
Tumblr: u.Tumblr,
TumblrUsername: u.TumblrUsername,
Google: u.Google,
GoogleUsername: u.GoogleUsername,
Fediverse: u.Fediverse,
FediverseUsername: u.FediverseUsername,
FediverseInstance: fediInstance,
})
return nil
}
type validator interface {
Validate(custom db.CustomPreferences) string
}
// validateSlicePtr validates a slice of validators.
// If the slice is nil, a nil error is returned (assuming that the field is not required)
func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPreferences) *server.APIError {
if slice == nil {
return nil
}
max := db.MaxFields
if typ != "field" {
max = db.FieldEntriesLimit
}
// max 25 fields
if len(*slice) > max {
return &server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("Too many %ss (max %d, current %d)", typ, max, len(*slice)),
}
}
// validate all fields
for i, pronouns := range *slice {
if s := pronouns.Validate(custom); s != "" {
return &server.APIError{
Code: server.ErrBadRequest,
Details: fmt.Sprintf("%s %d: %s", typ, i+1, s),
}
}
}
return nil
}
func (s *Server) rerollUserSID(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
}
u, err := s.DB.User(ctx, claims.UserID)
if err != nil {
return errors.Wrap(err, "getting existing user")
}
if time.Now().Add(-time.Hour).Before(u.LastSIDReroll) {
return server.APIError{Code: server.ErrRerollingTooQuickly}
}
newID, err := s.DB.RerollUserSID(ctx, u.ID)
if err != nil {
return errors.Wrap(err, "updating user SID")
}
u.SID = newID
render.JSON(w, r, dbUserToResponse(u, nil, nil, nil))
return nil
}

View file

@ -3,7 +3,7 @@ package user
import (
"os"
"codeberg.org/u1f320/pronouns.cc/backend/server"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5"
)
@ -29,6 +29,13 @@ func Mount(srv *server.Server, r chi.Router) {
r.Get("/@me/export/start", server.WrapHandler(s.startExport))
r.Get("/@me/export", server.WrapHandler(s.getExport))
r.Get("/@me/flags", server.WrapHandler(s.getUserFlags))
r.Post("/@me/flags", server.WrapHandler(s.postUserFlag))
r.Patch("/@me/flags/{flagID}", server.WrapHandler(s.patchUserFlag))
r.Delete("/@me/flags/{flagID}", server.WrapHandler(s.deleteUserFlag))
r.Get("/@me/reroll", server.WrapHandler(s.rerollUserSID))
})
})
}

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

@ -5,8 +5,8 @@ import (
"net/http"
"strings"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server/auth"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server/auth"
"github.com/go-chi/render"
)

View file

@ -6,7 +6,8 @@ import (
"os"
"time"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors"
"github.com/golang-jwt/jwt/v4"
"github.com/rs/xid"
@ -46,14 +47,11 @@ func New() *Verifier {
return &Verifier{key: key}
}
// ExpireDays is after how many days the token will expire.
const ExpireDays = 30
// CreateToken creates a token for the given user ID.
// It expires after 30 days.
// It expires after three months.
func (v *Verifier) CreateToken(userID, tokenID xid.ID, isAdmin bool, isAPIToken bool, isWriteToken bool) (token string, err error) {
now := time.Now()
expires := now.Add(ExpireDays * 24 * time.Hour)
expires := now.Add(db.TokenExpiryTime)
t := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
UserID: userID,

View file

@ -1,10 +1,14 @@
package server
import (
"context"
"fmt"
"net/http"
"codeberg.org/u1f320/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"
)
@ -12,6 +16,11 @@ import (
// The inner HandlerFunc additionally returns an error.
func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
hub := sentry.GetHubFromContext(r.Context())
if hub == nil {
hub = sentry.CurrentHub().Clone()
}
err := hn(w, r)
if err != nil {
// if the function returned an API error, just render that verbatim
@ -24,10 +33,20 @@ func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.Han
return
}
// otherwise, we log the error and return an internal server error message
log.Errorf("error in http handler: %v", err)
rctx := chi.RouteContext(r.Context())
hub.ConfigureScope(func(scope *sentry.Scope) {
scope.SetTag("method", rctx.RouteMethod)
scope.SetTag("path", rctx.RoutePattern())
})
apiErr := APIError{Code: ErrInternalServerError}
var eventID *sentry.EventID = nil
if isExpectedError(err) {
log.Infof("expected error in handler for %v %v, ignoring", rctx.RouteMethod, rctx.RoutePattern())
} else {
log.Errorf("error in handler for %v %v: %v", rctx.RouteMethod, rctx.RoutePattern(), err)
eventID = hub.CaptureException(err)
}
apiErr := APIError{ID: eventID, Code: ErrInternalServerError}
apiErr.prepare()
render.Status(r, apiErr.Status)
@ -36,12 +55,17 @@ func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.Han
}
}
func isExpectedError(err error) bool {
return errors.Is(err, context.Canceled)
}
// APIError is an object returned by the API when an error occurs.
// It implements the error interface and can be returned by handlers.
type APIError struct {
Code int `json:"code"`
Message string `json:"message,omitempty"`
Details string `json:"details,omitempty"`
Code int `json:"code"`
ID *sentry.EventID `json:"id,omitempty"`
Message string `json:"message,omitempty"`
Details string `json:"details,omitempty"`
RatelimitReset *int `json:"ratelimit_reset,omitempty"`
@ -97,9 +121,13 @@ const (
ErrAlreadyLinked = 1014 // user already has linked account of the same type
ErrNotLinked = 1015 // user already doesn't have a linked account
ErrLastProvider = 1016 // unlinking provider would leave account with no authentication method
ErrInvalidCaptcha = 1017 // invalid or missing captcha response
// User-related error codes
ErrUserNotFound = 2001
ErrUserNotFound = 2001
ErrMemberListPrivate = 2002
ErrFlagLimitReached = 2003
ErrRerollingTooQuickly = 2004
// Member-related error codes
ErrMemberNotFound = 3001
@ -140,8 +168,12 @@ var errCodeMessages = map[int]string{
ErrAlreadyLinked: "Your account is already linked to an account of this type",
ErrNotLinked: "Your account is already not linked to an account of this type",
ErrLastProvider: "This is your account's only authentication provider",
ErrInvalidCaptcha: "Invalid or missing captcha response",
ErrUserNotFound: "User not found",
ErrUserNotFound: "User not found",
ErrMemberListPrivate: "This user's member list is private",
ErrFlagLimitReached: "Maximum number of pride flags reached",
ErrRerollingTooQuickly: "You can only reroll one short ID per hour.",
ErrMemberNotFound: "Member not found",
ErrMemberLimitReached: "Member limit reached",
@ -179,8 +211,12 @@ var errCodeStatuses = map[int]int{
ErrAlreadyLinked: http.StatusBadRequest,
ErrNotLinked: http.StatusBadRequest,
ErrLastProvider: http.StatusBadRequest,
ErrInvalidCaptcha: http.StatusBadRequest,
ErrUserNotFound: http.StatusNotFound,
ErrUserNotFound: http.StatusNotFound,
ErrMemberListPrivate: http.StatusForbidden,
ErrFlagLimitReached: http.StatusBadRequest,
ErrRerollingTooQuickly: http.StatusForbidden,
ErrMemberNotFound: http.StatusNotFound,
ErrMemberLimitReached: http.StatusBadRequest,

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

@ -6,13 +6,15 @@ import (
"strconv"
"time"
"codeberg.org/u1f320/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/server/auth"
"codeberg.org/u1f320/pronouns.cc/backend/server/rate"
"codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/pronounscc/pronouns.cc/backend/server/auth"
"codeberg.org/pronounscc/pronouns.cc/backend/server/rate"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"github.com/go-chi/render"
chiprometheus "github.com/toshi0607/chi-prometheus"
)
// Revision is the git commit, filled at build time
@ -22,7 +24,7 @@ var (
)
// Repository is the URL of the git repository
const Repository = "https://codeberg.org/u1f320/pronouns.cc"
const Repository = "https://codeberg.org/pronounscc/pronouns.cc"
type Server struct {
Router *chi.Mux
@ -48,6 +50,25 @@ func New() (*Server, error) {
s.Router.Use(middleware.Logger)
}
s.Router.Use(middleware.Recoverer)
// add Sentry tracing handler
s.Router.Use(s.sentry)
// add CORS
s.Router.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"https://*", "http://*"},
// Allow all methods normally used by the API
AllowedMethods: []string{"HEAD", "GET", "POST", "PATCH", "DELETE"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
AllowCredentials: false,
MaxAge: 300,
}))
// enable request latency tracking
os.Setenv(chiprometheus.EnvChiPrometheusLatencyBuckets, "10,25,50,100,300,500,1000,5000")
prom := chiprometheus.New("pronouns.cc")
s.Router.Use(prom.Handler)
prom.MustRegisterDefault()
// enable authentication for all routes (but don't require it)
s.Router.Use(s.maybeAuth)
@ -79,30 +100,35 @@ func New() (*Server, error) {
// set scopes
// users
rateLimiter.Scope("GET", "/users/*", 60)
rateLimiter.Scope("PATCH", "/users/@me", 10)
_ = rateLimiter.Scope("GET", "/users/*", 60)
_ = rateLimiter.Scope("PATCH", "/users/@me", 10)
// members
rateLimiter.Scope("GET", "/users/*/members", 60)
rateLimiter.Scope("GET", "/users/*/members/*", 60)
_ = rateLimiter.Scope("GET", "/users/*/members", 60)
_ = rateLimiter.Scope("GET", "/users/*/members/*", 60)
rateLimiter.Scope("POST", "/members", 10)
rateLimiter.Scope("GET", "/members/*", 60)
rateLimiter.Scope("PATCH", "/members/*", 20)
rateLimiter.Scope("DELETE", "/members/*", 5)
_ = rateLimiter.Scope("POST", "/members", 10)
_ = rateLimiter.Scope("GET", "/members/*", 60)
_ = rateLimiter.Scope("PATCH", "/members/*", 20)
_ = rateLimiter.Scope("DELETE", "/members/*", 5)
// auth
rateLimiter.Scope("*", "/auth/*", 20)
rateLimiter.Scope("*", "/auth/tokens", 10)
rateLimiter.Scope("*", "/auth/invites", 10)
rateLimiter.Scope("POST", "/auth/discord/*", 10)
_ = rateLimiter.Scope("*", "/auth/*", 20)
_ = rateLimiter.Scope("*", "/auth/tokens", 10)
_ = rateLimiter.Scope("*", "/auth/invites", 10)
_ = rateLimiter.Scope("POST", "/auth/discord/*", 10)
// rate limit handling
// - 120 req/minute (2/s)
// - keyed by Authorization header if valid token is provided, otherwise by IP
// - returns rate limit reset info in error
s.Router.Use(rateLimiter.Handler())
// increment the total requests counter whenever a request is made
s.Router.Use(func(next http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
s.DB.TotalRequests.Inc()
next.ServeHTTP(w, r)
}
return http.HandlerFunc(fn)
})
// return an API error for not found + method not allowed
s.Router.NotFound(func(w http.ResponseWriter, r *http.Request) {
render.Status(r, errCodeStatuses[ErrNotFound])

1
docs/.vitepress/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
cache/

View file

@ -0,0 +1,41 @@
import { defineConfig } from "vitepress";
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: "pronouns.cc documentation",
description: "pronouns.cc documentation",
markdown: {
anchor: { level: [2, 3] },
},
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
siteTitle: "pronouns.cc",
logo: "/logo.svg",
nav: [
{ text: "Home", link: "/" },
{ text: "Back to pronouns.cc", link: "https://pronouns.cc/" },
],
outline: {
level: [2, 3],
},
sidebar: [
{
text: "API",
items: [
{ text: "API reference", link: "/api/" },
{ text: "Rate limits", link: "/api/ratelimits" },
{ text: "Error messages", link: "/api/errors" },
],
},
{
text: "Endpoints",
items: [
{ text: "Object reference", link: "/api/endpoints/" },
{ text: "Users", link: "/api/endpoints/users" },
{ text: "Members", link: "/api/endpoints/members" },
{ text: "Other", link: "/api/endpoints/other" },
],
},
],
},
});

View file

@ -0,0 +1,4 @@
:root {
--vp-font-family-base: "FiraGO", sans-serif;
--vp-font-family-mono: "Fira Mono", monospace;
}

View file

@ -0,0 +1,9 @@
import DefaultTheme from 'vitepress/theme-without-fonts'
import "@fontsource/firago/400.css";
import "@fontsource/firago/400-italic.css";
import "@fontsource/firago/700.css";
import "@fontsource/firago/700-italic.css";
import "@fontsource/fira-mono";
import "./custom.css";
export default DefaultTheme

View file

@ -0,0 +1,54 @@
# Object reference
These are some of the objects shared by multiple types of endpoints.
For other objects, such as [users](./users) or [members](./members), check their respective pages.
## Field
| Field | Type | Description |
| ------- | ------------------------------- | --------------------------- |
| name | string | the field's name or heading |
| entries | [field_entry](./#field-entry)[] | the field's entries |
## Field entry
| Field | Type | Description |
| ------ | ------ | -------------------------------- |
| value | string | this entry's value or key |
| status | string | this entry's [status](./#status) |
## Pronoun entry
| Field | Type | Description |
| ------------ | ------- | ----------------------------------------------------------------------------------------------------- |
| pronouns | string | this entry's raw pronouns. This can be any user-inputted value and does not have to be a complete set |
| display_text | string? | the text shown in the pronoun list, if `pronouns` is a valid 5-member set |
| status | string | this entry's [status](./#status) |
## Status
A name, pronoun, or field entry's **status** is how the user or member feels about that entry.
This can be any of `favourite`, `okay`, `jokingly`, `friends_only`, `avoid`,
as well as the UUID of any [custom preferences](./#custom-preference) the user has set.
## Custom preference
A user can set custom word preferences, which can have custom icons and tooltips. These are identified by a UUID.
| Field | Type | Description |
| --------- | ------ | ---------------------------------------------------------------------------------------------------- |
| icon | string | the [Bootstrap icon](https://icons.getbootstrap.com/) associated with this preference |
| tooltip | string | the description shown in the tooltip on hover or tap |
| size | string | the size at which any entry with this preference will be shown, can be `large`, `normal`, or `small` |
| muted | bool | whether the preference is shown in a muted grey colour |
| favourite | bool | whether the preference is treated the same as `favourite` when building embeds |
## Pride flag
| Field | Type | Description |
| ----------- | --------- | ------------------------------------- |
| id | string | the flag's unique ID |
| id_new | snowflake | the flag's unique snowflake ID |
| hash | string | the flag's [image hash](/api/#images) |
| name | string | the flag's name |
| description | string? | the flag's description or alt text |

View file

@ -0,0 +1,125 @@
# Member endpoints
## Member object
| Field | Type | Description |
| ------------ | ---------------------------------------------------- | --------------------------------------------------------------------------------- |
| id | string | the member's unique ID |
| id_new | snowflake | the member's unique snowflake ID |
| sid | string | the member's 6-letter short ID |
| name | string | the member's name |
| display_name | string? | the member's display name or nickname |
| bio | string? | the member's description |
| avatar | string? | the member's [avatar hash](/api/#images) |
| links | string[] | the member's profile links |
| names | [field_entry](./#field-entry)[] | the member's preferred names |
| pronouns | [pronoun_entry](./#pronoun-entry)[] | the member's preferred pronouns |
| fields | ?[field](./#field)[] | the member's term fields. Not returned in member list endpoints. |
| flags | [flag](./#pride-flag)[] | the member's pride flags |
| user | partial [user](./members#partial-user-object) object | the user associated with this member |
| unlisted | ?bool | _only returned for your own members_, whether the member is shown in member lists |
## Partial user object
| Field | Type | Description |
| ------------------ | ---------------------------------------------------- | -------------------------------------- |
| id | string | the user's unique ID |
| id_new | snowflake | the user's unique snowflake ID |
| name | string | the user's username |
| display_name | string? | the user's display name or nickname |
| avatar | string? | the user's [avatar hash](/api/#images) |
| custom_preferences | map\[uuid\][custom_preference](./#custom-preference) | the user's custom preferences |
## Endpoints
### Get member
#### `GET /members/{member.id}`
Gets a member by their ID. Returns a [member](./members#member-object) object.
If authenticated and the authenticated user is the owner of the requested member,
also returns the `unlisted` field.
### Get user member
#### `GET /users/{user.id}/members/{member.id} | GET /users/{user.name}/members/{member.name}`
Gets a member by their ID or name. Returns a [member](./members#member-object) object.
If authenticated and the authenticated user is the owner of the requested member,
also returns the `unlisted` field.
### Get user members
#### `GET /users/{user.id}/members | GET /users/{user.name}/members`
Get a user's members. Returns an array of [member](./members#member-object) objects.
### Get current user member
#### `GET /users/@me/members/{member.id} | GET /users/@me/members/{member.name}`
**Requires authentication.** Get one of the currently authenticated user's members by ID or name.
Returns a [member](./members#member-object) object.
### Get current user members
#### `GET /users/@me/members`
**Requires authentication.** Get the currently authenticated user's members.
Returns an array of [member](./members#member-object) objects.
### Create member
#### `POST /members`
**Requires authentication**. Creates a new member.
Returns the newly created [member](./members#member-object) on success.
#### Request body parameters
| Field | Type | Description |
| ------------ | --------------- | --------------------------------------------------------------------------------------------------- |
| name | string | the new member's name. Must be unique per user, and be between 1 and 100 characters. **Required** |
| display_name | string? | the new member's display name. Must be between 1 and 100 characters |
| bio | string? | the new member's bio. Must be between 1 and 1000 characters |
| avatar | string | the new member's avatar. This must be a PNG, JPEG, or WebP image, encoded in base64 data URI format |
| links | string[] | the new member's profile links. Maximum 25 links, and links must be between 1 and 256 characters |
| names | field_entry[] | the new member's preferred names |
| pronouns | pronoun_entry[] | the new member's preferred pronouns |
| fields | field[] | the new member's profile fields |
### Update member
#### `PATCH /members/{member.id}`
**Requires authentication.** Updates the given member.
Returns the updated [member](./members#member-object) on success.
#### Request body parameters
| Field | Type | Description |
| ------------ | --------------- | ------------------------------------------------------------------------------------------------------ |
| name | string | the member's new name. Must be unique per user, and be between 1 and 100 characters. |
| display_name | string | the member's new display name. Must be between 1 and 100 characters |
| bio | string | the member's new bio. Must be between 1 and 1000 characters |
| links | string[] | the member's new profile links. Maximum 25 links, and links must be between 1 and 256 characters |
| names | field_entry[] | the member's new preferred names |
| pronouns | pronoun_entry[] | the member's new preferred pronouns |
| fields | field[] | the member's new profile fields |
| flags | string[] | the member's new flags. This must be an array of [pride flag](./#pride-flag) IDs, _not_ snowflake IDs. |
| avatar | string | the member's new avatar. This must be a PNG, JPEG, or WebP image, encoded in base64 data URI format |
| unlisted | bool | whether or not the member should be hidden from the member list |
### Delete member
#### `DELETE /members/{member.id}`
**Requires authentication.** Deletes the given member. Returns `204 No Content` on success.
### Reroll short ID
#### `GET /members/{member.id}/reroll`
**Requires authentication.** Rerolls the member's short ID.
Returns the updated [member](./members#member-object) on success.
If the user has already rerolled a short ID in the past hour, returns `403 Forbidden`.

View file

@ -0,0 +1,46 @@
# Other endpoints
There are some endpoints that are neither user or member related:
### Get statistics
#### `GET /meta`
Get aggregate statistics for pronouns.cc.
Note: a user is considered active if they have updated their profile, created a member, deleted a member,
or updated a member's profile in the given time period.
#### Response body
| Field | Type | Description |
| -------------- | ----------------- | ------------------------------------------------------------------------- |
| git_repository | string | link to the project's Git repository |
| git_commit | string | the commit the backend is built from |
| users | user count object | the total number of users |
| members | int | the total number of non-hidden members |
| require_invite | bool | whether invites are required to sign up. _Always `false` for pronouns.cc_ |
#### User count object
| Field | Type | Description |
| ------------ | ---- | ------------------------------------------- |
| total | int | total number of users |
| active_month | int | number of users active in the last month |
| active_week | int | number of users active in the last week |
| active_day | int | number of users active in the last 24 hours |
### Get warnings
#### `GET /auth/warnings`
**Requires authentication.** Returns an array of warnings the currently authenticated user has.
Add `?all=true` query parameter to return all warnings, not just unread ones.
#### Response body
| Field | Type | Description |
| ---------- | -------- | ---------------------------------------------- |
| id | int | the warning ID |
| reason | string | the reason for the warning |
| created_at | datetime | when the warning was created |
| read | bool | whether the warning has been read/acknowledged |

145
docs/api/endpoints/users.md Normal file
View file

@ -0,0 +1,145 @@
# User endpoints
## User object
| Field | Type | Description |
| ------------------ | ---------------------------------------------------- | --------------------------------------------------------------------------- |
| id | string | the user's unique ID |
| id_new | snowflake | the user's unique snowflake ID |
| sid | string | the user's 5 letter short ID |
| name | string | the user's username |
| display_name | string? | the user's display name or nickname |
| bio | string? | the user's description or bio |
| member_title | string? | the heading used for the user's member list. If null, defaults to "Members" |
| avatar | string? | the user's [avatar hash](/api/#images) |
| links | string[] | the user's profile links |
| names | [field_entry](./#field-entry)[] | the user's preferred names |
| pronouns | [pronoun_entry](./#pronoun-entry)[] | the user's preferred pronouns |
| fields | [field](./#field)[] | the user's term fields |
| flags | [flag](./#pride-flag)[] | the user's pride flags |
| members | [partial](./users#partial-member-object) member[] | the user's non-hidden members |
| badges | int | the user's badges, represented as a bitmask field |
| utc_offset | int? | the user's current offset from UTC, in seconds |
| custom_preferences | map\[uuid\][custom_preference](./#custom-preference) | the user's custom preferences |
### Additional fields for the currently authenticated user {#additional-user-fields}
| Field | Type | Description |
| ------------------ | -------- | ------------------------------------------------ |
| created_at | datetime | the user's creation date and time |
| timezone | string? | the user's timezone in IANA timezone format |
| is_admin | bool | whether or not the user is an administrator |
| list_private | bool | whether or not the user's member list is private |
| last_sid_reroll | datetime | the last time the user rerolled a short ID |
| discord | string? | the user's Discord ID |
| discord_username | string? | the user's Discord username |
| tumblr | string? | the user's Tumblr ID |
| tumblr_username | string? | the user's Tumblr username |
| google | string? | the user's Google ID |
| google_username | string? | the user's Google username |
| fediverse | string? | the user's fediverse user ID |
| fediverse_username | string? | the user's fediverse username, without instance |
| fediverse_instance | string? | the user's fediverse instance |
## Partial member object
| Field | Type | Description |
| ------------ | ----------------------------------- | ---------------------------------------- |
| id | string | the member's unique ID |
| id_new | snowflake | the member's unique snowflake ID |
| sid | string | the member's 6-letter short ID |
| name | string | the member's name |
| display_name | string? | the member's display name or nickname |
| bio | string? | the member's description |
| avatar | string? | the member's [avatar hash](/api/#images) |
| links | string[] | the member's profile links |
| names | [field_entry](./#field-entry)[] | the member's preferred names |
| pronouns | [pronoun_entry](./#pronoun-entry)[] | the member's preferred pronouns |
## Endpoints
### Get user
#### `GET /users/{user.id} | GET /users/{user.name}`
Gets a user by their ID or username. Returns a [user](./users#user-object) object.
If authenticated and the authenticated user is the requested user, also returns the [additional user fields](./users#additional-user-fields).
### Get current user
#### `GET /users/@me`
**Requires authentication.** Gets the currently authenticated [user](./users#user-object),
with all [additional user fields](./users#additional-user-fields).
### Update current user
#### `PATCH /users/@me`
**Requires authentication.** Updates the currently authenticated user.
Returns the updated [user](./users#user-object) object on success.
#### Request body parameters
| Field | Type | Description |
| ------------------ | -------------------- | ------------------------------------------------------------------------------------------------------------- |
| name | string | the user's new name. Must be between 2 and 40 characters and only consist of ASCII letters, `_`, `.`, and `-` |
| display_name | string | the user's new display name. Must be between 1 and 100 characters |
| bio | string | the user's new bio. Must be between 1 and 1000 characters |
| member_title | string | the user's new member title. Must be between 1 and 150 characters |
| links | string[] | the user's new profile links. Maximum 25 links, and links must be between 1 and 256 characters |
| names | field_entry[] | the user's new preferred names |
| pronouns | pronoun_entry[] | the user's new preferred pronouns |
| fields | field[] | the user's new profile fields |
| flags | string[] | the user's new flags. This must be an array of [pride flag](./#pride-flag) IDs, _not_ snowflake IDs. |
| avatar | string | the user's new avatar. This must be a PNG, JPEG, or WebP image, encoded in base64 data URI format |
| timezone | string | the user's new timezone. Must be in IANA timezone database format |
| list_private | bool | whether or not the user's member list should be hidden |
| custom_preferences | _custom preferences_ | the user's new custom preferences |
### Get pride flags
#### `GET /users/@me/flags`
**Requires authentication.** Returns an array of the currently authenticated user's [pride flags](./#pride-flag).
### Create pride flag
#### `POST /users/@me/flags`
**Requires authentication.** Creates a new pride flag. Returns a [pride flag](./#pride-flag) object on success.
#### Request body parameters
| Field | Type | Description |
| ----------- | ------ | -------------------------------------------------------------------------------------------------------- |
| flag | string | the flag image. This must be a PNG, JPEG, or WebP image, encoded in base64 data URI format. **Required** |
| name | string | the flag name. Must be between 1 and 100 characters. **Required** |
| description | string | the flag description or alt text. |
### Edit pride flag
#### `PATCH /users/@me/flags/{flag.id}`
**Requires authentication.** Edits an existing pride flag.
Returns the updated [pride flag](./#pride-flag) object on success.
#### Request body parameters
| Field | Type | Description |
| ----------- | ------ | ---------------------------------------------------------------- |
| name | string | the flag's new name. Must be between 1 and 100 characters |
| description | string | the flag's new description. Must be between 1 and 500 characters |
### Delete pride flag
#### `DELETE /users/@me/flags/{flag.id}`
**Requires authentication.** Deletes an existing pride flag. Returns `204 No Content` on success.
### Reroll short ID
#### `GET /users/@me/reroll`
**Requires authentication.** Rerolls the user's short ID. Returns the updated [user](./users#user-object) on success.
If the user has already rerolled a short ID in the past hour, returns `403 Forbidden`.

34
docs/api/errors.md Normal file
View file

@ -0,0 +1,34 @@
# Error messages
If there is an error in your request, or the server encounters an error while processing it, an error object will be returned.
| Field | Type | Description |
| --------------- | ------- | ------------------------------------------------------------------- |
| code | int | an [error code](./errors#error-codes) |
| id | ?string | an opaque Sentry event ID, only returned for internal server errors |
| message | ?string | a human-readable description of the error |
| details | ?string | more details about the error, most often for bad request errors |
| ratelimit_reset | ?int | the unix time when an expired rate limit will reset |
### Error codes
| Code | Description |
| ---- | ----------------------------------------------------------------------------------- |
| 400 | One or more fields in your requests was invalid, or some required field is missing. |
| 403 | You are not authorized to use this endpoint. |
| 404 | The endpoint was not found. |
| 405 | The method you are trying to use is not suported for this endpoint. |
| 429 | You have made too many requests in the last minute. |
| 500 | An internal server error occurred. |
| 1006 | That username is invalid. |
| 1007 | That username is already taken. |
| 2001 | User not found. |
| 2002 | This user's member list is private. |
| 2003 | You have reached the maximum number of pride flags. |
| 2004 | You are trying to reroll short IDs too quickly. |
| 3001 | Member not found. |
| 3002 | You have reached the maximum number of members. |
| 3003 | That member name is already in use. |
| 3004 | You can only edit your own members. |
| 4001 | Your request is too big (maximum 2 megabytes) |
| 4002 | This endpoint is unavailable to your account or the current token. |

95
docs/api/index.md Normal file
View file

@ -0,0 +1,95 @@
# API reference
pronouns.cc has a HTTP REST API to query and edit profiles, available at `https://pronouns.cc/api`.
## Versioning
The API is versioned, and versions must be explicitly specified for all endpoints.
The current, and only, available version is **1**.
The version is specified in the request path, like `https://pronouns.cc/api/v{version}`.
| Version | Status |
| ------- | ---------- |
| 1 | Default |
| 2 | _Upcoming_ |
The API version will be incremented for any breaking changes, including:
- Removing entire endpoints
- Removing fields from responses
- Changing the behaviour of fields (in some situations, see below)
However, the following types of changes are **not** considered breaking:
- Adding new endpoints
- Adding new fields to requests or responses (your JSON serializer/deserializer should ignore unknown fields)
- Forcing fields related to removed features to their default value
## Authentication
Tokens can be created [here](https://pronouns.cc/settings/tokens).
Not all endpoints require authentication. For those that do, a token must be provided in the `Authorization` header.
The token _may_ be prefixed with `Bearer `, but this is not required.
::: info
You are allowed to use site tokens (those stored in your browser's local storage) to access endpoints not available to API tokens,
however, these endpoints are not available to API tokens *for a reason*:
site tokens can take destructive actions such as deleting your account.
Additionally, endpoints that are not available to API tokens may have breaking changes without a major version bump.
:::
## Request bodies
::: info
The current API version doesn't distinguish between omitted and `null` keys yet.
However, the next version of the API will use `null` to unset keys, so clients should not rely on this behaviour.
:::
Request bodies should be in JSON format.
For PATCH requests, **all keys are optional**. Omitted keys will not be updated,
and keys set to the zero value of their respective types (for strings: `""`, for numbers: `0`, for arrays: `[]`, etc.)
will be unset.
## Documentation formatting
The "type" column in tables is formatted as follows:
- The type used is the _Go_ type, not the _JSON_ type.
For example, the documentation will use `int` for integers and `float` for floats,
even though they are both represented with JSON numbers.
- A _leading_ `?` signifies that the field may be omitted.
- A _trailing_ `?` signifies that the field may be null.
## IDs
### Snowflake IDs
For [multiple reasons](https://codeberg.org/pronounscc/pronouns.cc/issues/89),
pronouns.cc is transitioning to using snowflakes for unique IDs. These will become the default in the next API version,
but are already returned as `id_new` in the relevant objects (users, members, and flags).
### xids
[xid](https://github.com/rs/xid) is the previous unique ID format. These are always serialized as strings.
Although xids have timestamp information embedded in them, this is non-trivial to extract.
xids are unique across _all_ resources, they are never shared (for example, a user and a member cannot share the same ID).
### prns.cc IDs
Users and members also have an additional ID type, `sid`.
These are randomly generated 5 or 6 letter strings, and are used for the prns.cc URL shortener.
**These can change at any time**, as short IDs can be rerolled once per hour.
## Images
The API does not return full URLs to images such as avatars and pride flags.
Instead, the URL must be constructed manually using the `avatar` or `hash` fields.
The default user and member avatar is served at `https://pronouns.cc/default/512.webp`.
All custom images are served on the base URL `https://cdn.pronouns.cc`, and are only available in WebP format.
| Type | Format |
| ------------- | ------------------------------------------- |
| User avatar | `/users/{user.id}/{user.avatar}.webp` |
| Member avatar | `/members/{member.id}/{member.avatar}.webp` |
| Pride flag | `/flags/{flag.hash}.webp` |

31
docs/api/ratelimits.md Normal file
View file

@ -0,0 +1,31 @@
# Rate limits
The API has rate limits, generally separated by groups of endpoints.
If you exceed a rate limit, the API will start to return 429 errors.
## Headers
- `X-RateLimit-Bucket`: the bucket the rate limit is for (listed below)
- `X-RateLimit-Limit`: the total number of requests you can make per minute
- `X-RateLimit-Remaining`: the number of requests remaining in the current timeframe
- `X-RateLimit-Reset`: the unix timestamp that the number of requests resets at
- `Retry-After`: only if you hit a rate limit, the number of seconds until you can make requests again
## Buckets
Note that only the most specific matching bucket is used for rate limits.
| Bucket | Rate limit per minute | Notes |
| ------------------------ | --------------------- | ----------------------------------------------------------- |
| / | 120 | Used as fallback if no other bucket exists for the endpoint |
| GET /users/\* | 60 | |
| GET /users/\*/members | 60 | |
| GET /users/\*/members/\* | 60 | |
| PATCH /users/@me | 10 | |
| POST /members | 10 | |
| GET /members/\* | 60 | |
| PATCH /members/\* | 20 | |
| DELETE /members/\* | 5 | |
| /auth/\* | 20 | |
| /auth/tokens | 10 | |
| /auth/invites | 10 | |

View file

@ -0,0 +1,12 @@
http://pronouns.local {
handle /media* {
uri path_regexp ^/media /pronouns.cc
reverse_proxy localhost:9000
}
handle_path /api* {
reverse_proxy localhost:8080
}
reverse_proxy localhost:5173
}

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