Compare commits

...

204 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
2a4195ac03
start rust exporter 2023-04-09 23:27:40 +02:00
263 changed files with 19354 additions and 5964 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 package
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.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 .PHONY: backend
backend: backend:
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 .PHONY: generate
generate: generate:

View file

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

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,23 +6,20 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"image"
_ "image/gif" _ "image/gif"
"image/jpeg"
_ "image/png" _ "image/png"
"io" "io"
"strings" "strings"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/disintegration/imaging" "github.com/davidbyttow/govips/v2/vips"
"github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7"
"github.com/rs/xid" "github.com/rs/xid"
"github.com/chai2010/webp"
) )
const ErrInvalidDataURI = errors.Sentinel("invalid data URI") const ErrInvalidDataURI = errors.Sentinel("invalid data URI")
const ErrInvalidContentType = errors.Sentinel("invalid avatar content type") 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. // ConvertAvatar parses an avatar from a data URI, converts it to WebP and JPEG, and returns the results.
func (db *DB) ConvertAvatar(data string) ( func (db *DB) ConvertAvatar(data string) (
@ -30,6 +27,8 @@ func (db *DB) ConvertAvatar(data string) (
jpgOut *bytes.Buffer, jpgOut *bytes.Buffer,
err error, err error,
) { ) {
defer vips.ShutdownThread()
data = strings.TrimSpace(data) data = strings.TrimSpace(data)
if !strings.Contains(data, ",") || !strings.Contains(data, ":") || !strings.Contains(data, ";") { if !strings.Contains(data, ",") || !strings.Contains(data, ":") || !strings.Contains(data, ";") {
return nil, nil, ErrInvalidDataURI return nil, nil, ErrInvalidDataURI
@ -41,28 +40,31 @@ func (db *DB) ConvertAvatar(data string) (
return nil, nil, errors.Wrap(err, "invalid base64 data") return nil, nil, errors.Wrap(err, "invalid base64 data")
} }
img, _, err := image.Decode(bytes.NewReader(rawData)) image, err := vips.LoadImageFromBuffer(rawData, nil)
if err != nil { if err != nil {
return nil, nil, errors.Wrap(err, "decodign image") return nil, nil, errors.Wrap(err, "decoding image")
} }
resized := imaging.Fill(img, 512, 512, imaging.Center, imaging.Linear) err = image.ThumbnailWithSize(512, 512, vips.InterestingCentre, vips.SizeBoth)
webpOut = new(bytes.Buffer)
err = webp.Encode(webpOut, resized, &webp.Options{
Quality: 90,
})
if err != nil { if err != nil {
return nil, nil, errors.Wrap(err, "encoding WebP image") return nil, nil, errors.Wrap(err, "resizing image")
} }
jpgOut = new(bytes.Buffer) webpExport := vips.NewWebpExportParams()
err = jpeg.Encode(jpgOut, resized, &jpeg.Options{ webpExport.Quality = 90
Quality: 80, webpB, _, err := image.ExportWebp(webpExport)
})
if err != nil { if err != nil {
return nil, nil, errors.Wrap(err, "encoding JPEG image") return nil, nil, errors.Wrap(err, "exporting webp image")
} }
webpOut = bytes.NewBuffer(webpB)
jpegExport := vips.NewJpegExportParams()
jpegExport.Quality = 80
jpegB, _, err := image.ExportJpeg(jpegExport)
if err != nil {
return nil, nil, errors.Wrap(err, "exporting jpeg image")
}
jpgOut = bytes.NewBuffer(jpegB)
return webpOut, jpgOut, nil return webpOut, jpgOut, nil
} }
@ -79,15 +81,17 @@ func (db *DB) WriteUserAvatar(ctx context.Context,
} }
hash = hex.EncodeToString(hasher.Sum(nil)) hash = hex.EncodeToString(hasher.Sum(nil))
_, err = db.minio.PutObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{ _, err = db.minio.PutObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{
ContentType: "image/webp", ContentType: "image/webp",
SendContentMd5: true,
}) })
if err != nil { if err != nil {
return "", errors.Wrap(err, "uploading webp avatar") return "", errors.Wrap(err, "uploading webp avatar")
} }
_, err = db.minio.PutObject(ctx, db.minioBucket, "/users/"+userID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{ _, err = db.minio.PutObject(ctx, db.minioBucket, "users/"+userID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{
ContentType: "image/jpeg", ContentType: "image/jpeg",
SendContentMd5: true,
}) })
if err != nil { if err != nil {
return "", errors.Wrap(err, "uploading jpeg avatar") return "", errors.Wrap(err, "uploading jpeg avatar")
@ -108,15 +112,17 @@ func (db *DB) WriteMemberAvatar(ctx context.Context,
} }
hash = hex.EncodeToString(hasher.Sum(nil)) hash = hex.EncodeToString(hasher.Sum(nil))
_, err = db.minio.PutObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{ _, err = db.minio.PutObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".webp", webp, -1, minio.PutObjectOptions{
ContentType: "image/webp", ContentType: "image/webp",
SendContentMd5: true,
}) })
if err != nil { if err != nil {
return "", errors.Wrap(err, "uploading webp avatar") return "", errors.Wrap(err, "uploading webp avatar")
} }
_, err = db.minio.PutObject(ctx, db.minioBucket, "/members/"+memberID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{ _, err = db.minio.PutObject(ctx, db.minioBucket, "members/"+memberID.String()+"/"+hash+".jpg", jpeg, -1, minio.PutObjectOptions{
ContentType: "image/jpeg", ContentType: "image/jpeg",
SendContentMd5: true,
}) })
if err != nil { if err != nil {
return "", errors.Wrap(err, "uploading jpeg avatar") return "", errors.Wrap(err, "uploading jpeg avatar")
@ -126,12 +132,12 @@ func (db *DB) WriteMemberAvatar(ctx context.Context,
} }
func (db *DB) DeleteUserAvatar(ctx context.Context, userID xid.ID, hash string) error { 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 { if err != nil {
return errors.Wrap(err, "deleting webp avatar") 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 { if err != nil {
return errors.Wrap(err, "deleting jpeg avatar") return errors.Wrap(err, "deleting jpeg avatar")
} }
@ -140,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 { 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 { if err != nil {
return errors.Wrap(err, "deleting webp avatar") 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 { if err != nil {
return errors.Wrap(err, "deleting jpeg avatar") return errors.Wrap(err, "deleting jpeg avatar")
} }
@ -154,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) { 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 { if err != nil {
return nil, errors.Wrap(err, "getting object") return nil, errors.Wrap(err, "getting object")
} }
@ -162,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) { 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 { if err != nil {
return nil, errors.Wrap(err, "getting object") return nil, errors.Wrap(err, "getting object")
} }

View file

@ -6,8 +6,9 @@ import (
"fmt" "fmt"
"net/url" "net/url"
"os" "os"
"sync"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/Masterminds/squirrel" "github.com/Masterminds/squirrel"
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
@ -22,6 +23,11 @@ var sq = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
const ErrNothingToUpdate = errors.Sentinel("nothing to update") const ErrNothingToUpdate = errors.Sentinel("nothing to update")
const (
uniqueViolation = "23505"
foreignKeyViolation = "23503"
)
type Execer interface { type Execer interface {
Exec(ctx context.Context, sql string, arguments ...interface{}) (commandTag pgconn.CommandTag, err error) Exec(ctx context.Context, sql string, arguments ...interface{}) (commandTag pgconn.CommandTag, err error)
} }
@ -36,6 +42,10 @@ type DB struct {
baseURL *url.URL baseURL *url.URL
TotalRequests prometheus.Counter TotalRequests prometheus.Counter
activeUsersDay, activeUsersWeek, activeUsersMonth int64
usersTotal, membersTotal int64
countMu sync.RWMutex
} }
func New() (*DB, error) { func New() (*DB, error) {

View file

@ -7,15 +7,6 @@ import (
type WordStatus string type WordStatus string
const (
StatusUnknown WordStatus = ""
StatusFavourite WordStatus = "favourite"
StatusOkay WordStatus = "okay"
StatusJokingly WordStatus = "jokingly"
StatusFriendsOnly WordStatus = "friends_only"
StatusAvoid WordStatus = "avoid"
)
func (w *WordStatus) UnmarshalJSON(src []byte) error { func (w *WordStatus) UnmarshalJSON(src []byte) error {
if string(src) == "null" { if string(src) == "null" {
return nil return nil
@ -41,7 +32,7 @@ func (w *WordStatus) UnmarshalJSON(src []byte) error {
} }
func (w WordStatus) Valid(extra CustomPreferences) bool { func (w WordStatus) Valid(extra CustomPreferences) bool {
if w == StatusFavourite || w == StatusOkay || w == StatusJokingly || w == StatusFriendsOnly || w == StatusAvoid { if w == "favourite" || w == "okay" || w == "jokingly" || w == "friends_only" || w == "avoid" {
return true return true
} }

View file

@ -20,7 +20,7 @@ type DataExport struct {
} }
func (de DataExport) Path() string { 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") const ErrNoExport = errors.Sentinel("no data export exists")
@ -68,6 +68,7 @@ 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{ _, 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 { if err != nil {
return de, errors.Wrap(err, "writing export file") 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") return de, errors.Wrap(err, "building query")
} }
pgxscan.Get(ctx, db, &de, sql, args...) err = pgxscan.Get(ctx, db, &de, sql, args...)
if err != nil { if err != nil {
return de, errors.Wrap(err, "executing sql") return de, errors.Wrap(err, "executing sql")
} }

View file

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

View file

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

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

View file

@ -3,8 +3,13 @@ package db
import ( import (
"context" "context"
"regexp" "regexp"
"strings"
"time"
"codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/Masterminds/squirrel"
"github.com/georgysavva/scany/v2/pgxscan" "github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
@ -19,6 +24,8 @@ const (
type Member struct { type Member struct {
ID xid.ID ID xid.ID
UserID xid.ID UserID xid.ID
SnowflakeID common.MemberID
SID string `db:"sid"`
Name string Name string
DisplayName *string DisplayName *string
Bio *string Bio *string
@ -35,9 +42,24 @@ const (
) )
// member names must match this regex // member names must match this regex
var memberNameRegex = regexp.MustCompile("^[^@\\?!#/\\\\[\\]\"'$%&()+<=>^|~`,]{1,100}$") var memberNameRegex = regexp.MustCompile("^[^@\\?!#/\\\\[\\]\"\\{\\}'$%&()+<=>^|~`,\\*]{1,100}$")
// List of member names that cannot be used because they would break routing or be inaccessible due to page conflicts.
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 { func MemberNameValid(name string) bool {
for i := range invalidMemberNames {
if strings.EqualFold(name, invalidMemberNames[i]) {
return false
}
}
return memberNameRegex.MatchString(name) return memberNameRegex.MatchString(name)
} }
@ -54,9 +76,8 @@ func (db *DB) Member(ctx context.Context, id xid.ID) (m Member, err error) {
return m, nil return m, nil
} }
// UserMember returns a member scoped by user. func (db *DB) MemberBySnowflake(ctx context.Context, id common.MemberID) (m Member, err error) {
func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) (m Member, err error) { sql, args, err := sq.Select("*").From("members").Where("snowflake_id = ?", id).ToSql()
sql, args, err := sq.Select("*").From("members").Where("user_id = ?", userID).Where("(id = ? or name = ?)", memberRef, memberRef).ToSql()
if err != nil { if err != nil {
return m, errors.Wrap(err, "building sql") return m, errors.Wrap(err, "building sql")
} }
@ -68,6 +89,40 @@ func (db *DB) UserMember(ctx context.Context, userID xid.ID, memberRef string) (
return m, nil 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. // UserMembers returns all of a user's members, sorted by name.
func (db *DB) UserMembers(ctx context.Context, userID xid.ID, showHidden bool) (ms []Member, err error) { func (db *DB) UserMembers(ctx context.Context, userID xid.ID, showHidden bool) (ms []Member, err error) {
builder := sq.Select("*"). builder := sq.Select("*").
@ -99,8 +154,8 @@ func (db *DB) CreateMember(
name string, displayName *string, bio string, links []string, name string, displayName *string, bio string, links []string,
) (m Member, err error) { ) (m Member, err error) {
sql, args, err := sq.Insert("members"). sql, args, err := sq.Insert("members").
Columns("user_id", "id", "name", "display_name", "bio", "links"). Columns("user_id", "snowflake_id", "id", "sid", "name", "display_name", "bio", "links").
Values(userID, xid.New(), name, displayName, bio, links). Values(userID, common.GenerateID(), xid.New(), squirrel.Expr("find_free_member_sid()"), name, displayName, bio, links).
Suffix("RETURNING *").ToSql() Suffix("RETURNING *").ToSql()
if err != nil { if err != nil {
return m, errors.Wrap(err, "building sql") return m, errors.Wrap(err, "building sql")
@ -111,7 +166,7 @@ func (db *DB) CreateMember(
pge := &pgconn.PgError{} pge := &pgconn.PgError{}
if errors.As(err, &pge) { if errors.As(err, &pge) {
// unique constraint violation // unique constraint violation
if pge.Code == "23505" { if pge.Code == uniqueViolation {
return m, ErrMemberNameInUse return m, ErrMemberNameInUse
} }
} }
@ -218,7 +273,7 @@ func (db *DB) UpdateMember(
if err != nil { if err != nil {
pge := &pgconn.PgError{} pge := &pgconn.PgError{}
if errors.As(err, &pge) { if errors.As(err, &pge) {
if pge.Code == "23505" { if pge.Code == uniqueViolation {
return m, ErrMemberNameInUse return m, ErrMemberNameInUse
} }
} }
@ -227,3 +282,48 @@ func (db *DB) UpdateMember(
} }
return m, nil 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
}

View file

@ -2,11 +2,14 @@ package db
import ( import (
"context" "context"
"time"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/jackc/pgx/v5/pgconn"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto" "github.com/prometheus/client_golang/prometheus/promauto"
"github.com/rs/xid"
) )
func (db *DB) initMetrics() (err error) { func (db *DB) initMetrics() (err error) {
@ -18,6 +21,11 @@ func (db *DB) initMetrics() (err error) {
if err != nil { if err != nil {
log.Errorf("getting user count for metrics: %v", err) log.Errorf("getting user count for metrics: %v", err)
} }
db.countMu.Lock()
db.usersTotal = count
db.countMu.Unlock()
return float64(count) return float64(count)
})) }))
if err != nil { if err != nil {
@ -32,12 +40,90 @@ func (db *DB) initMetrics() (err error) {
if err != nil { if err != nil {
log.Errorf("getting member count for metrics: %v", err) log.Errorf("getting member count for metrics: %v", err)
} }
db.countMu.Lock()
db.membersTotal = count
db.countMu.Unlock()
return float64(count) return float64(count)
})) }))
if err != nil { if err != nil {
return errors.Wrap(err, "registering member count gauge") 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{ db.TotalRequests = promauto.NewCounter(prometheus.CounterOpts{
Name: "pronouns_api_requests_total", Name: "pronouns_api_requests_total",
Help: "The total number of API requests since the last restart", Help: "The total number of API requests since the last restart",
@ -46,6 +132,22 @@ func (db *DB) initMetrics() (err error) {
return nil 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) { 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) err = db.QueryRow(ctx, "SELECT COUNT(*) FROM users WHERE deleted_at IS NULL").Scan(&numUsers)
if err != nil { if err != nil {
@ -61,3 +163,36 @@ func (db *DB) TotalMemberCount(ctx context.Context) (numMembers int64, err error
} }
return numMembers, nil 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
}

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

@ -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) { 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 { if before != 0 {
builder = builder.Where("id < ?", before) 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) { 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 { if before != 0 {
builder = builder.Where("id < ?", before) builder = builder.Where("id < ?", before)
} }

View file

@ -6,11 +6,13 @@ import (
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"regexp" "regexp"
"strings"
"time" "time"
"codeberg.org/u1f320/pronouns.cc/backend/common" "codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/u1f320/pronouns.cc/backend/icons" "codeberg.org/pronounscc/pronouns.cc/backend/icons"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/Masterminds/squirrel"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/georgysavva/scany/v2/pgxscan" "github.com/georgysavva/scany/v2/pgxscan"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
@ -20,10 +22,13 @@ import (
type User struct { type User struct {
ID xid.ID ID xid.ID
SnowflakeID common.UserID
SID string `db:"sid"`
Username string Username string
DisplayName *string DisplayName *string
Bio *string Bio *string
MemberTitle *string MemberTitle *string
LastActive time.Time
Avatar *string Avatar *string
Links []string Links []string
@ -48,6 +53,9 @@ type User struct {
MaxInvites int MaxInvites int
IsAdmin bool IsAdmin bool
ListPrivate bool ListPrivate bool
LastSIDReroll time.Time `db:"last_sid_reroll"`
Timezone *string
Settings UserSettings
DeletedAt *time.Time DeletedAt *time.Time
SelfDelete *bool SelfDelete *bool
@ -109,9 +117,65 @@ func (u User) NumProviders() (numProviders int) {
return 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 // usernames must match this regex
var usernameRegex = regexp.MustCompile(`^[\w-.]{2,40}$`) 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 ( const (
ErrUserNotFound = errors.Sentinel("user not found") ErrUserNotFound = errors.Sentinel("user not found")
@ -119,6 +183,7 @@ const (
ErrInvalidUsername = errors.Sentinel("username contains invalid characters") ErrInvalidUsername = errors.Sentinel("username contains invalid characters")
ErrUsernameTooShort = errors.Sentinel("username is too short") ErrUsernameTooShort = errors.Sentinel("username is too short")
ErrUsernameTooLong = errors.Sentinel("username is too long") ErrUsernameTooLong = errors.Sentinel("username is too long")
ErrBannedUsername = errors.Sentinel("username is banned")
) )
const ( const (
@ -138,17 +203,11 @@ const (
func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u User, err error) { func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u User, err error) {
// check if the username is valid // check if the username is valid
// if not, return an error depending on what failed // if not, return an error depending on what failed
if !usernameRegex.MatchString(username) { if err := UsernameValid(username); err != nil {
if len(username) < 2 { return u, err
return u, ErrUsernameTooShort
} else if len(username) > 40 {
return u, ErrUsernameTooLong
} }
return u, ErrInvalidUsername 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()
}
sql, args, err := sq.Insert("users").Columns("id", "username").Values(xid.New(), username).Suffix("RETURNING *").ToSql()
if err != nil { if err != nil {
return u, errors.Wrap(err, "building sql") return u, errors.Wrap(err, "building sql")
} }
@ -158,7 +217,7 @@ func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u Use
pge := &pgconn.PgError{} pge := &pgconn.PgError{}
if errors.As(err, &pge) { if errors.As(err, &pge) {
// unique constraint violation // unique constraint violation
if pge.Code == "23505" { if pge.Code == uniqueViolation {
return u, ErrUsernameTaken return u, ErrUsernameTaken
} }
} }
@ -436,6 +495,26 @@ func (db *DB) User(ctx context.Context, id xid.ID) (u User, err error) {
return u, nil return u, nil
} }
// UserBySnowflake gets a user by their snowflake ID.
func (db *DB) UserBySnowflake(ctx context.Context, id common.UserID) (u User, err error) {
sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
From("users").Where("snowflake_id = ?", id).ToSql()
if err != nil {
return u, errors.Wrap(err, "building sql")
}
err = pgxscan.Get(ctx, db, &u, sql, args...)
if err != nil {
if errors.Cause(err) == pgx.ErrNoRows {
return u, ErrUserNotFound
}
return u, errors.Wrap(err, "getting user from db")
}
return u, nil
}
// Username gets a user by username. // Username gets a user by username.
func (db *DB) Username(ctx context.Context, name string) (u User, err error) { func (db *DB) Username(ctx context.Context, name string) (u User, err error) {
sql, args, err := sq.Select("*").From("users").Where("username = ?", name).ToSql() sql, args, err := sq.Select("*").From("users").Where("username = ?", name).ToSql()
@ -455,9 +534,28 @@ func (db *DB) Username(ctx context.Context, name string) (u User, err error) {
return u, nil 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. // UsernameTaken checks if the given username is already taken.
func (db *DB) UsernameTaken(ctx context.Context, username string) (valid, taken bool, err error) { 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 return false, false, nil
} }
@ -467,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. // 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 { func (db *DB) UpdateUsername(ctx context.Context, tx pgx.Tx, id xid.ID, newName string) error {
if !usernameRegex.MatchString(newName) { if err := UsernameValid(newName); err != nil {
return ErrInvalidUsername return err
} }
sql, args, err := sq.Update("users").Set("username", newName).Where("id = ?", id).ToSql() sql, args, err := sq.Update("users").Set("username", newName).Where("id = ?", id).ToSql()
@ -481,7 +579,7 @@ func (db *DB) UpdateUsername(ctx context.Context, tx pgx.Tx, id xid.ID, newName
pge := &pgconn.PgError{} pge := &pgconn.PgError{}
if errors.As(err, &pge) { if errors.As(err, &pge) {
// unique constraint violation // unique constraint violation
if pge.Code == "23505" { if pge.Code == uniqueViolation {
return ErrUsernameTaken return ErrUsernameTaken
} }
} }
@ -498,9 +596,10 @@ func (db *DB) UpdateUser(
memberTitle *string, listPrivate *bool, memberTitle *string, listPrivate *bool,
links *[]string, links *[]string,
avatar *string, avatar *string,
timezone *string,
customPreferences *CustomPreferences, customPreferences *CustomPreferences,
) (u User, err error) { ) (u User, err error) {
if displayName == nil && bio == nil && links == nil && avatar == nil && memberTitle == nil && listPrivate == nil && customPreferences == 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() sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql()
if err != nil { if err != nil {
return u, errors.Wrap(err, "building sql") return u, errors.Wrap(err, "building sql")
@ -536,6 +635,13 @@ func (db *DB) UpdateUser(
builder = builder.Set("member_title", *memberTitle) 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 { if links != nil {
builder = builder.Set("links", *links) builder = builder.Set("links", *links)
} }
@ -583,6 +689,23 @@ func (db *DB) DeleteUser(ctx context.Context, tx pgx.Tx, id xid.ID, selfDelete b
return nil 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 { func (db *DB) UndoDeleteUser(ctx context.Context, id xid.ID) error {
sql, args, err := sq.Update("users"). sql, args, err := sq.Update("users").
Set("deleted_at", nil). Set("deleted_at", nil).
@ -702,3 +825,24 @@ func (db *DB) CleanUser(ctx context.Context, id xid.ID) error {
} }
return nil return nil
} }
const inactiveUsersSQL = `select id, snowflake_id from users
where last_active < now() - '30 days'::interval
and display_name is null and bio is null and timezone is null
and links is null and avatar is null and member_title is null
and names = '[]' and pronouns = '[]'
and (select count(m.id) from members m where user_id = users.id) = 0
and (select count(f.id) from user_fields f where user_id = users.id) = 0;`
// InactiveUsers gets the list of inactive users from the database.
// "Inactive" is defined as:
// - not logged in for 30 days or more
// - no display name, bio, avatar, names, pronouns, profile links, or profile fields
// - no members
func (db *DB) InactiveUsers(ctx context.Context, tx pgx.Tx) (us []User, err error) {
err = pgxscan.Select(ctx, tx, &us, inactiveUsersSQL)
if err != nil {
return nil, errors.Wrap(err, "executing query")
}
return us, nil
}

View file

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

View file

@ -13,8 +13,8 @@ import (
"os/signal" "os/signal"
"sync" "sync"
"codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/rs/xid" "github.com/rs/xid"

View file

@ -1,7 +1,7 @@
package exporter package exporter
import ( import (
"codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"github.com/rs/xid" "github.com/rs/xid"
) )

File diff suppressed because it is too large Load diff

View file

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

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

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: discordgo.MessageFlagsEphemeral,
},
})
}

View file

@ -1,56 +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)
if !claims.TokenWrite {
return server.APIError{Code: server.ErrMissingPermissions, Details: "this token is read-only"}
}
id, err := xid.FromString(chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{Code: server.ErrMemberNotFound}
}
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.NoContent(w, r)
return nil
}

View file

@ -1,152 +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"`
Unlisted *bool `json:"unlisted,omitempty"`
}
func dbMemberToMember(u db.User, m db.Member, fields []db.Field, isOwnMember bool) GetMemberResponse {
r := 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,
CustomPreferences: u.CustomPreferences,
},
}
if isOwnMember {
r.Unlisted = &m.Unlisted
}
return r
}
type PartialUser struct {
ID xid.ID `json:"id"`
Username string `json:"name"`
DisplayName *string `json:"display_name"`
Avatar *string `json:"avatar"`
CustomPreferences db.CustomPreferences `json:"custom_preferences"`
}
func (s *Server) getMember(w http.ResponseWriter, r *http.Request) error {
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}
}
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 err
}
render.JSON(w, r, dbMemberToMember(u, m, fields, 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 err
}
render.JSON(w, r, dbMemberToMember(u, m, fields, isOwnMember))
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,52 +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()
numUsers, err := s.DB.TotalUserCount(ctx)
if err != nil {
return errors.Wrap(err, "querying user count")
}
numMembers, err := s.DB.TotalMemberCount(ctx)
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

@ -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" "os"
"time" "time"
"codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4" "github.com/mediocregopher/radix/v4"
"github.com/rs/xid" "github.com/rs/xid"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@ -42,6 +43,7 @@ type discordCallbackResponse struct {
Discord string `json:"discord,omitempty"` // username, for UI purposes Discord string `json:"discord,omitempty"` // username, for UI purposes
Ticket string `json:"ticket,omitempty"` Ticket string `json:"ticket,omitempty"`
RequireInvite bool `json:"require_invite"` // require an invite for signing up RequireInvite bool `json:"require_invite"` // require an invite for signing up
RequireCaptcha bool `json:"require_captcha"`
IsDeleted bool `json:"is_deleted"` IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"` DeletedAt *time.Time `json:"deleted_at,omitempty"`
@ -60,7 +62,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
// if the state can't be validated, return // if the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil { if err != nil {
return err return errors.Wrap(err, "validating state")
} }
return server.APIError{Code: server.ErrInvalidState} return server.APIError{Code: server.ErrInvalidState}
@ -78,7 +80,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
dg, _ := discordgo.New(token.Type() + " " + token.AccessToken) dg, _ := discordgo.New(token.Type() + " " + token.AccessToken)
du, err := dg.User("@me") du, err := dg.User("@me")
if err != nil { if err != nil {
return err return errors.Wrap(err, "getting discord user")
} }
u, err := s.DB.DiscordUser(ctx, du.ID) u, err := s.DB.DiscordUser(ctx, du.ID)
@ -89,7 +91,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
err = s.saveUndeleteToken(ctx, u.ID, token) err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil { if err != nil {
log.Errorf("saving undelete token: %v", err) log.Errorf("saving undelete token: %v", err)
return err return errors.Wrap(err, "saving undelete token")
} }
render.JSON(w, r, discordCallbackResponse{ render.JSON(w, r, discordCallbackResponse{
@ -113,7 +115,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
tokenID := xid.New() tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil { if err != nil {
return err return errors.Wrap(err, "creating token")
} }
// save token to database // save token to database
@ -136,7 +138,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
return nil return nil
} else if err != db.ErrUserNotFound { // internal error } else if err != db.ErrUserNotFound { // internal error
return err return errors.Wrap(err, "getting user")
} }
// no user found, so save a ticket + save their Discord info in Redis // no user found, so save a ticket + save their Discord info in Redis
@ -144,7 +146,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetJSON(ctx, "discord:"+ticket, du, "EX", "600") err = s.DB.SetJSON(ctx, "discord:"+ticket, du, "EX", "600")
if err != nil { if err != nil {
log.Errorf("setting Discord user for ticket %q: %v", ticket, err) log.Errorf("setting Discord user for ticket %q: %v", ticket, err)
return err return errors.Wrap(err, "caching discord user for ticket")
} }
render.JSON(w, r, discordCallbackResponse{ render.JSON(w, r, discordCallbackResponse{
@ -152,6 +154,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
Discord: du.String(), Discord: du.String(),
Ticket: ticket, Ticket: ticket,
RequireInvite: s.RequireInvite, RequireInvite: s.RequireInvite,
RequireCaptcha: s.hcaptchaSecret != "",
}) })
return nil return nil
@ -193,6 +196,11 @@ func (s *Server) discordLink(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket} 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) err = u.UpdateFromDiscord(ctx, s.DB, du)
if err != nil { if err != nil {
return errors.Wrap(err, "updating user from discord") return errors.Wrap(err, "updating user from discord")
@ -249,6 +257,7 @@ type signupRequest struct {
Ticket string `json:"ticket"` Ticket string `json:"ticket"`
Username string `json:"username"` Username string `json:"username"`
InviteCode string `json:"invite_code"` InviteCode string `json:"invite_code"`
CaptchaResponse string `json:"captcha_response"`
} }
type signupResponse struct { type signupResponse struct {
@ -270,7 +279,7 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil { if err != nil {
return err return errors.Wrap(err, "checking if username is taken")
} }
if !valid { if !valid {
return server.APIError{Code: server.ErrInvalidUsername} return server.APIError{Code: server.ErrInvalidUsername}
@ -283,7 +292,12 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return errors.Wrap(err, "beginning transaction") return errors.Wrap(err, "beginning transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
du := new(discordgo.User) du := new(discordgo.User)
err = s.DB.GetJSON(ctx, "discord:"+req.Ticket, &du) err = s.DB.GetJSON(ctx, "discord:"+req.Ticket, &du)
@ -293,6 +307,19 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket} 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) u, err := s.DB.CreateUser(ctx, tx, req.Username)
if err != nil { if err != nil {
if errors.Cause(err) == db.ErrUsernameTaken { 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") 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) err = u.UpdateFromDiscord(ctx, tx, du)
if err != nil { if err != nil {
return errors.Wrap(err, "updating user from discord") return errors.Wrap(err, "updating user from discord")

View file

@ -6,11 +6,12 @@ import (
"net/http" "net/http"
"time" "time"
"codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4" "github.com/mediocregopher/radix/v4"
"github.com/rs/xid" "github.com/rs/xid"
) )
@ -30,6 +31,7 @@ type fediCallbackResponse struct {
Fediverse string `json:"fediverse,omitempty"` // username, for UI purposes Fediverse string `json:"fediverse,omitempty"` // username, for UI purposes
Ticket string `json:"ticket,omitempty"` Ticket string `json:"ticket,omitempty"`
RequireInvite bool `json:"require_invite"` // require an invite for signing up RequireInvite bool `json:"require_invite"` // require an invite for signing up
RequireCaptcha bool `json:"require_captcha"`
IsDeleted bool `json:"is_deleted"` IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"` 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 the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil { if err != nil {
return err return errors.Wrap(err, "validating state")
} }
return server.APIError{Code: server.ErrInvalidState} return server.APIError{Code: server.ErrInvalidState}
@ -110,7 +112,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
err = s.saveUndeleteToken(ctx, u.ID, token) err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil { if err != nil {
log.Errorf("saving undelete token: %v", err) log.Errorf("saving undelete token: %v", err)
return err return errors.Wrap(err, "saving undelete token")
} }
render.JSON(w, r, fediCallbackResponse{ render.JSON(w, r, fediCallbackResponse{
@ -134,7 +136,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
tokenID := xid.New() tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil { if err != nil {
return err return errors.Wrap(err, "creating token")
} }
// save token to database // save token to database
@ -157,7 +159,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
return nil return nil
} else if err != db.ErrUserNotFound { // internal error } else if err != db.ErrUserNotFound { // internal error
return err return errors.Wrap(err, "getting user")
} }
// no user found, so save a ticket + save their Mastodon info in Redis // no user found, so save a ticket + save their Mastodon info in Redis
@ -165,7 +167,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
err = s.DB.SetJSON(ctx, "mastodon:"+ticket, mu, "EX", "600") err = s.DB.SetJSON(ctx, "mastodon:"+ticket, mu, "EX", "600")
if err != nil { if err != nil {
log.Errorf("setting mastoAPI user for ticket %q: %v", ticket, err) log.Errorf("setting mastoAPI user for ticket %q: %v", ticket, err)
return err return errors.Wrap(err, "setting user for ticket")
} }
render.JSON(w, r, fediCallbackResponse{ render.JSON(w, r, fediCallbackResponse{
@ -173,6 +175,7 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
Fediverse: mu.Username, Fediverse: mu.Username,
Ticket: ticket, Ticket: ticket,
RequireInvite: s.RequireInvite, RequireInvite: s.RequireInvite,
RequireCaptcha: s.hcaptchaSecret != "",
}) })
return nil return nil
@ -220,6 +223,11 @@ func (s *Server) mastodonLink(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket} 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) err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID)
if err != nil { if err != nil {
return errors.Wrap(err, "updating user from mastoAPI") return errors.Wrap(err, "updating user from mastoAPI")
@ -277,6 +285,7 @@ type fediSignupRequest struct {
Ticket string `json:"ticket"` Ticket string `json:"ticket"`
Username string `json:"username"` Username string `json:"username"`
InviteCode string `json:"invite_code"` InviteCode string `json:"invite_code"`
CaptchaResponse string `json:"captcha_response"`
} }
func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error { 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) valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil { if err != nil {
return err return errors.Wrap(err, "checking if username is taken")
} }
if !valid { if !valid {
return server.APIError{Code: server.ErrInvalidUsername} return server.APIError{Code: server.ErrInvalidUsername}
@ -311,7 +320,12 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return errors.Wrap(err, "beginning transaction") return errors.Wrap(err, "beginning transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
mu := new(partialMastodonAccount) mu := new(partialMastodonAccount)
err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu) err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu)
@ -321,6 +335,19 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket} 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) u, err := s.DB.CreateUser(ctx, tx, req.Username)
if err != nil { if err != nil {
if errors.Cause(err) == db.ErrUsernameTaken { 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") 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) err = u.UpdateFromFedi(ctx, tx, mu.ID, mu.Username, app.ID)
if err != nil { if err != nil {
return errors.Wrap(err, "updating user from mastoAPI") return errors.Wrap(err, "updating user from mastoAPI")

View file

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

View file

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

View file

@ -8,8 +8,8 @@ import (
"net/url" "net/url"
"strings" "strings"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
) )
@ -65,11 +65,15 @@ func (s *Server) noAppFediverseURL(ctx context.Context, w http.ResponseWriter, r
} }
switch softwareName { switch softwareName {
case "misskey", "foundkey", "calckey": case "iceshrimp":
softwareName = "firefish"
fallthrough
case "misskey", "foundkey", "calckey", "firefish", "sharkey":
return s.noAppMisskeyURL(ctx, w, r, softwareName, instance) return s.noAppMisskeyURL(ctx, w, r, softwareName, instance)
case "mastodon", "pleroma", "akkoma", "pixelfed": case "mastodon", "pleroma", "akkoma", "incestoma", "pixelfed", "gotosocial":
case "glitchcafe", "hometown":
softwareName = "mastodon"
default: default:
// sorry, misskey :( TODO: support misskey
return server.APIError{Code: server.ErrUnsupportedInstance} return server.APIError{Code: server.ErrUnsupportedInstance}
} }

View file

@ -5,11 +5,12 @@ import (
"os" "os"
"time" "time"
"codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4" "github.com/mediocregopher/radix/v4"
"github.com/rs/xid" "github.com/rs/xid"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@ -36,6 +37,7 @@ type googleCallbackResponse struct {
Google string `json:"google,omitempty"` // username, for UI purposes Google string `json:"google,omitempty"` // username, for UI purposes
Ticket string `json:"ticket,omitempty"` Ticket string `json:"ticket,omitempty"`
RequireInvite bool `json:"require_invite"` // require an invite for signing up RequireInvite bool `json:"require_invite"` // require an invite for signing up
RequireCaptcha bool `json:"require_captcha"`
IsDeleted bool `json:"is_deleted"` IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"` DeletedAt *time.Time `json:"deleted_at,omitempty"`
@ -59,7 +61,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
// if the state can't be validated, return // if the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil { if err != nil {
return err return errors.Wrap(err, "validating state")
} }
return server.APIError{Code: server.ErrInvalidState} return server.APIError{Code: server.ErrInvalidState}
@ -108,7 +110,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
err = s.saveUndeleteToken(ctx, u.ID, token) err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil { if err != nil {
log.Errorf("saving undelete token: %v", err) log.Errorf("saving undelete token: %v", err)
return err return errors.Wrap(err, "saving undelete token")
} }
render.JSON(w, r, googleCallbackResponse{ render.JSON(w, r, googleCallbackResponse{
@ -132,7 +134,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
tokenID := xid.New() tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil { if err != nil {
return err return errors.Wrap(err, "creating token")
} }
// save token to database // save token to database
@ -155,7 +157,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
return nil return nil
} else if err != db.ErrUserNotFound { // internal error } else if err != db.ErrUserNotFound { // internal error
return err return errors.Wrap(err, "getting user")
} }
// no user found, so save a ticket + save their Google info in Redis // no user found, so save a ticket + save their Google info in Redis
@ -163,7 +165,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetJSON(ctx, "google:"+ticket, partialGoogleUser{ID: googleID, Email: googleUsername}, "EX", "600") err = s.DB.SetJSON(ctx, "google:"+ticket, partialGoogleUser{ID: googleID, Email: googleUsername}, "EX", "600")
if err != nil { if err != nil {
log.Errorf("setting Google user for ticket %q: %v", ticket, err) log.Errorf("setting Google user for ticket %q: %v", ticket, err)
return err return errors.Wrap(err, "setting user for ticket")
} }
render.JSON(w, r, googleCallbackResponse{ render.JSON(w, r, googleCallbackResponse{
@ -171,6 +173,7 @@ func (s *Server) googleCallback(w http.ResponseWriter, r *http.Request) error {
Google: googleUsername, Google: googleUsername,
Ticket: ticket, Ticket: ticket,
RequireInvite: s.RequireInvite, RequireInvite: s.RequireInvite,
RequireCaptcha: s.hcaptchaSecret != "",
}) })
return nil return nil
@ -208,6 +211,11 @@ func (s *Server) googleLink(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket} 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) err = u.UpdateFromGoogle(ctx, s.DB, gu.ID, gu.Email)
if err != nil { if err != nil {
return errors.Wrap(err, "updating user from google") return errors.Wrap(err, "updating user from google")
@ -274,7 +282,7 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil { if err != nil {
return err return errors.Wrap(err, "checking if username is taken")
} }
if !valid { if !valid {
return server.APIError{Code: server.ErrInvalidUsername} return server.APIError{Code: server.ErrInvalidUsername}
@ -287,7 +295,12 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return errors.Wrap(err, "beginning transaction") return errors.Wrap(err, "beginning transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
gu := new(partialGoogleUser) gu := new(partialGoogleUser)
err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu) err = s.DB.GetJSON(ctx, "google:"+req.Ticket, &gu)
@ -297,6 +310,19 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket} 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) u, err := s.DB.CreateUser(ctx, tx, req.Username)
if err != nil { if err != nil {
if errors.Cause(err) == db.ErrUsernameTaken { if errors.Cause(err) == db.ErrUsernameTaken {
@ -306,6 +332,11 @@ func (s *Server) googleSignup(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "creating user") 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) err = u.UpdateFromGoogle(ctx, tx, gu.ID, gu.Email)
if err != nil { if err != nil {
return errors.Wrap(err, "updating user from google") return errors.Wrap(err, "updating user from google")

View file

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

View file

@ -4,9 +4,10 @@ import (
"net/http" "net/http"
"os" "os"
"codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
@ -18,10 +19,14 @@ type Server struct {
RequireInvite bool RequireInvite bool
BaseURL string BaseURL string
hcaptchaSitekey string
hcaptchaSecret string
} }
type userResponse struct { type userResponse struct {
ID xid.ID `json:"id"` ID xid.ID `json:"id"`
SnowflakeID common.UserID `json:"id_new"`
Username string `json:"name"` Username string `json:"name"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
Bio *string `json:"bio"` Bio *string `json:"bio"`
@ -48,6 +53,7 @@ type userResponse struct {
func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse { func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse {
return &userResponse{ return &userResponse{
ID: u.ID, ID: u.ID,
SnowflakeID: u.SnowflakeID,
Username: u.Username, Username: u.Username,
DisplayName: u.DisplayName, DisplayName: u.DisplayName,
Bio: u.Bio, Bio: u.Bio,
@ -73,6 +79,8 @@ func Mount(srv *server.Server, r chi.Router) {
Server: srv, Server: srv,
RequireInvite: os.Getenv("REQUIRE_INVITE") == "true", RequireInvite: os.Getenv("REQUIRE_INVITE") == "true",
BaseURL: os.Getenv("BASE_URL"), BaseURL: os.Getenv("BASE_URL"),
hcaptchaSitekey: os.Getenv("HCAPTCHA_SITEKEY"),
hcaptchaSecret: os.Getenv("HCAPTCHA_SECRET"),
} }
r.Route("/auth", func(r chi.Router) { r.Route("/auth", func(r chi.Router) {
@ -177,7 +185,7 @@ func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error {
if googleOAuthConfig.ClientID != "" { if googleOAuthConfig.ClientID != "" {
googleCfg := googleOAuthConfig googleCfg := googleOAuthConfig
googleCfg.RedirectURL = req.CallbackDomain + "/auth/login/google" googleCfg.RedirectURL = req.CallbackDomain + "/auth/login/google"
resp.Google = googleCfg.AuthCodeURL(state) resp.Google = googleCfg.AuthCodeURL(state) + "&prompt=select_account"
} }
render.JSON(w, r, resp) render.JSON(w, r, resp)

View file

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

View file

@ -7,11 +7,12 @@ import (
"os" "os"
"time" "time"
"codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/mediocregopher/radix/v4" "github.com/mediocregopher/radix/v4"
"github.com/rs/xid" "github.com/rs/xid"
"golang.org/x/oauth2" "golang.org/x/oauth2"
@ -58,6 +59,7 @@ type tumblrCallbackResponse struct {
Tumblr string `json:"tumblr,omitempty"` // username, for UI purposes Tumblr string `json:"tumblr,omitempty"` // username, for UI purposes
Ticket string `json:"ticket,omitempty"` Ticket string `json:"ticket,omitempty"`
RequireInvite bool `json:"require_invite"` // require an invite for signing up RequireInvite bool `json:"require_invite"` // require an invite for signing up
RequireCaptcha bool `json:"require_captcha"`
IsDeleted bool `json:"is_deleted"` IsDeleted bool `json:"is_deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"` DeletedAt *time.Time `json:"deleted_at,omitempty"`
@ -76,7 +78,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
// if the state can't be validated, return // if the state can't be validated, return
if valid, err := s.validateCSRFState(ctx, decoded.State); !valid { if valid, err := s.validateCSRFState(ctx, decoded.State); !valid {
if err != nil { if err != nil {
return err return errors.Wrap(err, "validating state")
} }
return server.APIError{Code: server.ErrInvalidState} return server.APIError{Code: server.ErrInvalidState}
@ -141,7 +143,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
err = s.saveUndeleteToken(ctx, u.ID, token) err = s.saveUndeleteToken(ctx, u.ID, token)
if err != nil { if err != nil {
log.Errorf("saving undelete token: %v", err) log.Errorf("saving undelete token: %v", err)
return err return errors.Wrap(err, "saving undelete token")
} }
render.JSON(w, r, tumblrCallbackResponse{ render.JSON(w, r, tumblrCallbackResponse{
@ -165,7 +167,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
tokenID := xid.New() tokenID := xid.New()
token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true) token, err := s.Auth.CreateToken(u.ID, tokenID, u.IsAdmin, false, true)
if err != nil { if err != nil {
return err return errors.Wrap(err, "creating token")
} }
// save token to database // save token to database
@ -188,7 +190,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
return nil return nil
} else if err != db.ErrUserNotFound { // internal error } else if err != db.ErrUserNotFound { // internal error
return err return errors.Wrap(err, "getting user")
} }
// no user found, so save a ticket + save their Tumblr info in Redis // no user found, so save a ticket + save their Tumblr info in Redis
@ -196,7 +198,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetJSON(ctx, "tumblr:"+ticket, tumblrUserInfo{ID: tumblrID, Name: tumblrName}, "EX", "600") err = s.DB.SetJSON(ctx, "tumblr:"+ticket, tumblrUserInfo{ID: tumblrID, Name: tumblrName}, "EX", "600")
if err != nil { if err != nil {
log.Errorf("setting Tumblr user for ticket %q: %v", ticket, err) log.Errorf("setting Tumblr user for ticket %q: %v", ticket, err)
return err return errors.Wrap(err, "setting user for ticket")
} }
render.JSON(w, r, tumblrCallbackResponse{ render.JSON(w, r, tumblrCallbackResponse{
@ -204,6 +206,7 @@ func (s *Server) tumblrCallback(w http.ResponseWriter, r *http.Request) error {
Tumblr: tumblrName, Tumblr: tumblrName,
Ticket: ticket, Ticket: ticket,
RequireInvite: s.RequireInvite, RequireInvite: s.RequireInvite,
RequireCaptcha: s.hcaptchaSecret != "",
}) })
return nil return nil
@ -241,6 +244,11 @@ func (s *Server) tumblrLink(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket} 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) err = u.UpdateFromTumblr(ctx, s.DB, tui.ID, tui.Name)
if err != nil { if err != nil {
return errors.Wrap(err, "updating user from tumblr") return errors.Wrap(err, "updating user from tumblr")
@ -307,7 +315,7 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
valid, taken, err := s.DB.UsernameTaken(ctx, req.Username) valid, taken, err := s.DB.UsernameTaken(ctx, req.Username)
if err != nil { if err != nil {
return err return errors.Wrap(err, "checking if username is taken")
} }
if !valid { if !valid {
return server.APIError{Code: server.ErrInvalidUsername} return server.APIError{Code: server.ErrInvalidUsername}
@ -320,7 +328,12 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
if err != nil { if err != nil {
return errors.Wrap(err, "beginning transaction") return errors.Wrap(err, "beginning transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
tui := new(tumblrUserInfo) tui := new(tumblrUserInfo)
err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui) err = s.DB.GetJSON(ctx, "tumblr:"+req.Ticket, &tui)
@ -330,6 +343,19 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrInvalidTicket} 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) u, err := s.DB.CreateUser(ctx, tx, req.Username)
if err != nil { if err != nil {
if errors.Cause(err) == db.ErrUsernameTaken { if errors.Cause(err) == db.ErrUsernameTaken {
@ -339,6 +365,11 @@ func (s *Server) tumblrSignup(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "creating user") 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) err = u.UpdateFromTumblr(ctx, tx, tui.ID, tui.Name)
if err != nil { if err != nil {
return errors.Wrap(err, "updating user from tumblr") return errors.Wrap(err, "updating user from tumblr")

View file

@ -6,8 +6,8 @@ import (
"encoding/base64" "encoding/base64"
"net/http" "net/http"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/mediocregopher/radix/v4" "github.com/mediocregopher/radix/v4"

View file

@ -5,12 +5,13 @@ import (
"net/http" "net/http"
"strings" "strings"
"codeberg.org/u1f320/pronouns.cc/backend/common" "codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
) )
type CreateMemberRequest struct { type CreateMemberRequest struct {
@ -80,7 +81,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
if !db.MemberNameValid(cmr.Name) { if !db.MemberNameValid(cmr.Name) {
return server.APIError{ return server.APIError{
Code: server.ErrBadRequest, 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.",
} }
} }
@ -119,7 +120,12 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
if err != nil { if err != nil {
return errors.Wrap(err, "starting transaction") return errors.Wrap(err, "starting transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
m, err := s.DB.CreateMember(ctx, tx, claims.UserID, cmr.Name, cmr.DisplayName, cmr.Bio, cmr.Links) m, err := s.DB.CreateMember(ctx, tx, claims.UserID, cmr.Name, cmr.DisplayName, cmr.Bio, cmr.Links)
if err != nil { if err != nil {
@ -127,14 +133,14 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
return server.APIError{Code: server.ErrMemberNameInUse} return server.APIError{Code: server.ErrMemberNameInUse}
} }
return err return errors.Wrap(err, "creating member")
} }
// set names, pronouns, fields // set names, pronouns, fields
err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, db.NotNull(cmr.Names), db.NotNull(cmr.Pronouns)) err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, db.NotNull(cmr.Names), db.NotNull(cmr.Pronouns))
if err != nil { if err != nil {
log.Errorf("setting names and pronouns for member %v: %v", m.ID, err) log.Errorf("setting names and pronouns for member %v: %v", m.ID, err)
return err return errors.Wrap(err, "setting names/pronouns")
} }
m.Names = cmr.Names m.Names = cmr.Names
m.Pronouns = cmr.Pronouns m.Pronouns = cmr.Pronouns
@ -142,7 +148,7 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
err = s.DB.SetMemberFields(ctx, tx, m.ID, cmr.Fields) err = s.DB.SetMemberFields(ctx, tx, m.ID, cmr.Fields)
if err != nil { if err != nil {
log.Errorf("setting fields for member %v: %v", m.ID, err) log.Errorf("setting fields for member %v: %v", m.ID, err)
return err return errors.Wrap(err, "setting fields")
} }
if cmr.Avatar != "" { if cmr.Avatar != "" {
@ -161,13 +167,13 @@ func (s *Server) createMember(w http.ResponseWriter, r *http.Request) (err error
} }
log.Errorf("converting member avatar: %v", err) log.Errorf("converting member avatar: %v", err)
return err return errors.Wrap(err, "converting avatar")
} }
hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg) hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
if err != nil { if err != nil {
log.Errorf("uploading member avatar: %v", err) log.Errorf("uploading member avatar: %v", err)
return err return errors.Wrap(err, "uploading avatar")
} }
err = tx.QueryRow(ctx, "UPDATE members SET avatar = $1 WHERE id = $2", hash, m.ID).Scan(&m.Avatar) err = tx.QueryRow(ctx, "UPDATE members SET avatar = $1 WHERE id = $2", hash, m.ID).Scan(&m.Avatar)
@ -176,12 +182,19 @@ 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) err = tx.Commit(ctx)
if err != nil { if err != nil {
return errors.Wrap(err, "committing transaction") return errors.Wrap(err, "committing transaction")
} }
render.JSON(w, r, dbMemberToMember(u, m, cmr.Fields, true)) render.JSON(w, r, dbMemberToMember(u, m, cmr.Fields, nil, true))
return nil return nil
} }

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

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

View file

@ -4,14 +4,16 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"strings" "strings"
"time"
"codeberg.org/u1f320/pronouns.cc/backend/common" "codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
"github.com/rs/xid" "github.com/rs/xid"
) )
@ -25,6 +27,7 @@ type PatchMemberRequest struct {
Fields *[]db.Field `json:"fields"` Fields *[]db.Field `json:"fields"`
Avatar *string `json:"avatar"` Avatar *string `json:"avatar"`
Unlisted *bool `json:"unlisted"` Unlisted *bool `json:"unlisted"`
Flags *[]xid.ID `json:"flags"`
} }
func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error { func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
@ -36,17 +39,16 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"} return server.APIError{Code: server.ErrMissingPermissions, Details: "This token is read-only"}
} }
id, err := xid.FromString(chi.URLParam(r, "memberRef"))
if err != nil {
return server.APIError{Code: server.ErrMemberNotFound}
}
u, err := s.DB.User(ctx, claims.UserID) u, err := s.DB.User(ctx, claims.UserID)
if err != nil { if err != nil {
return errors.Wrap(err, "getting user") return errors.Wrap(err, "getting user")
} }
m, err := s.DB.Member(ctx, id) var m db.Member
if 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 != nil {
if err == db.ErrMemberNotFound { if err == db.ErrMemberNotFound {
return server.APIError{Code: server.ErrMemberNotFound} return server.APIError{Code: server.ErrMemberNotFound}
@ -54,6 +56,21 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
return errors.Wrap(err, "getting member") 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 { if m.UserID != claims.UserID {
return server.APIError{Code: server.ErrNotOwnMember} return server.APIError{Code: server.ErrNotOwnMember}
@ -74,7 +91,8 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
req.Fields == nil && req.Fields == nil &&
req.Names == nil && req.Names == nil &&
req.Pronouns == nil && req.Pronouns == nil &&
req.Avatar == nil { req.Avatar == nil &&
req.Flags == nil {
return server.APIError{ return server.APIError{
Code: server.ErrBadRequest, Code: server.ErrBadRequest,
Details: "Data must not be empty", Details: "Data must not be empty",
@ -109,7 +127,7 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
if !db.MemberNameValid(*req.Name) { if !db.MemberNameValid(*req.Name) {
return server.APIError{ return server.APIError{
Code: server.ErrBadRequest, 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.",
} }
} }
} }
@ -153,6 +171,16 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
} }
} }
// 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 { if err := validateSlicePtr("name", req.Names, u.CustomPreferences); err != nil {
return *err return *err
} }
@ -193,13 +221,13 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
} }
log.Errorf("converting member avatar: %v", err) log.Errorf("converting member avatar: %v", err)
return err return errors.Wrap(err, "converting member avatar")
} }
hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg) hash, err := s.DB.WriteMemberAvatar(ctx, m.ID, webp, jpg)
if err != nil { if err != nil {
log.Errorf("uploading member avatar: %v", err) log.Errorf("uploading member avatar: %v", err)
return err return errors.Wrap(err, "writing member avatar")
} }
avatarHash = &hash avatarHash = &hash
@ -217,11 +245,16 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
tx, err := s.DB.Begin(ctx) tx, err := s.DB.Begin(ctx)
if err != nil { if err != nil {
log.Errorf("creating transaction: %v", err) log.Errorf("creating transaction: %v", err)
return err return errors.Wrap(err, "creating transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
m, err = s.DB.UpdateMember(ctx, tx, id, req.Name, req.DisplayName, req.Bio, req.Unlisted, req.Links, avatarHash) m, err = s.DB.UpdateMember(ctx, tx, m.ID, req.Name, req.DisplayName, req.Bio, req.Unlisted, req.Links, avatarHash)
if err != nil { if err != nil {
switch errors.Cause(err) { switch errors.Cause(err) {
case db.ErrNothingToUpdate: case db.ErrNothingToUpdate:
@ -245,10 +278,10 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
pronouns = *req.Pronouns pronouns = *req.Pronouns
} }
err = s.DB.SetMemberNamesPronouns(ctx, tx, id, names, pronouns) err = s.DB.SetMemberNamesPronouns(ctx, tx, m.ID, names, pronouns)
if err != nil { if err != nil {
log.Errorf("setting names for member %v: %v", id, err) log.Errorf("setting names for member %v: %v", m.ID, err)
return err return errors.Wrap(err, "setting names/pronouns")
} }
m.Names = names m.Names = names
m.Pronouns = pronouns m.Pronouns = pronouns
@ -256,27 +289,114 @@ func (s *Server) patchMember(w http.ResponseWriter, r *http.Request) error {
var fields []db.Field var fields []db.Field
if req.Fields != nil { if req.Fields != nil {
err = s.DB.SetMemberFields(ctx, tx, id, *req.Fields) err = s.DB.SetMemberFields(ctx, tx, m.ID, *req.Fields)
if err != nil { if err != nil {
log.Errorf("setting fields for member %v: %v", id, err) log.Errorf("setting fields for member %v: %v", m.ID, err)
return err return errors.Wrap(err, "setting fields")
} }
fields = *req.Fields fields = *req.Fields
} else { } else {
fields, err = s.DB.MemberFields(ctx, id) fields, err = s.DB.MemberFields(ctx, m.ID)
if err != nil { if err != nil {
log.Errorf("getting fields for member %v: %v", id, err) log.Errorf("getting fields for member %v: %v", m.ID, err)
return err return errors.Wrap(err, "getting fields")
} }
} }
// 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) err = tx.Commit(ctx)
if err != nil { if err != nil {
log.Errorf("committing transaction: %v", err) log.Errorf("committing transaction: %v", err)
return err return errors.Wrap(err, "committing transaction")
}
// get flags to return (we need to return full flag objects, not the array of IDs in the request body)
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 // echo the updated member back on success
render.JSON(w, r, dbMemberToMember(u, m, fields, true)) 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 return nil
} }

View file

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

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

View file

@ -4,8 +4,8 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "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" "net/http"
"strconv" "strconv"
"codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/jackc/pgx/v5"
) )
type resolveReportRequest struct { type resolveReportRequest struct {
@ -43,7 +44,12 @@ func (s *Server) resolveReport(w http.ResponseWriter, r *http.Request) error {
log.Errorf("creating transaction: %v", err) log.Errorf("creating transaction: %v", err)
return errors.Wrap(err, "creating transaction") return errors.Wrap(err, "creating transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
report, err := s.DB.Report(ctx, tx, id) report, err := s.DB.Report(ctx, tx, id)
if err != nil { if err != nil {

View file

@ -3,7 +3,7 @@ package mod
import ( import (
"net/http" "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/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
@ -22,6 +22,8 @@ func Mount(srv *server.Server, r chi.Router) {
r.Get("/reports/by-reporter/{id}", server.WrapHandler(s.getReportsByReporter)) r.Get("/reports/by-reporter/{id}", server.WrapHandler(s.getReportsByReporter))
r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport)) r.Patch("/reports/{id}", server.WrapHandler(s.resolveReport))
r.Post("/notices", server.WrapHandler(s.createNotice))
}) })
r.With(MustAdmin).Handle("/metrics", promhttp.Handler()) r.With(MustAdmin).Handle("/metrics", promhttp.Handler())

View file

@ -4,9 +4,9 @@ import (
"net/http" "net/http"
"strconv" "strconv"
"codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
@ -44,7 +44,7 @@ func (s *Server) ackWarning(w http.ResponseWriter, r *http.Request) (err error)
ctx := r.Context() ctx := r.Context()
claims, _ := server.ClaimsFromContext(ctx) claims, _ := server.ClaimsFromContext(ctx)
if !claims.APIToken { if claims.APIToken {
return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"} return server.APIError{Code: server.ErrMissingPermissions, Details: "This endpoint cannot be used by API tokens"}
} }

View file

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

View file

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

View file

@ -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

@ -2,10 +2,13 @@ package user
import ( import (
"net/http" "net/http"
"time"
"codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"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/chi/v5"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/rs/xid" "github.com/rs/xid"
@ -13,6 +16,8 @@ import (
type GetUserResponse struct { type GetUserResponse struct {
ID xid.ID `json:"id"` ID xid.ID `json:"id"`
SnowflakeID common.UserID `json:"id_new"`
SID string `json:"sid"`
Username string `json:"name"` Username string `json:"name"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
Bio *string `json:"bio"` Bio *string `json:"bio"`
@ -24,14 +29,21 @@ type GetUserResponse struct {
Members []PartialMember `json:"members"` Members []PartialMember `json:"members"`
Fields []db.Field `json:"fields"` Fields []db.Field `json:"fields"`
CustomPreferences db.CustomPreferences `json:"custom_preferences"` CustomPreferences db.CustomPreferences `json:"custom_preferences"`
Flags []db.UserFlag `json:"flags"`
Badges db.Badge `json:"badges"`
UTCOffset *int `json:"utc_offset"`
} }
type GetMeResponse struct { type GetMeResponse struct {
GetUserResponse GetUserResponse
CreatedAt time.Time `json:"created_at"`
Timezone *string `json:"timezone"`
MaxInvites int `json:"max_invites"` MaxInvites int `json:"max_invites"`
IsAdmin bool `json:"is_admin"` IsAdmin bool `json:"is_admin"`
ListPrivate bool `json:"list_private"` ListPrivate bool `json:"list_private"`
LastSIDReroll time.Time `json:"last_sid_reroll"`
Discord *string `json:"discord"` Discord *string `json:"discord"`
DiscordUsername *string `json:"discord_username"` DiscordUsername *string `json:"discord_username"`
@ -49,6 +61,8 @@ type GetMeResponse struct {
type PartialMember struct { type PartialMember struct {
ID xid.ID `json:"id"` ID xid.ID `json:"id"`
SnowflakeID common.MemberID `json:"id_new"`
SID string `json:"sid"`
Name string `json:"name"` Name string `json:"name"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
Bio *string `json:"bio"` Bio *string `json:"bio"`
@ -58,9 +72,11 @@ type PartialMember struct {
Pronouns []db.PronounEntry `json:"pronouns"` Pronouns []db.PronounEntry `json:"pronouns"`
} }
func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUserResponse { func dbUserToResponse(u db.User, fields []db.Field, members []db.Member, flags []db.UserFlag) GetUserResponse {
resp := GetUserResponse{ resp := GetUserResponse{
ID: u.ID, ID: u.ID,
SnowflakeID: u.SnowflakeID,
SID: u.SID,
Username: u.Username, Username: u.Username,
DisplayName: u.DisplayName, DisplayName: u.DisplayName,
Bio: u.Bio, Bio: u.Bio,
@ -71,12 +87,23 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUser
Pronouns: db.NotNull(u.Pronouns), Pronouns: db.NotNull(u.Pronouns),
Fields: db.NotNull(fields), Fields: db.NotNull(fields),
CustomPreferences: u.CustomPreferences, 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)) resp.Members = make([]PartialMember, len(members))
for i := range members { for i := range members {
resp.Members[i] = PartialMember{ resp.Members[i] = PartialMember{
ID: members[i].ID, ID: members[i].ID,
SnowflakeID: members[i].SnowflakeID,
SID: members[i].SID,
Name: members[i].Name, Name: members[i].Name,
DisplayName: members[i].DisplayName, DisplayName: members[i].DisplayName,
Bio: members[i].Bio, Bio: members[i].Bio,
@ -90,56 +117,38 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUser
return resp return resp
} }
func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error { func (s *Server) getUser(w http.ResponseWriter, r *http.Request) (err error) {
ctx := r.Context() ctx := r.Context()
userRef := chi.URLParamFromCtx(ctx, "userRef") userRef := chi.URLParamFromCtx(ctx, "userRef")
var u db.User
if id, err := xid.FromString(userRef); err == nil { if id, err := xid.FromString(userRef); err == nil {
u, err := s.DB.User(ctx, id) u, err = s.DB.User(ctx, id)
if err == nil {
if 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 { if err != nil {
log.Errorf("Error getting user fields: %v", err) log.Errorf("getting user by ID: %v", err)
return err }
} }
var members []db.Member if u.ID.IsNil() {
if !u.ListPrivate || isSelf { if id, err := common.ParseSnowflake(userRef); err == nil {
members, err = s.DB.UserMembers(ctx, u.ID, isSelf) u, err = s.DB.UserBySnowflake(ctx, common.UserID(id))
if err != nil { if err != nil {
log.Errorf("Error getting user members: %v", err) log.Errorf("getting user by snowflake: %v", err)
return err }
} }
} }
render.JSON(w, r, dbUserToResponse(u, fields, members)) if u.ID.IsNil() {
return nil u, err = s.DB.Username(ctx, userRef)
} 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 { if err == db.ErrUserNotFound {
return server.APIError{ return server.APIError{
Code: server.ErrUserNotFound, Code: server.ErrUserNotFound,
} }
} else if err != nil { } else if err != nil {
log.Errorf("Error getting user by username: %v", err) log.Errorf("Error getting user by username: %v", err)
return err return errors.Wrap(err, "getting user")
}
} }
if u.DeletedAt != nil { if u.DeletedAt != nil {
@ -154,7 +163,13 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error {
fields, err := s.DB.UserFields(ctx, u.ID) fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil { if err != nil {
log.Errorf("Error getting user fields: %v", err) log.Errorf("Error getting user fields: %v", err)
return err return errors.Wrap(err, "getting fields")
}
flags, err := s.DB.UserFlags(ctx, u.ID)
if err != nil {
log.Errorf("getting user flags: %v", err)
return errors.Wrap(err, "getting flags")
} }
var members []db.Member var members []db.Member
@ -162,11 +177,11 @@ func (s *Server) getUser(w http.ResponseWriter, r *http.Request) error {
members, err = s.DB.UserMembers(ctx, u.ID, isSelf) members, err = s.DB.UserMembers(ctx, u.ID, isSelf)
if err != nil { if err != nil {
log.Errorf("Error getting user members: %v", err) log.Errorf("Error getting user members: %v", err)
return err return errors.Wrap(err, "getting user members")
} }
} }
render.JSON(w, r, dbUserToResponse(u, fields, members)) render.JSON(w, r, dbUserToResponse(u, fields, members, flags))
return nil return nil
} }
@ -177,26 +192,35 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error {
u, err := s.DB.User(ctx, claims.UserID) u, err := s.DB.User(ctx, claims.UserID)
if err != nil { if err != nil {
log.Errorf("Error getting user: %v", err) log.Errorf("Error getting user: %v", err)
return err return errors.Wrap(err, "getting users")
} }
fields, err := s.DB.UserFields(ctx, u.ID) fields, err := s.DB.UserFields(ctx, u.ID)
if err != nil { if err != nil {
log.Errorf("Error getting user fields: %v", err) log.Errorf("Error getting user fields: %v", err)
return err return errors.Wrap(err, "getting fields")
} }
members, err := s.DB.UserMembers(ctx, u.ID, true) members, err := s.DB.UserMembers(ctx, u.ID, true)
if err != nil { if err != nil {
log.Errorf("Error getting user members: %v", err) log.Errorf("Error getting user members: %v", err)
return err return errors.Wrap(err, "getting members")
}
flags, err := s.DB.UserFlags(ctx, u.ID)
if err != nil {
log.Errorf("getting user flags: %v", err)
return errors.Wrap(err, "getting flags")
} }
render.JSON(w, r, GetMeResponse{ render.JSON(w, r, GetMeResponse{
GetUserResponse: dbUserToResponse(u, fields, members), GetUserResponse: dbUserToResponse(u, fields, members, flags),
CreatedAt: u.ID.Time(),
Timezone: u.Timezone,
MaxInvites: u.MaxInvites, MaxInvites: u.MaxInvites,
IsAdmin: u.IsAdmin, IsAdmin: u.IsAdmin,
ListPrivate: u.ListPrivate, ListPrivate: u.ListPrivate,
LastSIDReroll: u.LastSIDReroll,
Discord: u.Discord, Discord: u.Discord,
DiscordUsername: u.DiscordUsername, DiscordUsername: u.DiscordUsername,
Tumblr: u.Tumblr, Tumblr: u.Tumblr,

View file

@ -3,18 +3,21 @@ package user
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"time"
"codeberg.org/u1f320/pronouns.cc/backend/common" "codeberg.org/pronounscc/pronouns.cc/backend/common"
"codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/go-chi/render" "github.com/go-chi/render"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/rs/xid"
) )
type PatchUserRequest struct { type PatchUserRequest struct {
Username *string `json:"username"` Username *string `json:"name"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
Bio *string `json:"bio"` Bio *string `json:"bio"`
MemberTitle *string `json:"member_title"` MemberTitle *string `json:"member_title"`
@ -23,8 +26,10 @@ type PatchUserRequest struct {
Pronouns *[]db.PronounEntry `json:"pronouns"` Pronouns *[]db.PronounEntry `json:"pronouns"`
Fields *[]db.Field `json:"fields"` Fields *[]db.Field `json:"fields"`
Avatar *string `json:"avatar"` Avatar *string `json:"avatar"`
Timezone *string `json:"timezone"`
ListPrivate *bool `json:"list_private"` ListPrivate *bool `json:"list_private"`
CustomPreferences *db.CustomPreferences `json:"custom_preferences"` CustomPreferences *db.CustomPreferences `json:"custom_preferences"`
Flags *[]xid.ID `json:"flags"`
} }
// patchUser parses a PatchUserRequest and updates the user with the given ID. // patchUser parses a PatchUserRequest and updates the user with the given ID.
@ -60,7 +65,8 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
req.Names == nil && req.Names == nil &&
req.Pronouns == nil && req.Pronouns == nil &&
req.Avatar == nil && req.Avatar == nil &&
req.CustomPreferences == nil { req.CustomPreferences == nil &&
req.Flags == nil {
return server.APIError{ return server.APIError{
Code: server.ErrBadRequest, Code: server.ErrBadRequest,
Details: "Data must not be empty", Details: "Data must not be empty",
@ -87,6 +93,19 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
} }
} }
// 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 // validate links
if req.Links != nil { if req.Links != nil {
if len(*req.Links) > db.MaxUserLinksLength { if len(*req.Links) > db.MaxUserLinksLength {
@ -106,16 +125,14 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
} }
} }
if err := validateSlicePtr("name", req.Names, u.CustomPreferences); err != nil { // validate flag length
return *err 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("pronoun", req.Pronouns, u.CustomPreferences); err != nil {
return *err
} }
if err := validateSlicePtr("field", req.Fields, u.CustomPreferences); err != nil {
return *err
} }
// validate custom preferences // validate custom preferences
@ -134,6 +151,22 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
} }
} }
} }
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 // update avatar
var avatarHash *string = nil var avatarHash *string = nil
@ -163,13 +196,13 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
} }
log.Errorf("converting user avatar: %v", err) log.Errorf("converting user avatar: %v", err)
return err return errors.Wrap(err, "converting avatar")
} }
hash, err := s.DB.WriteUserAvatar(ctx, claims.UserID, webp, jpg) hash, err := s.DB.WriteUserAvatar(ctx, claims.UserID, webp, jpg)
if err != nil { if err != nil {
log.Errorf("uploading user avatar: %v", err) log.Errorf("uploading user avatar: %v", err)
return err return errors.Wrap(err, "uploading avatar")
} }
avatarHash = &hash avatarHash = &hash
@ -187,9 +220,14 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
tx, err := s.DB.Begin(ctx) tx, err := s.DB.Begin(ctx)
if err != nil { if err != nil {
log.Errorf("creating transaction: %v", err) log.Errorf("creating transaction: %v", err)
return err return errors.Wrap(err, "creating transaction")
} }
defer tx.Rollback(ctx) defer func() {
err := tx.Rollback(ctx)
if err != nil && !errors.Is(err, pgx.ErrTxClosed) {
log.Error("rolling back transaction:", err)
}
}()
// update username // update username
if req.Username != nil && *req.Username != u.Username { if req.Username != nil && *req.Username != u.Username {
@ -200,16 +238,18 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
return server.APIError{Code: server.ErrUsernameTaken} return server.APIError{Code: server.ErrUsernameTaken}
case db.ErrInvalidUsername: case db.ErrInvalidUsername:
return server.APIError{Code: server.ErrInvalidUsername} return server.APIError{Code: server.ErrInvalidUsername}
case db.ErrBannedUsername:
return server.APIError{Code: server.ErrInvalidUsername, Details: "That username cannot be used."}
default: default:
return errors.Wrap(err, "updating username") 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.CustomPreferences) u, err = s.DB.UpdateUser(ctx, tx, claims.UserID, req.DisplayName, req.Bio, req.MemberTitle, req.ListPrivate, req.Links, avatarHash, req.Timezone, req.CustomPreferences)
if err != nil && errors.Cause(err) != db.ErrNothingToUpdate { if err != nil && errors.Cause(err) != db.ErrNothingToUpdate {
log.Errorf("updating user: %v", err) log.Errorf("updating user: %v", err)
return err return errors.Wrap(err, "updating user")
} }
if req.Names != nil || req.Pronouns != nil { if req.Names != nil || req.Pronouns != nil {
@ -226,7 +266,7 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetUserNamesPronouns(ctx, tx, claims.UserID, names, pronouns) err = s.DB.SetUserNamesPronouns(ctx, tx, claims.UserID, names, pronouns)
if err != nil { if err != nil {
log.Errorf("setting names for member %v: %v", claims.UserID, err) log.Errorf("setting names for member %v: %v", claims.UserID, err)
return err return errors.Wrap(err, "setting names/pronouns")
} }
u.Names = names u.Names = names
u.Pronouns = pronouns u.Pronouns = pronouns
@ -237,21 +277,41 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
err = s.DB.SetUserFields(ctx, tx, claims.UserID, *req.Fields) err = s.DB.SetUserFields(ctx, tx, claims.UserID, *req.Fields)
if err != nil { if err != nil {
log.Errorf("setting fields for user %v: %v", claims.UserID, err) log.Errorf("setting fields for user %v: %v", claims.UserID, err)
return err return errors.Wrap(err, "setting fields")
} }
fields = *req.Fields fields = *req.Fields
} else { } else {
fields, err = s.DB.UserFields(ctx, claims.UserID) fields, err = s.DB.UserFields(ctx, claims.UserID)
if err != nil { if err != nil {
log.Errorf("getting fields for user %v: %v", claims.UserID, err) log.Errorf("getting fields for user %v: %v", claims.UserID, err)
return err return errors.Wrap(err, "getting fields")
} }
} }
// 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) err = tx.Commit(ctx)
if err != nil { if err != nil {
log.Errorf("committing transaction: %v", err) log.Errorf("committing transaction: %v", err)
return err return errors.Wrap(err, "committing transaction")
} }
// get fedi instance name if the user has a linked fedi account // get fedi instance name if the user has a linked fedi account
@ -263,12 +323,22 @@ func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
} }
} }
// 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 // echo the updated user back on success
render.JSON(w, r, GetMeResponse{ render.JSON(w, r, GetMeResponse{
GetUserResponse: dbUserToResponse(u, fields, nil), GetUserResponse: dbUserToResponse(u, fields, nil, flags),
CreatedAt: u.ID.Time(),
Timezone: u.Timezone,
MaxInvites: u.MaxInvites, MaxInvites: u.MaxInvites,
IsAdmin: u.IsAdmin, IsAdmin: u.IsAdmin,
ListPrivate: u.ListPrivate, ListPrivate: u.ListPrivate,
LastSIDReroll: u.LastSIDReroll,
Discord: u.Discord, Discord: u.Discord,
DiscordUsername: u.DiscordUsername, DiscordUsername: u.DiscordUsername,
Tumblr: u.Tumblr, Tumblr: u.Tumblr,
@ -318,3 +388,31 @@ func validateSlicePtr[T validator](typ string, slice *[]T, custom db.CustomPrefe
return nil 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 ( import (
"os" "os"
"codeberg.org/u1f320/pronouns.cc/backend/server" "codeberg.org/pronounscc/pronouns.cc/backend/server"
"github.com/go-chi/chi/v5" "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/start", server.WrapHandler(s.startExport))
r.Get("/@me/export", server.WrapHandler(s.getExport)) 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" "net/http"
"strings" "strings"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"codeberg.org/u1f320/pronouns.cc/backend/server/auth" "codeberg.org/pronounscc/pronouns.cc/backend/server/auth"
"github.com/go-chi/render" "github.com/go-chi/render"
) )

View file

@ -6,8 +6,8 @@ import (
"os" "os"
"time" "time"
"codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/log" "codeberg.org/pronounscc/pronouns.cc/backend/log"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/golang-jwt/jwt/v4" "github.com/golang-jwt/jwt/v4"
"github.com/rs/xid" "github.com/rs/xid"

View file

@ -1,10 +1,14 @@
package server package server
import ( import (
"context"
"fmt" "fmt"
"net/http" "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" "github.com/go-chi/render"
) )
@ -12,6 +16,11 @@ import (
// The inner HandlerFunc additionally returns an error. // The inner HandlerFunc additionally returns an error.
func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc { func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
hub := sentry.GetHubFromContext(r.Context())
if hub == nil {
hub = sentry.CurrentHub().Clone()
}
err := hn(w, r) err := hn(w, r)
if err != nil { if err != nil {
// if the function returned an API error, just render that verbatim // if the function returned an API error, just render that verbatim
@ -24,10 +33,20 @@ func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.Han
return return
} }
// otherwise, we log the error and return an internal server error message rctx := chi.RouteContext(r.Context())
log.Errorf("error in http handler: %v", err) hub.ConfigureScope(func(scope *sentry.Scope) {
scope.SetTag("method", rctx.RouteMethod)
scope.SetTag("path", rctx.RoutePattern())
})
apiErr := APIError{Code: ErrInternalServerError} var eventID *sentry.EventID = nil
if isExpectedError(err) {
log.Infof("expected error in handler for %v %v, ignoring", rctx.RouteMethod, rctx.RoutePattern())
} else {
log.Errorf("error in handler for %v %v: %v", rctx.RouteMethod, rctx.RoutePattern(), err)
eventID = hub.CaptureException(err)
}
apiErr := APIError{ID: eventID, Code: ErrInternalServerError}
apiErr.prepare() apiErr.prepare()
render.Status(r, apiErr.Status) render.Status(r, apiErr.Status)
@ -36,10 +55,15 @@ func WrapHandler(hn func(w http.ResponseWriter, r *http.Request) error) http.Han
} }
} }
func isExpectedError(err error) bool {
return errors.Is(err, context.Canceled)
}
// APIError is an object returned by the API when an error occurs. // APIError is an object returned by the API when an error occurs.
// It implements the error interface and can be returned by handlers. // It implements the error interface and can be returned by handlers.
type APIError struct { type APIError struct {
Code int `json:"code"` Code int `json:"code"`
ID *sentry.EventID `json:"id,omitempty"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
Details string `json:"details,omitempty"` Details string `json:"details,omitempty"`
@ -97,10 +121,13 @@ const (
ErrAlreadyLinked = 1014 // user already has linked account of the same type ErrAlreadyLinked = 1014 // user already has linked account of the same type
ErrNotLinked = 1015 // user already doesn't have a linked account ErrNotLinked = 1015 // user already doesn't have a linked account
ErrLastProvider = 1016 // unlinking provider would leave account with no authentication method ErrLastProvider = 1016 // unlinking provider would leave account with no authentication method
ErrInvalidCaptcha = 1017 // invalid or missing captcha response
// User-related error codes // User-related error codes
ErrUserNotFound = 2001 ErrUserNotFound = 2001
ErrMemberListPrivate = 2002 ErrMemberListPrivate = 2002
ErrFlagLimitReached = 2003
ErrRerollingTooQuickly = 2004
// Member-related error codes // Member-related error codes
ErrMemberNotFound = 3001 ErrMemberNotFound = 3001
@ -141,9 +168,12 @@ var errCodeMessages = map[int]string{
ErrAlreadyLinked: "Your account is already linked to an account of this type", 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", ErrNotLinked: "Your account is already not linked to an account of this type",
ErrLastProvider: "This is your account's only authentication provider", 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.", 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", ErrMemberNotFound: "Member not found",
ErrMemberLimitReached: "Member limit reached", ErrMemberLimitReached: "Member limit reached",
@ -181,9 +211,12 @@ var errCodeStatuses = map[int]int{
ErrAlreadyLinked: http.StatusBadRequest, ErrAlreadyLinked: http.StatusBadRequest,
ErrNotLinked: http.StatusBadRequest, ErrNotLinked: http.StatusBadRequest,
ErrLastProvider: http.StatusBadRequest, ErrLastProvider: http.StatusBadRequest,
ErrInvalidCaptcha: http.StatusBadRequest,
ErrUserNotFound: http.StatusNotFound, ErrUserNotFound: http.StatusNotFound,
ErrMemberListPrivate: http.StatusForbidden, ErrMemberListPrivate: http.StatusForbidden,
ErrFlagLimitReached: http.StatusBadRequest,
ErrRerollingTooQuickly: http.StatusForbidden,
ErrMemberNotFound: http.StatusNotFound, ErrMemberNotFound: http.StatusNotFound,
ErrMemberLimitReached: http.StatusBadRequest, 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,14 +6,15 @@ import (
"strconv" "strconv"
"time" "time"
"codeberg.org/u1f320/pronouns.cc/backend/db" "codeberg.org/pronounscc/pronouns.cc/backend/db"
"codeberg.org/u1f320/pronouns.cc/backend/server/auth" "codeberg.org/pronounscc/pronouns.cc/backend/server/auth"
"codeberg.org/u1f320/pronouns.cc/backend/server/rate" "codeberg.org/pronounscc/pronouns.cc/backend/server/rate"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors" "github.com/go-chi/cors"
"github.com/go-chi/httprate" "github.com/go-chi/httprate"
"github.com/go-chi/render" "github.com/go-chi/render"
chiprometheus "github.com/toshi0607/chi-prometheus"
) )
// Revision is the git commit, filled at build time // Revision is the git commit, filled at build time
@ -23,7 +24,7 @@ var (
) )
// Repository is the URL of the git repository // 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 { type Server struct {
Router *chi.Mux Router *chi.Mux
@ -49,15 +50,25 @@ func New() (*Server, error) {
s.Router.Use(middleware.Logger) s.Router.Use(middleware.Logger)
} }
s.Router.Use(middleware.Recoverer) s.Router.Use(middleware.Recoverer)
// add Sentry tracing handler
s.Router.Use(s.sentry)
// add CORS // add CORS
s.Router.Use(cors.Handler(cors.Options{ s.Router.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"https://*", "http://*"}, AllowedOrigins: []string{"https://*", "http://*"},
AllowedMethods: []string{"HEAD", "GET"}, // Allow all methods normally used by the API
AllowedMethods: []string{"HEAD", "GET", "POST", "PATCH", "DELETE"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
AllowCredentials: false, AllowCredentials: false,
MaxAge: 300, 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) // enable authentication for all routes (but don't require it)
s.Router.Use(s.maybeAuth) s.Router.Use(s.maybeAuth)
@ -89,23 +100,23 @@ func New() (*Server, error) {
// set scopes // set scopes
// users // users
rateLimiter.Scope("GET", "/users/*", 60) _ = rateLimiter.Scope("GET", "/users/*", 60)
rateLimiter.Scope("PATCH", "/users/@me", 10) _ = rateLimiter.Scope("PATCH", "/users/@me", 10)
// members // members
rateLimiter.Scope("GET", "/users/*/members", 60) _ = rateLimiter.Scope("GET", "/users/*/members", 60)
rateLimiter.Scope("GET", "/users/*/members/*", 60) _ = rateLimiter.Scope("GET", "/users/*/members/*", 60)
rateLimiter.Scope("POST", "/members", 10) _ = rateLimiter.Scope("POST", "/members", 10)
rateLimiter.Scope("GET", "/members/*", 60) _ = rateLimiter.Scope("GET", "/members/*", 60)
rateLimiter.Scope("PATCH", "/members/*", 20) _ = rateLimiter.Scope("PATCH", "/members/*", 20)
rateLimiter.Scope("DELETE", "/members/*", 5) _ = rateLimiter.Scope("DELETE", "/members/*", 5)
// auth // auth
rateLimiter.Scope("*", "/auth/*", 20) _ = rateLimiter.Scope("*", "/auth/*", 20)
rateLimiter.Scope("*", "/auth/tokens", 10) _ = rateLimiter.Scope("*", "/auth/tokens", 10)
rateLimiter.Scope("*", "/auth/invites", 10) _ = rateLimiter.Scope("*", "/auth/invites", 10)
rateLimiter.Scope("POST", "/auth/discord/*", 10) _ = rateLimiter.Scope("POST", "/auth/discord/*", 10)
s.Router.Use(rateLimiter.Handler()) s.Router.Use(rateLimiter.Handler())

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
}

5
docs/index.md Normal file
View file

@ -0,0 +1,5 @@
# pronouns.cc
pronouns.cc is a service where you can create a list of your preferred names, pronouns, and other terms, and share it with other people.
*Note: this documentation site is a work in progress, and currently only contains (partial) API documentation.*

14
docs/package.json Normal file
View file

@ -0,0 +1,14 @@
{
"devDependencies": {
"vitepress": "1.0.0-rc.4"
},
"scripts": {
"docs:dev": "vitepress dev",
"docs:build": "vitepress build",
"docs:preview": "vitepress preview"
},
"dependencies": {
"@fontsource/fira-mono": "^5.0.8",
"@fontsource/firago": "^5.0.7"
}
}

876
docs/pnpm-lock.yaml generated Normal file
View file

@ -0,0 +1,876 @@
lockfileVersion: '6.0'
dependencies:
'@fontsource/fira-mono':
specifier: ^5.0.8
version: 5.0.8
'@fontsource/firago':
specifier: ^5.0.7
version: 5.0.7
devDependencies:
vitepress:
specifier: 1.0.0-rc.4
version: 1.0.0-rc.4(@algolia/client-search@4.19.1)(search-insights@2.7.0)
packages:
/@algolia/autocomplete-core@1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1)(search-insights@2.7.0):
resolution: {integrity: sha512-009HdfugtGCdC4JdXUbVJClA0q0zh24yyePn+KUGk3rP7j8FEe/m5Yo/z65gn6nP/cM39PxpzqKrL7A6fP6PPw==}
dependencies:
'@algolia/autocomplete-plugin-algolia-insights': 1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1)(search-insights@2.7.0)
'@algolia/autocomplete-shared': 1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1)
transitivePeerDependencies:
- '@algolia/client-search'
- algoliasearch
- search-insights
dev: true
/@algolia/autocomplete-plugin-algolia-insights@1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1)(search-insights@2.7.0):
resolution: {integrity: sha512-a/yTUkcO/Vyy+JffmAnTWbr4/90cLzw+CC3bRbhnULr/EM0fGNvM13oQQ14f2moLMcVDyAx/leczLlAOovhSZg==}
peerDependencies:
search-insights: '>= 1 < 3'
dependencies:
'@algolia/autocomplete-shared': 1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1)
search-insights: 2.7.0
transitivePeerDependencies:
- '@algolia/client-search'
- algoliasearch
dev: true
/@algolia/autocomplete-preset-algolia@1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1):
resolution: {integrity: sha512-d4qlt6YmrLMYy95n5TB52wtNDr6EgAIPH81dvvvW8UmuWRgxEtY0NJiPwl/h95JtG2vmRM804M0DSwMCNZlzRA==}
peerDependencies:
'@algolia/client-search': '>= 4.9.1 < 6'
algoliasearch: '>= 4.9.1 < 6'
dependencies:
'@algolia/autocomplete-shared': 1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1)
'@algolia/client-search': 4.19.1
algoliasearch: 4.19.1
dev: true
/@algolia/autocomplete-shared@1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1):
resolution: {integrity: sha512-Wnm9E4Ye6Rl6sTTqjoymD+l8DjSTHsHboVRYrKgEt8Q7UHm9nYbqhN/i0fhUYA3OAEH7WA8x3jfpnmJm3rKvaQ==}
peerDependencies:
'@algolia/client-search': '>= 4.9.1 < 6'
algoliasearch: '>= 4.9.1 < 6'
dependencies:
'@algolia/client-search': 4.19.1
algoliasearch: 4.19.1
dev: true
/@algolia/cache-browser-local-storage@4.19.1:
resolution: {integrity: sha512-FYAZWcGsFTTaSAwj9Std8UML3Bu8dyWDncM7Ls8g+58UOe4XYdlgzXWbrIgjaguP63pCCbMoExKr61B+ztK3tw==}
dependencies:
'@algolia/cache-common': 4.19.1
dev: true
/@algolia/cache-common@4.19.1:
resolution: {integrity: sha512-XGghi3l0qA38HiqdoUY+wvGyBsGvKZ6U3vTiMBT4hArhP3fOGLXpIINgMiiGjTe4FVlTa5a/7Zf2bwlIHfRqqg==}
dev: true
/@algolia/cache-in-memory@4.19.1:
resolution: {integrity: sha512-+PDWL+XALGvIginigzu8oU6eWw+o76Z8zHbBovWYcrtWOEtinbl7a7UTt3x3lthv+wNuFr/YD1Gf+B+A9V8n5w==}
dependencies:
'@algolia/cache-common': 4.19.1
dev: true
/@algolia/client-account@4.19.1:
resolution: {integrity: sha512-Oy0ritA2k7AMxQ2JwNpfaEcgXEDgeyKu0V7E7xt/ZJRdXfEpZcwp9TOg4TJHC7Ia62gIeT2Y/ynzsxccPw92GA==}
dependencies:
'@algolia/client-common': 4.19.1
'@algolia/client-search': 4.19.1
'@algolia/transporter': 4.19.1
dev: true
/@algolia/client-analytics@4.19.1:
resolution: {integrity: sha512-5QCq2zmgdZLIQhHqwl55ZvKVpLM3DNWjFI4T+bHr3rGu23ew2bLO4YtyxaZeChmDb85jUdPDouDlCumGfk6wOg==}
dependencies:
'@algolia/client-common': 4.19.1
'@algolia/client-search': 4.19.1
'@algolia/requester-common': 4.19.1
'@algolia/transporter': 4.19.1
dev: true
/@algolia/client-common@4.19.1:
resolution: {integrity: sha512-3kAIVqTcPrjfS389KQvKzliC559x+BDRxtWamVJt8IVp7LGnjq+aVAXg4Xogkur1MUrScTZ59/AaUd5EdpyXgA==}
dependencies:
'@algolia/requester-common': 4.19.1
'@algolia/transporter': 4.19.1
dev: true
/@algolia/client-personalization@4.19.1:
resolution: {integrity: sha512-8CWz4/H5FA+krm9HMw2HUQenizC/DxUtsI5oYC0Jxxyce1vsr8cb1aEiSJArQT6IzMynrERif1RVWLac1m36xw==}
dependencies:
'@algolia/client-common': 4.19.1
'@algolia/requester-common': 4.19.1
'@algolia/transporter': 4.19.1
dev: true
/@algolia/client-search@4.19.1:
resolution: {integrity: sha512-mBecfMFS4N+yK/p0ZbK53vrZbL6OtWMk8YmnOv1i0LXx4pelY8TFhqKoTit3NPVPwoSNN0vdSN9dTu1xr1XOVw==}
dependencies:
'@algolia/client-common': 4.19.1
'@algolia/requester-common': 4.19.1
'@algolia/transporter': 4.19.1
dev: true
/@algolia/logger-common@4.19.1:
resolution: {integrity: sha512-i6pLPZW/+/YXKis8gpmSiNk1lOmYCmRI6+x6d2Qk1OdfvX051nRVdalRbEcVTpSQX6FQAoyeaui0cUfLYW5Elw==}
dev: true
/@algolia/logger-console@4.19.1:
resolution: {integrity: sha512-jj72k9GKb9W0c7TyC3cuZtTr0CngLBLmc8trzZlXdfvQiigpUdvTi1KoWIb2ZMcRBG7Tl8hSb81zEY3zI2RlXg==}
dependencies:
'@algolia/logger-common': 4.19.1
dev: true
/@algolia/requester-browser-xhr@4.19.1:
resolution: {integrity: sha512-09K/+t7lptsweRTueHnSnmPqIxbHMowejAkn9XIcJMLdseS3zl8ObnS5GWea86mu3vy4+8H+ZBKkUN82Zsq/zg==}
dependencies:
'@algolia/requester-common': 4.19.1
dev: true
/@algolia/requester-common@4.19.1:
resolution: {integrity: sha512-BisRkcWVxrDzF1YPhAckmi2CFYK+jdMT60q10d7z3PX+w6fPPukxHRnZwooiTUrzFe50UBmLItGizWHP5bDzVQ==}
dev: true
/@algolia/requester-node-http@4.19.1:
resolution: {integrity: sha512-6DK52DHviBHTG2BK/Vv2GIlEw7i+vxm7ypZW0Z7vybGCNDeWzADx+/TmxjkES2h15+FZOqVf/Ja677gePsVItA==}
dependencies:
'@algolia/requester-common': 4.19.1
dev: true
/@algolia/transporter@4.19.1:
resolution: {integrity: sha512-nkpvPWbpuzxo1flEYqNIbGz7xhfhGOKGAZS7tzC+TELgEmi7z99qRyTfNSUlW7LZmB3ACdnqAo+9A9KFBENviQ==}
dependencies:
'@algolia/cache-common': 4.19.1
'@algolia/logger-common': 4.19.1
'@algolia/requester-common': 4.19.1
dev: true
/@babel/helper-string-parser@7.22.5:
resolution: {integrity: sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==}
engines: {node: '>=6.9.0'}
dev: true
/@babel/helper-validator-identifier@7.22.5:
resolution: {integrity: sha512-aJXu+6lErq8ltp+JhkJUfk1MTGyuA4v7f3pA+BJ5HLfNC6nAQ0Cpi9uOquUj8Hehg0aUiHzWQbOVJGao6ztBAQ==}
engines: {node: '>=6.9.0'}
dev: true
/@babel/parser@7.22.10:
resolution: {integrity: sha512-lNbdGsQb9ekfsnjFGhEiF4hfFqGgfOP3H3d27re3n+CGhNuTSUEQdfWk556sTLNTloczcdM5TYF2LhzmDQKyvQ==}
engines: {node: '>=6.0.0'}
hasBin: true
dependencies:
'@babel/types': 7.22.10
dev: true
/@babel/types@7.22.10:
resolution: {integrity: sha512-obaoigiLrlDZ7TUQln/8m4mSqIW2QFeOrCQc9r+xsaHGNoplVNYlRVpsfE8Vj35GEm2ZH4ZhrNYogs/3fj85kg==}
engines: {node: '>=6.9.0'}
dependencies:
'@babel/helper-string-parser': 7.22.5
'@babel/helper-validator-identifier': 7.22.5
to-fast-properties: 2.0.0
dev: true
/@docsearch/css@3.5.2:
resolution: {integrity: sha512-SPiDHaWKQZpwR2siD0KQUwlStvIAnEyK6tAE2h2Wuoq8ue9skzhlyVQ1ddzOxX6khULnAALDiR/isSF3bnuciA==}
dev: true
/@docsearch/js@3.5.2(@algolia/client-search@4.19.1)(search-insights@2.7.0):
resolution: {integrity: sha512-p1YFTCDflk8ieHgFJYfmyHBki1D61+U9idwrLh+GQQMrBSP3DLGKpy0XUJtPjAOPltcVbqsTjiPFfH7JImjUNg==}
dependencies:
'@docsearch/react': 3.5.2(@algolia/client-search@4.19.1)(search-insights@2.7.0)
preact: 10.17.1
transitivePeerDependencies:
- '@algolia/client-search'
- '@types/react'
- react
- react-dom
- search-insights
dev: true
/@docsearch/react@3.5.2(@algolia/client-search@4.19.1)(search-insights@2.7.0):
resolution: {integrity: sha512-9Ahcrs5z2jq/DcAvYtvlqEBHImbm4YJI8M9y0x6Tqg598P40HTEkX7hsMcIuThI+hTFxRGZ9hll0Wygm2yEjng==}
peerDependencies:
'@types/react': '>= 16.8.0 < 19.0.0'
react: '>= 16.8.0 < 19.0.0'
react-dom: '>= 16.8.0 < 19.0.0'
search-insights: '>= 1 < 3'
peerDependenciesMeta:
'@types/react':
optional: true
react:
optional: true
react-dom:
optional: true
search-insights:
optional: true
dependencies:
'@algolia/autocomplete-core': 1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1)(search-insights@2.7.0)
'@algolia/autocomplete-preset-algolia': 1.9.3(@algolia/client-search@4.19.1)(algoliasearch@4.19.1)
'@docsearch/css': 3.5.2
algoliasearch: 4.19.1
search-insights: 2.7.0
transitivePeerDependencies:
- '@algolia/client-search'
dev: true
/@esbuild/android-arm64@0.18.20:
resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==}
engines: {node: '>=12'}
cpu: [arm64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-arm@0.18.20:
resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==}
engines: {node: '>=12'}
cpu: [arm]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/android-x64@0.18.20:
resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==}
engines: {node: '>=12'}
cpu: [x64]
os: [android]
requiresBuild: true
dev: true
optional: true
/@esbuild/darwin-arm64@0.18.20:
resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==}
engines: {node: '>=12'}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@esbuild/darwin-x64@0.18.20:
resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@esbuild/freebsd-arm64@0.18.20:
resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==}
engines: {node: '>=12'}
cpu: [arm64]
os: [freebsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/freebsd-x64@0.18.20:
resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [freebsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-arm64@0.18.20:
resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==}
engines: {node: '>=12'}
cpu: [arm64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-arm@0.18.20:
resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==}
engines: {node: '>=12'}
cpu: [arm]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-ia32@0.18.20:
resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==}
engines: {node: '>=12'}
cpu: [ia32]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-loong64@0.18.20:
resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==}
engines: {node: '>=12'}
cpu: [loong64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-mips64el@0.18.20:
resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==}
engines: {node: '>=12'}
cpu: [mips64el]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-ppc64@0.18.20:
resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==}
engines: {node: '>=12'}
cpu: [ppc64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-riscv64@0.18.20:
resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==}
engines: {node: '>=12'}
cpu: [riscv64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-s390x@0.18.20:
resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==}
engines: {node: '>=12'}
cpu: [s390x]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/linux-x64@0.18.20:
resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==}
engines: {node: '>=12'}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@esbuild/netbsd-x64@0.18.20:
resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==}
engines: {node: '>=12'}
cpu: [x64]
os: [netbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/openbsd-x64@0.18.20:
resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==}
engines: {node: '>=12'}
cpu: [x64]
os: [openbsd]
requiresBuild: true
dev: true
optional: true
/@esbuild/sunos-x64@0.18.20:
resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [sunos]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-arm64@0.18.20:
resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==}
engines: {node: '>=12'}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-ia32@0.18.20:
resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==}
engines: {node: '>=12'}
cpu: [ia32]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@esbuild/win32-x64@0.18.20:
resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==}
engines: {node: '>=12'}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@fontsource/fira-mono@5.0.8:
resolution: {integrity: sha512-8OJiUK2lzJjvDlkmamEfhtpL1cyFApg1Pk4kE5Pw5UTf1ETF3Yy/pprgwV5I+LQPDjuFvinsinT9xSUZ2b/zuQ==}
dev: false
/@fontsource/firago@5.0.7:
resolution: {integrity: sha512-xuTYVOBSwev2IVp2dqgrnq3gABUnehn91Ii+R1TM5Jpvr86gCPrMxmqfL9fgpUb5r12u7U1LBVC20GypIy8jeg==}
dev: false
/@jridgewell/sourcemap-codec@1.4.15:
resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==}
dev: true
/@types/web-bluetooth@0.0.17:
resolution: {integrity: sha512-4p9vcSmxAayx72yn70joFoL44c9MO/0+iVEBIQXe3v2h2SiAsEIo/G5v6ObFWvNKRFjbrVadNf9LqEEZeQPzdA==}
dev: true
/@vitejs/plugin-vue@4.3.1(vite@4.4.9)(vue@3.3.4):
resolution: {integrity: sha512-tUBEtWcF7wFtII7ayNiLNDTCE1X1afySEo+XNVMNkFXaThENyCowIEX095QqbJZGTgoOcSVDJGlnde2NG4jtbQ==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
vite: ^4.0.0
vue: ^3.2.25
dependencies:
vite: 4.4.9
vue: 3.3.4
dev: true
/@vue/compiler-core@3.3.4:
resolution: {integrity: sha512-cquyDNvZ6jTbf/+x+AgM2Arrp6G4Dzbb0R64jiG804HRMfRiFXWI6kqUVqZ6ZR0bQhIoQjB4+2bhNtVwndW15g==}
dependencies:
'@babel/parser': 7.22.10
'@vue/shared': 3.3.4
estree-walker: 2.0.2
source-map-js: 1.0.2
dev: true
/@vue/compiler-dom@3.3.4:
resolution: {integrity: sha512-wyM+OjOVpuUukIq6p5+nwHYtj9cFroz9cwkfmP9O1nzH68BenTTv0u7/ndggT8cIQlnBeOo6sUT/gvHcIkLA5w==}
dependencies:
'@vue/compiler-core': 3.3.4
'@vue/shared': 3.3.4
dev: true
/@vue/compiler-sfc@3.3.4:
resolution: {integrity: sha512-6y/d8uw+5TkCuzBkgLS0v3lSM3hJDntFEiUORM11pQ/hKvkhSKZrXW6i69UyXlJQisJxuUEJKAWEqWbWsLeNKQ==}
dependencies:
'@babel/parser': 7.22.10
'@vue/compiler-core': 3.3.4
'@vue/compiler-dom': 3.3.4
'@vue/compiler-ssr': 3.3.4
'@vue/reactivity-transform': 3.3.4
'@vue/shared': 3.3.4
estree-walker: 2.0.2
magic-string: 0.30.2
postcss: 8.4.28
source-map-js: 1.0.2
dev: true
/@vue/compiler-ssr@3.3.4:
resolution: {integrity: sha512-m0v6oKpup2nMSehwA6Uuu+j+wEwcy7QmwMkVNVfrV9P2qE5KshC6RwOCq8fjGS/Eak/uNb8AaWekfiXxbBB6gQ==}
dependencies:
'@vue/compiler-dom': 3.3.4
'@vue/shared': 3.3.4
dev: true
/@vue/devtools-api@6.5.0:
resolution: {integrity: sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q==}
dev: true
/@vue/reactivity-transform@3.3.4:
resolution: {integrity: sha512-MXgwjako4nu5WFLAjpBnCj/ieqcjE2aJBINUNQzkZQfzIZA4xn+0fV1tIYBJvvva3N3OvKGofRLvQIwEQPpaXw==}
dependencies:
'@babel/parser': 7.22.10
'@vue/compiler-core': 3.3.4
'@vue/shared': 3.3.4
estree-walker: 2.0.2
magic-string: 0.30.2
dev: true
/@vue/reactivity@3.3.4:
resolution: {integrity: sha512-kLTDLwd0B1jG08NBF3R5rqULtv/f8x3rOFByTDz4J53ttIQEDmALqKqXY0J+XQeN0aV2FBxY8nJDf88yvOPAqQ==}
dependencies:
'@vue/shared': 3.3.4
dev: true
/@vue/runtime-core@3.3.4:
resolution: {integrity: sha512-R+bqxMN6pWO7zGI4OMlmvePOdP2c93GsHFM/siJI7O2nxFRzj55pLwkpCedEY+bTMgp5miZ8CxfIZo3S+gFqvA==}
dependencies:
'@vue/reactivity': 3.3.4
'@vue/shared': 3.3.4
dev: true
/@vue/runtime-dom@3.3.4:
resolution: {integrity: sha512-Aj5bTJ3u5sFsUckRghsNjVTtxZQ1OyMWCr5dZRAPijF/0Vy4xEoRCwLyHXcj4D0UFbJ4lbx3gPTgg06K/GnPnQ==}
dependencies:
'@vue/runtime-core': 3.3.4
'@vue/shared': 3.3.4
csstype: 3.1.2
dev: true
/@vue/server-renderer@3.3.4(vue@3.3.4):
resolution: {integrity: sha512-Q6jDDzR23ViIb67v+vM1Dqntu+HUexQcsWKhhQa4ARVzxOY2HbC7QRW/ggkDBd5BU+uM1sV6XOAP0b216o34JQ==}
peerDependencies:
vue: 3.3.4
dependencies:
'@vue/compiler-ssr': 3.3.4
'@vue/shared': 3.3.4
vue: 3.3.4
dev: true
/@vue/shared@3.3.4:
resolution: {integrity: sha512-7OjdcV8vQ74eiz1TZLzZP4JwqM5fA94K6yntPS5Z25r9HDuGNzaGdgvwKYq6S+MxwF0TFRwe50fIR/MYnakdkQ==}
dev: true
/@vueuse/core@10.3.0(vue@3.3.4):
resolution: {integrity: sha512-BEM5yxcFKb5btFjTSAFjTu5jmwoW66fyV9uJIP4wUXXU8aR5Hl44gndaaXp7dC5HSObmgbnR2RN+Un1p68Mf5Q==}
dependencies:
'@types/web-bluetooth': 0.0.17
'@vueuse/metadata': 10.3.0
'@vueuse/shared': 10.3.0(vue@3.3.4)
vue-demi: 0.14.5(vue@3.3.4)
transitivePeerDependencies:
- '@vue/composition-api'
- vue
dev: true
/@vueuse/integrations@10.3.0(focus-trap@7.5.2)(vue@3.3.4):
resolution: {integrity: sha512-Jgiv7oFyIgC6BxmDtiyG/fxyGysIds00YaY7sefwbhCZ2/tjEx1W/1WcsISSJPNI30in28+HC2J4uuU8184ekg==}
peerDependencies:
async-validator: '*'
axios: '*'
change-case: '*'
drauu: '*'
focus-trap: '*'
fuse.js: '*'
idb-keyval: '*'
jwt-decode: '*'
nprogress: '*'
qrcode: '*'
sortablejs: '*'
universal-cookie: '*'
peerDependenciesMeta:
async-validator:
optional: true
axios:
optional: true
change-case:
optional: true
drauu:
optional: true
focus-trap:
optional: true
fuse.js:
optional: true
idb-keyval:
optional: true
jwt-decode:
optional: true
nprogress:
optional: true
qrcode:
optional: true
sortablejs:
optional: true
universal-cookie:
optional: true
dependencies:
'@vueuse/core': 10.3.0(vue@3.3.4)
'@vueuse/shared': 10.3.0(vue@3.3.4)
focus-trap: 7.5.2
vue-demi: 0.14.5(vue@3.3.4)
transitivePeerDependencies:
- '@vue/composition-api'
- vue
dev: true
/@vueuse/metadata@10.3.0:
resolution: {integrity: sha512-Ema3YhNOa4swDsV0V7CEY5JXvK19JI/o1szFO1iWxdFg3vhdFtCtSTP26PCvbUpnUtNHBY2wx5y3WDXND5Pvnw==}
dev: true
/@vueuse/shared@10.3.0(vue@3.3.4):
resolution: {integrity: sha512-kGqCTEuFPMK4+fNWy6dUOiYmxGcUbtznMwBZLC1PubidF4VZY05B+Oht7Jh7/6x4VOWGpvu3R37WHi81cKpiqg==}
dependencies:
vue-demi: 0.14.5(vue@3.3.4)
transitivePeerDependencies:
- '@vue/composition-api'
- vue
dev: true
/algoliasearch@4.19.1:
resolution: {integrity: sha512-IJF5b93b2MgAzcE/tuzW0yOPnuUyRgGAtaPv5UUywXM8kzqfdwZTO4sPJBzoGz1eOy6H9uEchsJsBFTELZSu+g==}
dependencies:
'@algolia/cache-browser-local-storage': 4.19.1
'@algolia/cache-common': 4.19.1
'@algolia/cache-in-memory': 4.19.1
'@algolia/client-account': 4.19.1
'@algolia/client-analytics': 4.19.1
'@algolia/client-common': 4.19.1
'@algolia/client-personalization': 4.19.1
'@algolia/client-search': 4.19.1
'@algolia/logger-common': 4.19.1
'@algolia/logger-console': 4.19.1
'@algolia/requester-browser-xhr': 4.19.1
'@algolia/requester-common': 4.19.1
'@algolia/requester-node-http': 4.19.1
'@algolia/transporter': 4.19.1
dev: true
/ansi-sequence-parser@1.1.1:
resolution: {integrity: sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==}
dev: true
/body-scroll-lock@4.0.0-beta.0:
resolution: {integrity: sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ==}
dev: true
/csstype@3.1.2:
resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==}
dev: true
/esbuild@0.18.20:
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
engines: {node: '>=12'}
hasBin: true
requiresBuild: true
optionalDependencies:
'@esbuild/android-arm': 0.18.20
'@esbuild/android-arm64': 0.18.20
'@esbuild/android-x64': 0.18.20
'@esbuild/darwin-arm64': 0.18.20
'@esbuild/darwin-x64': 0.18.20
'@esbuild/freebsd-arm64': 0.18.20
'@esbuild/freebsd-x64': 0.18.20
'@esbuild/linux-arm': 0.18.20
'@esbuild/linux-arm64': 0.18.20
'@esbuild/linux-ia32': 0.18.20
'@esbuild/linux-loong64': 0.18.20
'@esbuild/linux-mips64el': 0.18.20
'@esbuild/linux-ppc64': 0.18.20
'@esbuild/linux-riscv64': 0.18.20
'@esbuild/linux-s390x': 0.18.20
'@esbuild/linux-x64': 0.18.20
'@esbuild/netbsd-x64': 0.18.20
'@esbuild/openbsd-x64': 0.18.20
'@esbuild/sunos-x64': 0.18.20
'@esbuild/win32-arm64': 0.18.20
'@esbuild/win32-ia32': 0.18.20
'@esbuild/win32-x64': 0.18.20
dev: true
/estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
dev: true
/focus-trap@7.5.2:
resolution: {integrity: sha512-p6vGNNWLDGwJCiEjkSK6oERj/hEyI9ITsSwIUICBoKLlWiTWXJRfQibCwcoi50rTZdbi87qDtUlMCmQwsGSgPw==}
dependencies:
tabbable: 6.2.0
dev: true
/fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
requiresBuild: true
dev: true
optional: true
/jsonc-parser@3.2.0:
resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==}
dev: true
/magic-string@0.30.2:
resolution: {integrity: sha512-lNZdu7pewtq/ZvWUp9Wpf/x7WzMTsR26TWV03BRZrXFsv+BI6dy8RAiKgm1uM/kyR0rCfUcqvOlXKG66KhIGug==}
engines: {node: '>=12'}
dependencies:
'@jridgewell/sourcemap-codec': 1.4.15
dev: true
/mark.js@8.11.1:
resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==}
dev: true
/minisearch@6.1.0:
resolution: {integrity: sha512-PNxA/X8pWk+TiqPbsoIYH0GQ5Di7m6326/lwU/S4mlo4wGQddIcf/V//1f9TB0V4j59b57b+HZxt8h3iMROGvg==}
dev: true
/nanoid@3.3.6:
resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
dev: true
/picocolors@1.0.0:
resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==}
dev: true
/postcss@8.4.28:
resolution: {integrity: sha512-Z7V5j0cq8oEKyejIKfpD8b4eBy9cwW2JWPk0+fB1HOAMsfHbnAXLLS+PfVWlzMSLQaWttKDt607I0XHmpE67Vw==}
engines: {node: ^10 || ^12 || >=14}
dependencies:
nanoid: 3.3.6
picocolors: 1.0.0
source-map-js: 1.0.2
dev: true
/preact@10.17.1:
resolution: {integrity: sha512-X9BODrvQ4Ekwv9GURm9AKAGaomqXmip7NQTZgY7gcNmr7XE83adOMJvd3N42id1tMFU7ojiynRsYnY6/BRFxLA==}
dev: true
/rollup@3.28.0:
resolution: {integrity: sha512-d7zhvo1OUY2SXSM6pfNjgD5+d0Nz87CUp4mt8l/GgVP3oBsPwzNvSzyu1me6BSG9JIgWNTVcafIXBIyM8yQ3yw==}
engines: {node: '>=14.18.0', npm: '>=8.0.0'}
hasBin: true
optionalDependencies:
fsevents: 2.3.2
dev: true
/search-insights@2.7.0:
resolution: {integrity: sha512-GLbVaGgzYEKMvuJbHRhLi1qoBFnjXZGZ6l4LxOYPCp4lI2jDRB3jPU9/XNhMwv6kvnA9slTreq6pvK+b3o3aqg==}
engines: {node: '>=8.16.0'}
dev: true
/shiki@0.14.3:
resolution: {integrity: sha512-U3S/a+b0KS+UkTyMjoNojvTgrBHjgp7L6ovhFVZsXmBGnVdQ4K4U9oK0z63w538S91ATngv1vXigHCSWOwnr+g==}
dependencies:
ansi-sequence-parser: 1.1.1
jsonc-parser: 3.2.0
vscode-oniguruma: 1.7.0
vscode-textmate: 8.0.0
dev: true
/source-map-js@1.0.2:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'}
dev: true
/tabbable@6.2.0:
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
dev: true
/to-fast-properties@2.0.0:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
engines: {node: '>=4'}
dev: true
/vite@4.4.9:
resolution: {integrity: sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==}
engines: {node: ^14.18.0 || >=16.0.0}
hasBin: true
peerDependencies:
'@types/node': '>= 14'
less: '*'
lightningcss: ^1.21.0
sass: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
peerDependenciesMeta:
'@types/node':
optional: true
less:
optional: true
lightningcss:
optional: true
sass:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
dependencies:
esbuild: 0.18.20
postcss: 8.4.28
rollup: 3.28.0
optionalDependencies:
fsevents: 2.3.2
dev: true
/vitepress@1.0.0-rc.4(@algolia/client-search@4.19.1)(search-insights@2.7.0):
resolution: {integrity: sha512-JCQ89Bm6ECUTnyzyas3JENo00UDJeK8q1SUQyJYou+4Yz5BKEc/F3O21cu++DnUT2zXc0kvQ2Aj4BZCc/nioXQ==}
hasBin: true
dependencies:
'@docsearch/css': 3.5.2
'@docsearch/js': 3.5.2(@algolia/client-search@4.19.1)(search-insights@2.7.0)
'@vitejs/plugin-vue': 4.3.1(vite@4.4.9)(vue@3.3.4)
'@vue/devtools-api': 6.5.0
'@vueuse/core': 10.3.0(vue@3.3.4)
'@vueuse/integrations': 10.3.0(focus-trap@7.5.2)(vue@3.3.4)
body-scroll-lock: 4.0.0-beta.0
focus-trap: 7.5.2
mark.js: 8.11.1
minisearch: 6.1.0
shiki: 0.14.3
vite: 4.4.9
vue: 3.3.4
transitivePeerDependencies:
- '@algolia/client-search'
- '@types/node'
- '@types/react'
- '@vue/composition-api'
- async-validator
- axios
- change-case
- drauu
- fuse.js
- idb-keyval
- jwt-decode
- less
- lightningcss
- nprogress
- qrcode
- react
- react-dom
- sass
- search-insights
- sortablejs
- stylus
- sugarss
- terser
- universal-cookie
dev: true
/vscode-oniguruma@1.7.0:
resolution: {integrity: sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==}
dev: true
/vscode-textmate@8.0.0:
resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==}
dev: true
/vue-demi@0.14.5(vue@3.3.4):
resolution: {integrity: sha512-o9NUVpl/YlsGJ7t+xuqJKx8EBGf1quRhCiT6D/J0pfwmk9zUwYkC7yrF4SZCe6fETvSM3UNL2edcbYrSyc4QHA==}
engines: {node: '>=12'}
hasBin: true
requiresBuild: true
peerDependencies:
'@vue/composition-api': ^1.0.0-rc.1
vue: ^3.0.0-0 || ^2.6.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
dependencies:
vue: 3.3.4
dev: true
/vue@3.3.4:
resolution: {integrity: sha512-VTyEYn3yvIeY1Py0WaYGZsXnz3y5UnGi62GjVEqvEGPl6nxbOrCXbVOTQWBEJUqAyTUk2uJ5JLVnYJ6ZzGbrSw==}
dependencies:
'@vue/compiler-dom': 3.3.4
'@vue/compiler-sfc': 3.3.4
'@vue/runtime-dom': 3.3.4
'@vue/server-renderer': 3.3.4(vue@3.3.4)
'@vue/shared': 3.3.4
dev: true

1
docs/public/logo.svg Normal file
View file

@ -0,0 +1 @@
<svg width="11.411mm" height="11.076mm" version="1.1" viewBox="1 1 11.245 10.218" xmlns="http://www.w3.org/2000/svg"><defs><clipPath id="clipPath16"><path d="m0 38h38v-38h-38z"/></clipPath></defs><g transform="translate(-49.754 -142.39)"><g transform="matrix(.33073 0 0 -.33073 50.093 154.62)" clip-path="url(#clipPath16)"><path d="m35.347 20.107-8.899 3.294-3.323 10.891c-0.128 0.42-0.516 0.708-0.956 0.708-0.439 0-0.828-0.288-0.956-0.708l-3.322-10.891-8.9-3.294c-0.393-0.146-0.653-0.52-0.653-0.938s0.26-0.793 0.653-0.937l8.896-3.293 3.323-11.223c0.126-0.425 0.516-0.716 0.959-0.716s0.833 0.291 0.959 0.716l3.324 11.223 8.896 3.293c0.392 0.144 0.652 0.519 0.652 0.937s-0.26 0.792-0.653 0.938" fill="#aa8ed6"/><path d="m15.347 9.1064-2.313 0.856-0.9 3.3c-0.119 0.436-0.514 0.738-0.965 0.738s-0.846-0.302-0.965-0.738l-0.9-3.3-2.313-0.856c-0.393-0.145-0.653-0.52-0.653-0.937 0-0.418 0.26-0.793 0.653-0.938l2.301-0.853 0.907-3.622c0.111-0.444 0.511-0.756 0.97-0.756 0.458 0 0.858 0.312 0.97 0.756l0.907 3.622 2.301 0.853c0.393 0.145 0.653 0.52 0.653 0.938 0 0.417-0.26 0.792-0.653 0.937" fill="#fcab40"/><path d="m11.009 30.769-2.365 0.875-0.875 2.365c-0.146 0.393-0.52 0.653-0.938 0.653-0.419 0-0.793-0.26-0.938-0.653l-0.876-2.365-2.364-0.875c-0.393-0.146-0.653-0.52-0.653-0.938s0.26-0.792 0.653-0.938l2.364-0.875 0.876-2.365c0.145-0.393 0.519-0.653 0.938-0.653 0.418 0 0.792 0.26 0.938 0.653l0.875 2.365 2.365 0.875c0.393 0.146 0.653 0.52 0.653 0.938s-0.26 0.792-0.653 0.938" fill="#5dadec"/></g></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -6,12 +6,11 @@ You might have to change paths and ports, but they should work fine as-is.
## Building pronouns.cc ## Building pronouns.cc
```bash ```bash
git clone https://codeberg.org/u1f320/pronouns.cc.git pronouns git clone https://codeberg.org/pronounscc/pronouns.cc.git pronouns
cd pronouns cd pronouns
git checkout stable
make all make all
# if required fonts have not been downloaded yet
./download-fonts.sh
# if running for the first time # if running for the first time
./pronouns database migrate ./pronouns database migrate
``` ```
@ -23,7 +22,7 @@ one in the repository root (for the backend) and one in the frontend directory.
### Backend keys ### Backend keys
- `HMAC_KEY`: the key used to sign tokens. This should be a base64 string, you can generate one with `scripts/genkey`. - `HMAC_KEY`: the key used to sign tokens. This should be a base64 string, you can generate one with `go run -v . generate key` (or `./pronouns generate key` after building).
- `DATABASE_URL`: the URL for the PostgreSQL database. - `DATABASE_URL`: the URL for the PostgreSQL database.
- `REDIS`: the URL for the Redis database. - `REDIS`: the URL for the Redis database.
- `PORT` (int): the port the backend will listen on. - `PORT` (int): the port the backend will listen on.
@ -45,6 +44,8 @@ one in the repository root (for the backend) and one in the frontend directory.
- `PUBLIC_BASE_URL`: the base URL for the frontend. - `PUBLIC_BASE_URL`: the base URL for the frontend.
- `PRIVATE_SENTRY_DSN`: your Sentry DSN. - `PRIVATE_SENTRY_DSN`: your Sentry DSN.
- `PUBLIC_MEDIA_URL`: the base URL for media.
If you're proxying your media through nginx as in `pronounscc.nginx`, set this to `$PUBLIC_BASE_URL/media`.
## Updating ## Updating
@ -62,9 +63,6 @@ systemctl start pronouns-exporter # if the exporter was stopped
Both the backend and frontend are expected to run behind a reverse proxy such as Caddy or nginx. Both the backend and frontend are expected to run behind a reverse proxy such as Caddy or nginx.
This directory contains a sample configuration file for nginx. This directory contains a sample configuration file for nginx.
Every path should be proxied to the frontend, except: Every path should be proxied to the frontend, except for `/api/`:
this should be proxied to the backend, with the URL being rewritten to remove `/api`
- `/api/`: this should be proxied to the backend, with the URL being rewritten to remove `/api` (for example, a request to `$DOMAIN/api/v1/users/@me` should be proxied to `localhost:8080/v1/users/@me`)
(for example, a request to `$DOMAIN/api/v1/users/@me` should be proxied to `localhost:8080/v1/users/@me`)
- `/media/`: this should be proxied to your object storage.
Make sure to rewrite `/media` into your storage bucket's name.

View file

@ -1,3 +0,0 @@
#!/bin/sh
curl "https://free.bboxtype.com/embedfonts/fonts.php?family=FiraGO&weight=400" -o frontend/static/fonts/FiraGO-400.woff "https://free.bboxtype.com/embedfonts/fonts.php?family=FiraGO&weight=400i" -o frontend/static/fonts/FiraGO-400i.woff "https://free.bboxtype.com/embedfonts/fonts.php?family=FiraGO&weight=700" -o frontend/static/fonts/FiraGO-700.woff "https://free.bboxtype.com/embedfonts/fonts.php?family=FiraGO&weight=700i" -o frontend/static/fonts/FiraGO-700i.woff

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