Compare commits

...

257 commits
v0.1.0 ... main

Author SHA1 Message Date
sam
b0431ff962
feat(frontend): global notices 2025-04-06 16:24:05 +02:00
sam
b07f4b75c0
feat(backend): global notices 2025-04-06 15:32:44 +02:00
sam
22be49976a
feat(backend): return settings in GET /users/@me 2025-04-06 15:32:26 +02:00
sam
3527acb8ba
feat: add pre-built docker images 2025-03-18 15:38:06 +01:00
sam
978b8e100e
remove unused MetricsAddress from config 2025-03-18 15:03:03 +01:00
sam
f00f5b400e
feat(frontend): allow configuring assets url 2025-03-17 22:46:44 +01:00
sam
f5f0416346
refactor(backend): misc cleanup 2025-03-13 15:18:35 +01:00
sam
5d452824cd
refactor(backend): use single shared HTTP client with backoff 2025-03-11 16:15:11 +01:00
sam
bba322bd22
chore(backend): update dependencies 2025-03-08 23:46:46 +01:00
sam
200e648772
fix(backend): update User.LastActive in more places 2025-03-05 15:40:13 +01:00
sam
790b39f730
fix(frontend): consistency in the editor 2025-03-05 15:13:44 +01:00
sam
7d0df67c06
fix(frontend): fix moving pronouns 2025-03-05 15:13:26 +01:00
sam
dd9d35249c
feat(frontend): notifications 2025-03-05 01:18:21 +01:00
sam
f99d10ecf0
fix(backend): don't hardcode redis URL, add redis to docker compose 2025-03-04 17:25:07 +01:00
sam
7759225428
refactor(backend): replace coravel with hangfire for background jobs
for *some reason*, coravel locks a persistent job queue behind a
paywall. this means that if the server ever crashes, all pending jobs
are lost. this is... not good, so we're switching to hangfire for that
instead.

coravel is still used for emails, though.

BREAKING CHANGE: Foxnouns.NET now requires Redis to work. the EFCore
storage for hangfire doesn't work well enough, unfortunately.
2025-03-04 17:03:39 +01:00
sam
cd24196cd1
chore(backend): format 2025-02-28 16:53:53 +01:00
sam
7d6d4631b8
fix(frontend): don't reference email auth in text if it's disabled 2025-02-28 16:50:57 +01:00
sam
a248536789
fix typo in DOCKER.md 2025-02-28 16:47:21 +01:00
sam
218c756a70
feat(backend): make field limits configurable 2025-02-28 16:37:15 +01:00
sam
7ea6c62d67
chore(backend): update dependencies 2025-02-28 16:36:45 +01:00
sam
64ea25e89e
feat(frontend): avatar cropping 2025-02-24 21:32:20 +01:00
sam
f1f777ff82
fix(frontend): localize footer 2025-02-24 20:37:51 +01:00
sam
a72c0f41c3
add build script 2025-02-24 18:25:49 +01:00
sam
6fe816404f
rename rate/ to Foxnouns.RateLimiter/ for consistency 2025-02-24 17:47:37 +01:00
sam
d1faf1ddee
feat(frontend): store pending profile changes in sessionStorage 2025-02-24 17:37:49 +01:00
sam
92bf933c10
feat(frontend): link custom preferences in profile editor 2025-02-24 17:13:46 +01:00
sam
c8e4078b35
fix: show 404 page if /api/v2/meta/page/{page} can't find a file 2025-02-23 21:42:01 +01:00
sam
0c6e3bf38f
feat(frontend): show closed reports button, add some alerts for auth 2025-02-23 20:02:40 +01:00
sam
30146556f5
chore: update frontend dockerfile to node 23 2025-02-11 14:57:07 +01:00
sam
c47fc41437
feat(frontend): remove auth method 2025-02-11 14:21:40 +01:00
sam
373d97e70a
feat: make some limits configurable 2025-02-07 21:48:50 +01:00
sam
74800b46ef
feat(frontend): don't break signup pages on reload 2025-02-07 20:57:27 +01:00
sam
32e0c09d06
fix(backend): add thousands separators to footer 2025-02-07 20:33:59 +01:00
sam
6bb01f0bf1
feat(frontend): show audit log entry for closed reports 2025-02-03 17:35:34 +01:00
sam
cacd3a30b7
feat: report page, take action on reports 2025-02-03 17:03:32 +01:00
sam
a0ba712632
feat(frontend): show error ID for internal server errors 2025-01-30 02:00:49 +01:00
sam
83b62b4845
chore: update husky/csharpier 2025-01-27 16:26:00 +01:00
sam
045964ffb7
feat(backend): report detail endpoint 2025-01-27 16:25:49 +01:00
sam
8edbc8bf1d
feat(backend): only one sensitive data request per 24 hours 2024-12-29 16:34:11 -05:00
sam
db22e35f0d
feat(frontend): partial user lookup 2024-12-28 11:39:22 -05:00
sam
9d3d46bf33
feat(frontend): show "query sensitive data" in audit log 2024-12-27 17:49:29 -05:00
sam
12eddb9949
feat(backend): user lookup 2024-12-27 17:48:37 -05:00
sam
8713279d3d
raise member limit to 1000 2024-12-27 13:34:54 -05:00
sam
dc9c11ec52
feat: return reports in audit log entries 2024-12-27 13:21:02 -05:00
sam
53006ea313
feat(frontend): audit log 2024-12-26 16:33:32 -05:00
sam
49e9eabea0
refactor(frontend): deduplicate isActive function 2024-12-26 14:10:03 -05:00
sam
5077bd6a0b
fix(backend): return report context in mod api 2024-12-26 14:01:51 -05:00
sam
3f0edc4374
static pages volume in docker-compose.yml 2024-12-26 10:25:00 -05:00
sam
7468aa20ab
feat: static documentation pages 2024-12-25 17:53:31 -05:00
sam
fe1cf7ce8a
feat: GET /api/v1/users/@me 2024-12-25 16:04:32 -05:00
sam
478ba2a406
feat: GET /api/v1/users/{userRef}/members/{memberRef} 2024-12-25 14:53:36 -05:00
sam
78afb8b9c4
feat: GET /api/v1/users/{userRef}/members 2024-12-25 14:33:42 -05:00
sam
e908e67ca6
chore: license headers 2024-12-25 14:24:18 -05:00
sam
d182b07482
feat: GET /api/v1/members/{id}, api v1 flags 2024-12-25 14:23:16 -05:00
sam
2281b3e478
fix: replace port 5000 in example docs with port 6000
macOS runs a service on port 5000 by default. this doesn't actually
prevent the backend server from *starting*, or the rate limiter proxy
from working, but it *does* mean that when the backend restarts, if the
proxy sends a request, it will stop working until it's restarted.

the easiest way to work around this is by just changing the port the
backend listens on. this does not change the ports used in the docker
configuration.
2024-12-25 14:03:15 -05:00
sam
140419a1ca
feat: rate limiter lets api v1 requests through 2024-12-25 12:08:53 -05:00
sam
7791c91960
feat(backend): initial /api/v1/users endpoint 2024-12-25 11:19:50 -05:00
sam
5e7df2e074
feat(frontend): add footer 2024-12-25 11:04:20 -05:00
sam
e24c4f9b00
feat(frontend): self-service delete, force delete pages 2024-12-19 17:15:50 +01:00
sam
3f8f6d0f23
delete stray console.log 2024-12-19 16:24:17 +01:00
sam
661c3eab0f
fix(backend): save data exports as data-export.zip
change the random base 64 to a directory rather than part of the
filename, so that users downloading their exports aren't greeted with a
completely incomprehensible file in their downloads folder
2024-12-19 16:19:27 +01:00
sam
96725cc304
feat: self-service deletion API, reactivate account page 2024-12-19 16:13:05 +01:00
sam
8a2ffd7d69
feat(frontend): preference cheatsheet 2024-12-18 21:38:39 +01:00
sam
546e900204
feat(backend): report context, fix deleting reports 2024-12-18 21:26:35 +01:00
sam
bd21eeebcf
feat(frontend): report profile page 2024-12-18 21:26:17 +01:00
sam
05913a3b2f
chore: update svelte 2024-12-18 02:53:06 +01:00
sam
1fb1d8dd14
update gitignore 2024-12-18 02:30:21 +01:00
sam
ddd96e415a
refactor(frontend): use handleError hook for errors instead of try/catch 2024-12-18 02:25:47 +01:00
sam
397ffc2d5e
update sveltekit, migrate to $app/state 2024-12-17 23:33:05 +01:00
sam
80385893c7
feat: split migration into batches 2024-12-17 21:23:02 +01:00
sam
d518cdf739
feat: filters on reports list 2024-12-17 20:48:52 +01:00
sam
27846a4fe4
fix: make query parameters consistent 2024-12-17 20:48:39 +01:00
sam
f766a2054b
feat: allow suspended *and* self-deleted users to access a handful of pages 2024-12-17 18:08:43 +01:00
sam
36cb1d2043
feat: moderation API 2024-12-17 17:52:32 +01:00
sam
79b8c4799e
feat: new migrator 2024-12-16 21:38:38 +01:00
sam
b36b54f9e6
docker: expose metrics and internal API 2024-12-15 01:12:31 +01:00
sam
507b9c3f4c
feat(frontend): custom preference editor 2024-12-15 00:32:11 +01:00
sam
41a008799a
update dependencies 2024-12-14 16:54:47 +01:00
sam
11257ae069
chore: clean up backend code, fix most inspections 2024-12-14 16:51:58 +01:00
sam
49b2902d6d
fix: use url-unsafe base 64 for auth tokens
.net throws an error when decoding url-safe base 64
luckily we never decode it *except* for tokens, so those can keep using
url-unsafe base 64. they're never used in URLs after all
2024-12-14 16:39:02 +01:00
sam
9d33093339
feat: forgot password/reset password 2024-12-14 16:32:08 +01:00
sam
26b32b40e2
feat: show utc offset on profile 2024-12-14 14:00:48 +01:00
sam
5cdadc6158
fix: remove scoped styles from user pages
these are *hell* for user styles and they're really not necessary.
they are still used on some editor pages as those are less important
to be able to comprehensively style, imo
2024-12-14 00:52:44 +01:00
sam
39a3098a99
fix: fix all eslint errors 2024-12-14 00:46:27 +01:00
sam
1cf2619393
feat: add email to existing account, change password 2024-12-13 21:25:41 +01:00
sam
77c3047b1e
feat: misskey auth 2024-12-12 16:44:01 +01:00
sam
51e335f090
feat: use a FixedWindowRateLimiter keyed by IP to rate limit emails
we don't talk about the sent_emails table :)
2024-12-11 21:17:46 +01:00
sam
1ce4f9d278
fix: favicon 2024-12-11 20:43:55 +01:00
sam
ff8d53814d
feat: rate limit emails to two per address per hour 2024-12-11 20:42:48 +01:00
sam
5cb3faa92b
feat(backend): allow suspended users to access some endpoints, add flag scopes 2024-12-11 20:42:26 +01:00
sam
7f8e72e857
fix backend dockerfile, Caddyfile, and email controller 2024-12-11 02:11:53 +01:00
sam
a9ccc12671
add favicon 2024-12-11 01:44:12 +01:00
sam
a29d1fdb78
feat: plain text emails 2024-12-11 01:44:00 +01:00
sam
7e6698c3fb
update to .net 9 and add new OpenAPI packages 2024-12-10 15:28:44 +01:00
sam
80b7f192f1
clean up RemoteAuthService 2024-12-10 14:09:32 +01:00
sam
3338243cea
feat: log in with tumblr 2024-12-09 21:48:07 +01:00
sam
d30ebacc72
chore: add license headers to all c# files 2024-12-09 21:11:46 +01:00
sam
8a8b4caa18
feat: log in with google 2024-12-09 21:07:53 +01:00
sam
bb2fa55cd5
feat: docker config for new frontend 2024-12-09 18:04:56 +01:00
sam
c6eba5b51a
feat(frontend): links editor 2024-12-09 17:05:43 +01:00
sam
b0a286dd9f
feat(frontend): member fields and flags editors, fix user fields editor 2024-12-09 16:41:54 +01:00
sam
2a0df335bc
feat(frontend): user profile flag editor 2024-12-09 16:33:06 +01:00
sam
d9d48c3cbf
feat: flag management 2024-12-09 14:52:31 +01:00
sam
8bd4449804
refactor(backend): move all request/response types to a new Dto namespace 2024-12-09 13:58:18 +01:00
sam
f8e6032449
chore(backend): add roslynator and fix diagnostics 2024-12-08 15:17:18 +01:00
sam
649988db25
refactor(backend): use explicit types instead of var by default 2024-12-08 15:07:25 +01:00
sam
bc7fd6d804
feat(frontend): register/log in with email 2024-12-04 17:43:02 +01:00
sam
57e1ec09c0
feat: link fediverse account to existing user 2024-12-04 01:49:03 +01:00
sam
03209e4028
chore(backend): clean imports 2024-12-03 20:05:24 +01:00
sam
9966656c0c
fix(backend): don't need [NotMapped] for these actually 2024-12-03 20:04:28 +01:00
sam
c20831f20d
feat(frontend): export ui 2024-12-03 20:02:09 +01:00
sam
74222ead45
feat(frontend): replace placeholder avatar with identicons
i don't actually know what the license on the kitten image is, and while
it's very unlikely, i don't want to get into legal trouble. it was only
ever supposed to be a temporary image, too.

identicons aren't the prettiest but at least they have a clear license
:3
2024-12-03 15:19:52 +01:00
sam
71d3b42330
fix(frontend): don't throw a 500 error if a user or member doesn't exist 2024-12-03 14:55:41 +01:00
sam
18bdbc0745
feat(backend): clean deleted users 2024-12-03 14:55:19 +01:00
sam
903be2709c
feat(backend): initial data export support
obviously it's missing things that haven't been added yet
2024-12-02 18:06:19 +01:00
sam
f0ae648492
feat(frontend): force log out page 2024-12-02 16:32:13 +01:00
sam
54be457a47
chore(frontend): add docs to RequestArgs 2024-12-02 16:31:48 +01:00
sam
b47ed7b699
rate limit tweaks
the /users/{id} prefix contains most API routes so it's not a good idea
to put a single rate limit on *all* of them combined. the rate limiter
will now ignore the /users/{id} prefix *if* there's a second {id}
parameter in the URL.

also, X-RateLimit-Bucket is no longer hashed, so it can be directly
decoded by clients to get the actual bucket name. i'm not sure if this
will actually be useful, but it's nice to have the option.
2024-12-02 16:13:56 +01:00
sam
02e2b230bf
feat(frontend): actual error page 2024-12-02 15:24:09 +01:00
sam
f3bb2d5d01
fix(frontend): add autocomplete=off tags to most inputs 2024-12-02 15:06:17 +01:00
sam
de733a0682
feat(frontend): discord registration/login/linking
also moves the registration form found on the mastodon callback page
into a component so we're not repeating the same code for every auth method
2024-11-28 21:37:30 +01:00
sam
4780be3019
fix(backend): add unique index to auth methods 2024-11-28 21:29:25 +01:00
sam
8b1d5b2c1b
feat(backend): validate custom preferences on save 2024-11-28 17:28:52 +01:00
sam
71b59dbb00
feat: add icon list generation script
this is used to validate icons for custom preferences. it generates both
typescript and c# code
2024-11-27 20:00:28 +01:00
sam
f435ad4cf5
feat(frontend): fields editor 2024-11-27 19:50:45 +01:00
sam
7c52ab759c
tiny readme update 2024-11-25 23:12:19 +01:00
sam
59496a8cd8
feat(frontend): edit names/pronouns 2024-11-25 23:07:17 +01:00
sam
b6d42fb15d
feat(frontend): replace non-working bootstrap tooltips with tippy.js 2024-11-25 21:43:11 +01:00
sam
004111feb6
feat(frontend): unlisted toggle on member editor 2024-11-25 21:25:18 +01:00
sam
c237aa8827
fix(backend): add unlisted param to patch member 2024-11-25 21:24:28 +01:00
sam
c0bb76580d
even more frontend stuff 2024-11-25 17:35:24 +01:00
sam
8bba5f6137
fix: tweak rate limits as just browsing is triggering them 2024-11-25 16:15:07 +01:00
sam
261435c252
feat: so much more frontend stuff 2024-11-24 22:19:53 +01:00
sam
c179669799
feat(frontend): start settings 2024-11-24 17:36:12 +01:00
sam
0c78cd25b0
fix(backend): use serilog theme that actually works with a light terminal 2024-11-24 16:01:40 +01:00
sam
0d47f1fb01
you know what let's just change frontend framework again 2024-11-24 15:55:47 +01:00
sam
c8cd483d20
feat: sid redirect controller 2024-11-24 15:40:12 +01:00
sam
7cb17409cd
fix: explicitly set sids to null so the find free sid functions actually trigger 2024-11-24 15:39:44 +01:00
sam
4e9c4af4a5
feat(auth): misc fediverse auth improvements
- remove automatic app validation
- add force refresh option to GetFediverseUrlAsync
- pass state to mastodon authorization URI
2024-11-24 15:37:36 +01:00
sam
142ff36d3a
fix: stop crash on start with empty sentry dsn, make max avatar length a constant 2024-11-23 20:43:43 +01:00
sam
d87856bf2c
refactor: change ConvertBase64UriToImage from extension method to static method 2024-11-23 20:42:14 +01:00
sam
6abf505c40
refactor: make Member.display_name non-nullable and fall back to Member.name 2024-11-23 20:41:11 +01:00
sam
d0bf638a21
fix: check for obviously invalid instance URLs, use correct JSON key for mastodon scopes 2024-11-23 20:40:09 +01:00
sam
9160281ea2
feat: remove auth method 2024-11-04 22:04:04 +01:00
sam
201c56c3dd
feat: link discord account to existing account 2024-11-03 13:53:16 +01:00
sam
c4cb08cdc1
feat: initial fediverse registration/login 2024-11-03 02:07:07 +01:00
sam
5a22807410
fix: don't pass CancellationToken to method that shouldn't abort
also add license header to project
2024-11-02 21:23:49 +01:00
sam
d982342ab8
refactor: pass DbContextOptions into context directly
turns out efcore doesn't like it when we create a new options instance
(which includes a new data source *and* a new logger factory)
every single time we create a context. this commit extracts
OnConfiguring into static methods which are called when the context is
added to the service collection and when it's manually created for
migrations and the importer.
2024-10-30 15:35:23 +01:00
sam
0077a165b5
feat: add some fediverse authentication code
* create applications on instances
* generate authorize URLs
* exchange oauth code for token and user info (untested)
* recreate mastodon app on authentication failure
2024-10-06 15:34:31 +02:00
sam
a4ca0902a3
fix(frontend): proxy authenticated non-GET requests through rate limiter 2024-10-03 16:53:26 +02:00
sam
567e794154
feat(frontend): hide everything email related if it's disabled on the backend 2024-10-02 21:05:52 +02:00
sam
40da4865bc
feat(frontend): add confirmation before force log out 2024-10-02 16:49:33 +02:00
sam
e030342358
feat(frontend): add, list email 2024-10-02 02:46:39 +02:00
sam
5b17c716cb
feat(backend): add add email address endpoint 2024-10-02 00:52:49 +02:00
sam
7f971e8549
chore: add csharpier to husky, format backend with csharpier 2024-10-02 00:28:07 +02:00
sam
5fab66444f
chore: fix husky 2024-10-02 00:16:20 +02:00
sam
06f7019330
feat(backend): move internal endpoints to /api/internal 2024-10-02 00:15:14 +02:00
sam
eac0a17473
chore: add husky + prettier/dotnet format pre-commit 2024-10-01 22:35:17 +02:00
sam
aa756ac56a
chore(backend): format 2024-10-01 21:58:13 +02:00
sam
42041d49bc
feat: add force log out endpoint 2024-10-01 21:25:51 +02:00
sam
c18b79e570
sam struggles with caching 2024 colorized 2024-10-01 16:30:51 +02:00
sam
9b55747657
fix(frontend): only cache locale files for a minute 2024-10-01 16:27:44 +02:00
sam
3f8fe307ab
fix(frontend): remove unused limits object from env.server 2024-10-01 16:19:04 +02:00
sam
2a66e3e25e
feat(frontend): add username editing 2024-10-01 16:06:02 +02:00
sam
5a8b7aae80
fix(backend): fix username regex accepting characters with diacritics 2024-10-01 16:04:36 +02:00
sam
b1165c3780
refactor(frontend): extract avatar image component 2024-10-01 14:44:34 +02:00
sam
562ecc46bd
feat(frontend): grab limits from API, add created time + member count to settings 2024-09-30 22:05:14 +02:00
sam
4002893323
feat(backend): limit total members per user 2024-09-30 21:44:41 +02:00
sam
80ac16694c
feat(frontend): start settings pages 2024-09-30 21:40:28 +02:00
sam
8f3478d57a
fix(backend): only validate member name if it's changed 2024-09-30 20:14:16 +02:00
sam
2b8e4c3e8d
feat(frontend): use __Host prefix for token cookie 2024-09-30 20:14:03 +02:00
sam
646c2694e1
add .noai file 2024-09-30 13:02:10 +02:00
sam
19bfee6203
fix(frontend): correct wording in own member alert 2024-09-29 21:32:09 +02:00
sam
0bdd0148d2
feat(frontend): member page 2024-09-29 21:10:11 +02:00
sam
3f0a94af3d
fix(frontend): reset colour and change size of member card links 2024-09-29 20:37:30 +02:00
sam
4514216405
refactor(frontend): extract profile view to component shared between users and members 2024-09-29 20:32:54 +02:00
sam
dc18ab60d2
feat(frontend): add flags to user page 2024-09-29 20:24:47 +02:00
sam
f539902711
feat(backend): render flags in member response 2024-09-29 19:52:22 +02:00
sam
e11e60e16b
feat(backend): add update member endpoint 2024-09-28 22:28:59 +02:00
sam
8fe8755183
feat(backend): validate links, allow setting links in POST /users/@me/members 2024-09-27 15:29:33 +02:00
sam
a3cbdc1a08
feat(backend): ability to set profile flags, return profile flags in get user endpoint 2024-09-27 14:48:09 +02:00
sam
6a4aa8064a
feat(backend): update flag endpoint 2024-09-27 00:38:34 +02:00
sam
758ab9ec5b
feat(backend): delete flag endpoint 2024-09-26 23:03:50 +02:00
sam
e20a7d3465
fix(backend): *actually* correctly hash images 2024-09-26 22:30:24 +02:00
sam
14e6e35cb7
feat(backend): add create flag endpoint and job 2024-09-26 22:26:40 +02:00
sam
ff2ba1fb1b
fix(backend): correctly hash images 2024-09-26 22:25:47 +02:00
sam
a70078995b
feat(backend): add pride flag models 2024-09-26 20:15:04 +02:00
sam
39b0917585
add script to prune designer files from migrations, add README with acknowledgements 2024-09-26 17:11:52 +02:00
sam
e83895255e
fix(backend): return last_sid_reroll in API, update last sid reroll + last active correctly 2024-09-26 17:09:40 +02:00
sam
b5f9ef9bd6
feat(backend): add short ID reroll endpoints 2024-09-26 16:38:43 +02:00
sam
e76c634738
feat(backend): return short IDs 2024-09-26 15:26:52 +02:00
sam
e7e4937082
fix(frontend): remove debug console.logs 2024-09-26 15:26:37 +02:00
sam
df93f28273
feat(backend): add short IDs to models 2024-09-26 15:08:08 +02:00
sam
6ea8861da2
feat(frontend): add member pagination 2024-09-26 02:15:54 +02:00
sam
a4a62fa6b6
fix(backend): invert unlisted member filter in RenderUserAsync 2024-09-26 02:14:58 +02:00
sam
6c7a26c73a
chore: add some names to ignored spell check words 2024-09-25 19:48:54 +02:00
sam
6f79d35f11
feat(frontend): add members to user page 2024-09-25 19:48:28 +02:00
sam
f81ae97821
feat(backend): return unlisted status in partial member for authenticated users 2024-09-25 19:48:09 +02:00
sam
bb649d1d72
fix: actually commit the favicon 2024-09-25 19:15:03 +02:00
sam
4732451040
feat(frontend): show user profile fields 2024-09-25 16:43:53 +02:00
sam
4ba28bbfde
feat(frontend): add correct favicon 2024-09-25 16:09:23 +02:00
sam
0f3ab19f6f
feat: remove dark mode toggle, switch to prefers-color-scheme
This means it's not possible to manually change the theme, but all major operating systems
support global dark mode now, so it shouldn't be a huge problem.
Will re-add the dark mode toggle if the Sec-CH-Prefers-Color-Scheme header gets added to Firefox and Safari.
2024-09-25 15:14:48 +02:00
sam
862a64840e
feat: add avatar/bio/links/names/pronouns to user page 2024-09-24 20:56:10 +02:00
sam
412d720abc
feat: add .net user importer 2024-09-18 21:44:47 +02:00
sam
41e620ad03
feat: add go users exporter 2024-09-17 22:12:12 +02:00
sam
6388e3127d
add dev command to repository root 2024-09-17 20:58:31 +02:00
sam
bb76c24017
feat(frontend): slightly better error page 2024-09-15 16:48:22 +02:00
sam
0f51f01b34
feat(frontend): start welcome page 2024-09-15 00:03:15 +02:00
sam
6acd9b94f4
fix(backend): reference System.Text.RegularExpressions directly to avoid CVE 2024-09-14 23:24:23 +02:00
sam
df09a2add8
fix(config): use correct target in example proxy config 2024-09-14 18:11:29 +02:00
sam
cf2f624ae4
feat: add docker configuration 2024-09-14 18:07:49 +02:00
sam
821712f43b
fix(backend): use packages.lock file when restoring 2024-09-14 16:45:33 +02:00
sam
2cef7523d2
chore(backend): silence some more resharper errors 2024-09-14 16:37:52 +02:00
sam
103ba24555
feat(frontend): create account from discord, better error alert 2024-09-14 16:37:27 +02:00
sam
ff22530f0a
feat(frontend): add discord callback page
this only handles existing accounts for now, still need to write an action function
2024-09-13 14:56:38 +02:00
sam
116d0577a7
improve login page 2024-09-11 20:00:35 +02:00
sam
4ac0001795
fix: only query user ID in /api/internal/request-data 2024-09-11 16:34:08 +02:00
sam
2682cabfb0
refactor: add DatabaseContext.GetToken method 2024-09-11 16:23:45 +02:00
sam
be34c4c77e
feat(frontend): working email login 2024-09-10 21:24:40 +02:00
sam
498d79de4e
feat(frontend): internationalization 2024-09-10 20:33:22 +02:00
sam
2323810b06
feat(backend): add option to disable postgres connection pooling 2024-09-10 18:52:13 +02:00
sam
8054d68f79
feat(rate): add customizable X-Powered-By header 2024-09-10 18:49:25 +02:00
sam
3d22385689
feat: add rate limiter proxy 2024-09-10 16:53:43 +02:00
sam
13a0cac663
feat(backend): email registration 2024-09-10 02:39:07 +02:00
sam
c77ee660ca
refactor: more consistent field names, also in STYLE.md 2024-09-09 14:50:00 +02:00
sam
344a0071e5
start (actual) email auth, add CancellationToken to most async methods 2024-09-09 14:37:59 +02:00
sam
acc54a55bc
format(frontend): change line width to 100 2024-09-06 15:01:44 +02:00
sam
fa71f3fb23
add Foxnouns.Frontend to solution in rider 2024-09-05 22:36:07 +02:00
sam
c4adf6918c
switch to another frontend framework wheeeeeeeeeeee 2024-09-05 22:29:12 +02:00
sam
fa3c1ccaa7
feat: add user settings endpoint 2024-09-05 22:17:10 +02:00
sam
22d09ad7a6
fix: return correct error in GET /users/@me 2024-09-05 21:10:45 +02:00
sam
6c9d1c328b
fix: add class context to all loggers, format 2024-09-04 14:25:44 +02:00
sam
fb324e7576
refactor: replace periodic tasks loop with background service 2024-09-04 01:46:39 +02:00
sam
54ec469cd9
feat: add actual metrics using prometheus-net 2024-09-03 17:00:14 +02:00
sam
4a6b5f3b85
Merge branch 'main' of vulpine.solutions:sam/Foxnouns.NET 2024-09-03 16:31:02 +02:00
sam
0aadc5fb47
feat: replace Hangfire with Coravel 2024-09-03 16:29:51 +02:00
sam
2915893049
start user pages 2024-08-22 17:27:04 +02:00
sam
ef221b2c45
feat: update custom preferences endpoint 2024-08-22 15:13:46 +02:00
sam
c4e39d4d59
chore: update dependencies 2024-07-25 22:52:15 +02:00
sam
2b91723696
feat(backend): add member delete endpoint 2024-07-14 21:41:16 +02:00
sam
a069d0ff15
feat(backend): add more params to POST /users/@me/members 2024-07-14 21:25:23 +02:00
sam
fb34464199
feat(backend): improve bad request errors 2024-07-14 16:44:41 +02:00
sam
e7ec0e6661
feat(backend): add member GET endpoints, POST /users/@me/members endpoint 2024-07-13 19:38:40 +02:00
sam
16f230b97d
feat(backend): start work on metrics 2024-07-13 17:23:52 +02:00
sam
fa49030b06
feat: add deleted user columns in database 2024-07-13 03:09:07 +02:00
sam
e95e0a79ff
feat: add PATCH request support, expand PATCH /users/@me, serialize enums correctly 2024-07-12 17:12:24 +02:00
sam
d6c9345dba
too many things to list (notably, user avatar update) 2024-07-08 19:03:04 +02:00
sam
a7950671e1
feat: initial working discord authentication 2024-06-13 02:23:55 +02:00
sam
6186eda092
feat(backend): add RequestDiscordTokenAsync method 2024-06-12 16:19:49 +02:00
sam
2a7bd746aa
feat(frontend): start auth pages 2024-06-12 03:54:25 +02:00
sam
25540f2de2 feat(backend): start authentication controllers 2024-06-12 03:47:20 +02:00
sam
493a6e4d29
feat(backend): add skeleton discord auth controller 2024-06-10 16:25:49 +02:00
sam
50257d61f8 switch frontend css from bootstrap to bulma 2024-06-09 23:21:28 +02:00
sam
a2f001392b
stuff 2024-06-09 15:48:26 +02:00
sam
14f8e77e6a add sveltekit template 2024-06-08 21:02:12 +02:00
sam
401e268281 chore: add back Properties/launchSettings.json 2024-06-04 17:47:16 +02:00
sam
24155a149c fix: fix BuildInfo not being initialized 2024-06-04 17:43:48 +02:00
417 changed files with 32377 additions and 2581 deletions

20
.config/dotnet-tools.json Normal file
View file

@ -0,0 +1,20 @@
{
"version": 1,
"isRoot": true,
"tools": {
"husky": {
"version": "0.7.2",
"commands": [
"husky"
],
"rollForward": false
},
"csharpier": {
"version": "0.30.6",
"commands": [
"dotnet-csharpier"
],
"rollForward": false
}
}
}

24
.dockerignore Normal file
View file

@ -0,0 +1,24 @@
**/.dockerignore
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
static-pages/*

View file

@ -1,2 +1,52 @@
[*.cs] [*]
# We use PostgresSQL which doesn't recommend more specific string types
resharper_entity_framework_model_validation_unlimited_string_length_highlighting = none resharper_entity_framework_model_validation_unlimited_string_length_highlighting = none
# This is raised for every single property of records returned by endpoints
resharper_not_accessed_positional_property_local_highlighting = none
# Microsoft .NET properties
csharp_new_line_before_members_in_object_initializers = false
csharp_preferred_modifier_order = public, internal, protected, private, file, new, virtual, override, required, abstract, sealed, static, extern, unsafe, volatile, async, readonly:suggestion
# ReSharper properties
resharper_align_multiline_binary_expressions_chain = false
resharper_arguments_skip_single = true
resharper_blank_lines_after_start_comment = 0
resharper_blank_lines_around_single_line_invocable = 0
resharper_blank_lines_before_block_statements = 0
resharper_braces_for_foreach = required_for_multiline
resharper_braces_for_ifelse = required_for_multiline
resharper_braces_redundant = false
resharper_csharp_blank_lines_around_field = 0
resharper_csharp_empty_block_style = together_same_line
resharper_csharp_max_line_length = 166
resharper_csharp_wrap_after_declaration_lpar = true
resharper_csharp_wrap_before_binary_opsign = true
resharper_csharp_wrap_before_declaration_rpar = true
resharper_csharp_wrap_parameters_style = chop_if_long
resharper_indent_preprocessor_other = do_not_change
resharper_instance_members_qualify_declared_in =
resharper_keep_existing_attribute_arrangement = true
resharper_max_attribute_length_for_same_line = 70
resharper_place_accessorholder_attribute_on_same_line = false
resharper_place_expr_method_on_single_line = if_owner_is_single_line
resharper_place_method_attribute_on_same_line = if_owner_is_single_line
resharper_place_record_field_attribute_on_same_line = true
resharper_place_simple_embedded_statement_on_same_line = false
resharper_place_simple_initializer_on_single_line = false
resharper_place_simple_list_pattern_on_single_line = false
resharper_space_within_empty_braces = false
resharper_trailing_comma_in_multiline_lists = true
resharper_wrap_after_invocation_lpar = false
resharper_wrap_before_invocation_rpar = false
resharper_wrap_before_primary_constructor_declaration_rpar = true
resharper_wrap_chained_binary_patterns = chop_if_long
resharper_wrap_list_pattern = chop_always
resharper_wrap_object_and_collection_initializer_style = chop_always
# Roslynator properties
dotnet_diagnostic.RCS1194.severity = none
[*generated.cs]
generated_code = true

16
.gitignore vendored
View file

@ -1,3 +1,19 @@
bin/ bin/
obj/ obj/
node_modules/
.version .version
config.ini
*.DotSettings.user
proxy-config.json
.DS_Store
.idea/.idea.Foxnouns.NET/.idea/dataSources.xml
.idea/.idea.Foxnouns.NET/.idea/sqldialects.xml
docker/config.ini
docker/proxy-config.json
docker/frontend.env
Foxnouns.DataMigrator/apps.json
out/
build/

22
.husky/pre-commit Executable file
View file

@ -0,0 +1,22 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
## husky task runner examples -------------------
## Note : for local installation use 'dotnet' prefix. e.g. 'dotnet husky'
## run all tasks
#husky run
### run all tasks with group: 'group-name'
#husky run --group group-name
## run task with name: 'task-name'
#husky run --name task-name
## pass hook arguments to task
#husky run --args "$1" "$2"
## or put your custom commands -------------------
#echo 'Husky.Net is awesome!'
dotnet husky run

34
.husky/task-runner.json Normal file
View file

@ -0,0 +1,34 @@
{
"$schema": "https://alirezanet.github.io/Husky.Net/schema.json",
"tasks": [
{
"name": "run-prettier",
"command": "pnpm",
"args": [
"prettier",
"-w",
"${staged}"
],
"include": [
"Foxnouns.Frontend/**/*.ts",
"Foxnouns.Frontend/**/*.json",
"Foxnouns.Frontend/**/*.scss",
"Foxnouns.Frontend/**/*.js",
"Foxnouns.Frontend/**/*.svelte"
],
"cwd": "Foxnouns.Frontend/",
"pathMode": "absolute"
},
{
"name": "run-csharpier",
"command": "dotnet",
"args": [
"csharpier",
"${staged}"
],
"include": [
"**/*.cs"
]
}
]
}

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="com.intellij.csharpier">
<option name="customPath" value="" />
<option name="runOnSave" value="true" />
</component>
</project>

View file

@ -0,0 +1,61 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<HTMLCodeStyleSettings>
<option name="HTML_SPACE_INSIDE_EMPTY_TAG" value="true" />
</HTMLCodeStyleSettings>
<JSCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</JSCodeStyleSettings>
<TypeScriptCodeStyleSettings version="0">
<option name="FORCE_SEMICOLON_STYLE" value="true" />
<option name="SPACE_BEFORE_FUNCTION_LEFT_PARENTH" value="false" />
<option name="FORCE_QUOTE_STYlE" value="true" />
<option name="ENFORCE_TRAILING_COMMA" value="Remove" />
<option name="SPACES_WITHIN_OBJECT_LITERAL_BRACES" value="true" />
<option name="SPACES_WITHIN_IMPORTS" value="true" />
</TypeScriptCodeStyleSettings>
<VueCodeStyleSettings>
<option name="INTERPOLATION_NEW_LINE_AFTER_START_DELIMITER" value="false" />
<option name="INTERPOLATION_NEW_LINE_BEFORE_END_DELIMITER" value="false" />
</VueCodeStyleSettings>
<codeStyleSettings language="HTML">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="JavaScript">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="TypeScript">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="INDENT_SIZE" value="2" />
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="TAB_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
<codeStyleSettings language="Vue">
<option name="SOFT_MARGINS" value="100" />
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="2" />
<option name="USE_TAB_CHARACTER" value="true" />
</indentOptions>
</codeStyleSettings>
</code_scheme>
</component>

View file

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

View file

@ -1,7 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="UserContentModel"> <component name="UserContentModel">
<attachedFolders /> <attachedFolders>
<Path>Foxnouns.Frontend</Path>
<Path>migrators/go-exporter</Path>
</attachedFolders>
<explicitIncludes /> <explicitIncludes />
<explicitExcludes /> <explicitExcludes />
</component> </component>

View file

@ -0,0 +1,40 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="ignoredUrls">
<list>
<option value="http://" />
<option value="http://0.0.0.0" />
<option value="http://127.0.0.1" />
<option value="http://activemq.apache.org/schema/" />
<option value="http://cxf.apache.org/schemas/" />
<option value="http://java.sun.com/" />
<option value="http://javafx.com/fxml" />
<option value="http://javafx.com/javafx/" />
<option value="http://json-schema.org/draft" />
<option value="http://localhost" />
<option value="http://maven.apache.org/POM/" />
<option value="http://maven.apache.org/xsd/" />
<option value="http://primefaces.org/ui" />
<option value="http://schema.cloudfoundry.org/spring/" />
<option value="http://schemas.xmlsoap.org/" />
<option value="http://tiles.apache.org/" />
<option value="http://www.ibm.com/webservices/xsd" />
<option value="http://www.jboss.com/xml/ns/" />
<option value="http://www.jboss.org/j2ee/schema/" />
<option value="http://www.springframework.org/schema/" />
<option value="http://www.springframework.org/security/tags" />
<option value="http://www.springframework.org/tags" />
<option value="http://www.thymeleaf.org" />
<option value="http://www.w3.org/" />
<option value="http://xmlns.jcp.org/" />
</list>
</option>
</inspection_tool>
<inspection_tool class="RequiredAttributes" enabled="true" level="WARNING" enabled_by_default="true">
<option name="myAdditionalRequiredHtmlAttributes" value="column" />
</inspection_tool>
</profile>
</component>

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="EslintConfiguration">
<files-pattern value="**/*.{js,ts,jsx,tsx,html,vue,svelte}" />
<option name="fix-on-save" value="true" />
</component>
</project>

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PrettierConfiguration">
<option name="myConfigurationMode" value="AUTOMATIC" />
<option name="myRunOnSave" value="true" />
<option name="myRunOnReformat" value="true" />
<option name="myFilesPattern" value="**/*.{js,ts,jsx,tsx,vue,astro,svelte,html}" />
</component>
</project>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectTasksOptions" suppressed-tasks="SCSS" />
</project>

0
.noai Normal file
View file

32
DOCKER.md Normal file
View file

@ -0,0 +1,32 @@
# Running with Docker (pre-built backend and rate limiter) *(linux/arm64 only)*
Because SvelteKit is a pain in the ass to build in a container, and processes secrets at build time,
there is no pre-built frontend image available.
If you don't want to build images on your server, I recommend running the frontend outside of Docker.
This is preconfigured in `docker-compose.prebuilt.yml`: the backend, database, and rate limiter will run in Docker,
while the frontend is run as a normal, non-containerized service.
1. Copy `docker/config.example.ini` to `docker/config.ini`, and change the settings to your liking.
2. Copy `docker/proxy-config.example.json` to `docker/proxy-config.json`, and do the same.
3. Run with `docker compose up -f docker-compose.prebuilt.yml`
The backend will listen on port 5001 and metrics will be available on port 5002.
The rate limiter (which is what should be exposed to the outside) will listen on port 5003.
You can use `docker/Caddyfile` as an example for your reverse proxy. If you use nginx, good luck.
# Running with Docker (local builds)
In order to run *everything* in Docker, you'll have to build every container yourself.
The advantage of this is that it's an all-in-one solution, where you only have to point your reverse proxy at a single container.
The disadvantage is that you'll likely have to build the images on the server you'll be running them on.
1. Configure the backend and rate limiter as in the section above.
2. Copy `docker/frontend.example.env` to `docker/frontend.env`, and configure it.
3. Build with `docker compose build -f docker-compose.local.yml`
4. Run with `docker compose up -f docker-compose.local.yml`
The Caddy server will listen on `localhost:5004` for the frontend and API,
and on `localhost:5005` for the profile URL shortener.
The backend server listens on `localhost:5006` for unproxied API access,
and `localhost:5007` for metrics.

22
Dockerfile.backend Normal file
View file

@ -0,0 +1,22 @@
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 5000
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["Foxnouns.Backend/Foxnouns.Backend.csproj", "Foxnouns.Backend/"]
RUN dotnet restore "Foxnouns.Backend/Foxnouns.Backend.csproj"
COPY . .
WORKDIR "/src/Foxnouns.Backend"
RUN dotnet build "Foxnouns.Backend.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "Foxnouns.Backend.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Foxnouns.Backend.dll", "--migrate-and-start"]

48
ENDPOINTS.md Normal file
View file

@ -0,0 +1,48 @@
# List of API endpoints and scopes
## Scopes
- `identify`: `@me` will refer to token user (always granted)
- `user.read_hidden`: can read non-privileged hidden information such as timezone,
whether the member list is hidden, and whether a member is unlisted.
- `user.read_privileged`: can read privileged information such as authentication methods
- `user.update`: can update the user's profile.
**cannot** update anything locked behind `user.read_privileged`
- `member.read`: can view member list if it's hidden and enumerate unlisted members
- `member.create`: can create new members
- `member.update`: can edit and delete members
## Meta
- [x] GET `/meta`: gets stats and server information
## Users
- [x] GET `/users/{userRef}`: views current user.
`identify` required to use `@me` as user reference.
`user.read_hidden` required to view timezone and other hidden non-privileged data.
`user.read_privileged` required to view authentication methods.
`member.read` required to view unlisted members.
- [x] PATCH `/users/@me`: updates current user. `user.update` required
- [x] PATCH `/users/@me/custom-preferences`: updates user's custom preferences. `user.update` required
- [ ] DELETE `/users/@me`: deletes current user. `*` required
- [ ] POST `/users/@me/export`: queues new data export. `*` required
- [ ] GET `/users/@me/export`: gets latest data export. `*` required
- [ ] GET `/users/@me/flags`: get all the user's flags. `identify` required
- [ ] POST `/users/@me/flags`: creates a new flag. `user.update` required
- [ ] PATCH `/users/@me/flags/{id}`: updates an existing flag. `user.update` required
- [ ] DELETE `/users/@me/flags/{id}`: deletes a user flag. `user.update` required
- [ ] POST `/users/@me/reroll`: rerolls a user's short ID. `user.update` required
## Members
- [x] GET `/users/{userRef}/members`: gets list of a user's members.
if the user's member list is hidden,
and it is not the authenticated user (or the token doesn't have the `member.read` scope)
returns an empty array.
- [x] GET `/users/{userRef}/members/{memberRef}`: gets a single member.
will always return a member if it exists, even if the member is unlisted.
- [x] POST `/users/@me/members`: creates a new member. `member.create` required
- [ ] PATCH `/users/@me/members/{memberRef}`: edits a member. `member.update` required
- [x] DELETE `/users/@me/members/{memberRef}`: deletes a member. `member.update` required
- [ ] POST `/users/@me/members/{memberRef}/reroll`: rerolls a member's short ID. `member.update` required.

View file

@ -1,3 +1,17 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
namespace Foxnouns.Backend; namespace Foxnouns.Backend;
public static class BuildInfo public static class BuildInfo
@ -7,20 +21,27 @@ public static class BuildInfo
public static async Task ReadBuildInfo() public static async Task ReadBuildInfo()
{ {
await using var stream = typeof(BuildInfo).Assembly.GetManifestResourceStream("version"); await using Stream? stream = typeof(BuildInfo).Assembly.GetManifestResourceStream(
if (stream == null) return; "version"
);
if (stream == null)
return;
using var reader = new StreamReader(stream); using var reader = new StreamReader(stream);
var data = (await reader.ReadToEndAsync()).Trim().Split("\n"); string[] data = (await reader.ReadToEndAsync()).Trim().Split("\n");
if (data.Length < 3) return; if (data.Length < 3)
return;
Hash = data[0]; Hash = data[0];
var dirty = data[2] == "dirty"; bool dirty = data[2] == "dirty";
var versionData = data[1].Split("-"); string[] versionData = data[1].Split("-");
if (versionData.Length < 3) return; if (versionData.Length < 3)
return;
Version = versionData[0]; Version = versionData[0];
if (versionData[1] != "0" || dirty) Version += $"+{versionData[2]}"; if (versionData[1] != "0" || dirty)
if (dirty) Version += ".dirty"; Version += $"+{versionData[2]}";
if (dirty)
Version += ".dirty";
} }
} }

View file

@ -1,24 +1,115 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// ReSharper disable UnusedAutoPropertyAccessor.Global
using Serilog.Events; using Serilog.Events;
namespace Foxnouns.Backend; namespace Foxnouns.Backend;
public class Config public class Config
{ {
public string Host { get; set; } = "localhost"; public string Host { get; init; } = "localhost";
public int Port { get; set; } = 3000; public int Port { get; init; } = 3000;
public string BaseUrl { get; set; } = null!; public string BaseUrl { get; init; } = null!;
public string MediaBaseUrl { get; init; } = null!;
public string Address => $"http://{Host}:{Port}"; public string Address => $"http://{Host}:{Port}";
public string? SeqLogUrl { get; set; } public LoggingConfig Logging { get; init; } = new();
public LogEventLevel LogEventLevel { get; set; } = LogEventLevel.Debug; public DatabaseConfig Database { get; init; } = new();
public StorageConfig Storage { get; init; } = new();
public LimitsConfig Limits { get; init; } = new();
public EmailAuthConfig EmailAuth { get; init; } = new();
public DiscordAuthConfig DiscordAuth { get; init; } = new();
public GoogleAuthConfig GoogleAuth { get; init; } = new();
public TumblrAuthConfig TumblrAuth { get; init; } = new();
public DatabaseConfig Database { get; set; } = new(); public class LoggingConfig
{
public LogEventLevel LogEventLevel { get; init; } = LogEventLevel.Debug;
public string? SeqLogUrl { get; init; }
public string? SentryUrl { get; init; }
public bool SentryTracing { get; init; } = false;
public double SentryTracesSampleRate { get; init; } = 0.0;
public bool LogQueries { get; init; } = false;
public bool EnableMetrics { get; init; } = false;
public ushort MetricsPort { get; init; } = 5001;
}
public class DatabaseConfig public class DatabaseConfig
{ {
public string Url { get; set; } = string.Empty; public string Url { get; init; } = string.Empty;
public int? Timeout { get; set; } public bool? EnablePooling { get; init; }
public int? MaxPoolSize { get; set; } public int? Timeout { get; init; }
public int? MaxPoolSize { get; init; }
public string Redis { get; init; } = string.Empty;
} }
}
public class StorageConfig
{
public string Endpoint { get; init; } = string.Empty;
public string AccessKey { get; init; } = string.Empty;
public string SecretKey { get; init; } = string.Empty;
public string Bucket { get; init; } = string.Empty;
}
public class EmailAuthConfig
{
public bool Enabled => From != null;
public string? From { get; init; }
}
public class DiscordAuthConfig
{
public bool Enabled => ClientId != null && ClientSecret != null;
public string? ClientId { get; init; }
public string? ClientSecret { get; init; }
}
public class GoogleAuthConfig
{
public bool Enabled => ClientId != null && ClientSecret != null;
public string? ClientId { get; init; }
public string? ClientSecret { get; init; }
}
public class TumblrAuthConfig
{
public bool Enabled => ClientId != null && ClientSecret != null;
public string? ClientId { get; init; }
public string? ClientSecret { get; init; }
}
public class LimitsConfig
{
public int MaxMemberCount { get; init; } = 1000;
public int MaxFields { get; init; } = 25;
public int MaxFieldNameLength { get; init; } = 100;
public int MaxFieldEntryTextLength { get; init; } = 100;
public int MaxFieldEntries { get; init; } = 100;
public int MaxUsernameLength { get; init; } = 40;
public int MaxMemberNameLength { get; init; } = 100;
public int MaxDisplayNameLength { get; init; } = 100;
public int MaxLinks { get; init; } = 25;
public int MaxLinkLength { get; init; } = 256;
public int MaxBioLength { get; init; } = 1024;
public int MaxAvatarLength { get; init; } = 1_500_000;
}
}

View file

@ -1,3 +1,17 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Middleware;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -10,4 +24,4 @@ public class ApiControllerBase : ControllerBase
{ {
internal Token? CurrentToken => HttpContext.GetToken(); internal Token? CurrentToken => HttpContext.GetToken();
internal User? CurrentUser => HttpContext.GetUser(); internal User? CurrentUser => HttpContext.GetUser();
} }

View file

@ -0,0 +1,155 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Net;
using System.Web;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Controllers.Authentication;
[Route("/api/internal/auth")]
[ApiExplorerSettings(IgnoreApi = true)]
public class AuthController(
Config config,
DatabaseContext db,
KeyCacheService keyCacheService,
ILogger logger
) : ApiControllerBase
{
private readonly ILogger _logger = logger.ForContext<AuthController>();
[HttpPost("urls")]
[ProducesResponseType<UrlsResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> UrlsAsync(CancellationToken ct = default)
{
_logger.Debug(
"Generating auth URLs for Discord: {Discord}, Google: {Google}, Tumblr: {Tumblr}",
config.DiscordAuth.Enabled,
config.GoogleAuth.Enabled,
config.TumblrAuth.Enabled
);
string state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync());
string? discord = null;
string? google = null;
string? tumblr = null;
if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null })
{
discord =
"https://discord.com/oauth2/authorize?response_type=code"
+ $"&client_id={config.DiscordAuth.ClientId}&scope=identify"
+ $"&prompt=none&state={state}"
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}";
}
if (config.GoogleAuth is { ClientId: not null, ClientSecret: not null })
{
google =
"https://accounts.google.com/o/oauth2/auth?response_type=code"
+ $"&client_id={config.GoogleAuth.ClientId}"
+ $"&scope=openid+{HttpUtility.UrlEncode("https://www.googleapis.com/auth/userinfo.email")}"
+ $"&prompt=select_account&state={state}"
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/google")}";
}
if (config.TumblrAuth is { ClientId: not null, ClientSecret: not null })
{
tumblr =
"https://www.tumblr.com/oauth2/authorize?response_type=code"
+ $"&client_id={config.TumblrAuth.ClientId}"
+ $"&scope=basic&state={state}"
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/tumblr")}";
}
return Ok(new UrlsResponse(config.EmailAuth.Enabled, discord, google, tumblr));
}
[HttpPost("force-log-out")]
[Authorize("identify")]
public async Task<IActionResult> ForceLogoutAsync()
{
_logger.Information("Invalidating all tokens for user {UserId}", CurrentUser!.Id);
await db
.Tokens.Where(t => t.UserId == CurrentUser.Id)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.ManuallyExpired, true));
return NoContent();
}
[HttpGet("methods/{id}")]
[Authorize("*")]
[ProducesResponseType<AuthMethodResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> GetAuthMethodAsync(Snowflake id)
{
AuthMethod? authMethod = await db
.AuthMethods.Include(a => a.FediverseApplication)
.FirstOrDefaultAsync(a => a.UserId == CurrentUser!.Id && a.Id == id);
if (authMethod == null)
throw new ApiError.NotFound("No authentication method with that ID found.");
return Ok(UserRendererService.RenderAuthMethod(authMethod));
}
[HttpDelete("methods/{id}")]
[Authorize("*")]
public async Task<IActionResult> DeleteAuthMethodAsync(Snowflake id)
{
List<AuthMethod> authMethods = await db
.AuthMethods.Where(a => a.UserId == CurrentUser!.Id)
.ToListAsync();
if (authMethods.Count < 2)
{
throw new ApiError(
"You cannot remove your last authentication method.",
HttpStatusCode.BadRequest,
ErrorCode.LastAuthMethod
);
}
AuthMethod? authMethod = authMethods.FirstOrDefault(a => a.Id == id);
if (authMethod == null)
throw new ApiError.NotFound("No authentication method with that ID found.");
_logger.Debug(
"Deleting auth method {AuthMethodId} for user {UserId}",
authMethod.Id,
CurrentUser!.Id
);
// If this is the user's last email, we should also clear the user's password.
if (
authMethod.AuthType == AuthType.Email
&& authMethods.Count(a => a.AuthType == AuthType.Email) == 1
)
{
_logger.Debug(
"Deleted last email address for user {UserId}, resetting their password",
CurrentUser.Id
);
CurrentUser.Password = null;
db.Update(CurrentUser);
}
db.Remove(authMethod);
await db.SaveChangesAsync();
return NoContent();
}
}

View file

@ -0,0 +1,186 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Net;
using System.Web;
using EntityFramework.Exceptions.Common;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth;
using Foxnouns.Backend.Utils;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace Foxnouns.Backend.Controllers.Authentication;
[Route("/api/internal/auth/discord")]
[ApiExplorerSettings(IgnoreApi = true)]
public class DiscordAuthController(
[UsedImplicitly] Config config,
ILogger logger,
DatabaseContext db,
KeyCacheService keyCacheService,
AuthService authService,
RemoteAuthService remoteAuthService
) : ApiControllerBase
{
private readonly ILogger _logger = logger.ForContext<DiscordAuthController>();
[HttpPost("callback")]
[ProducesResponseType<CallbackResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> CallbackAsync([FromBody] CallbackRequest req)
{
CheckRequirements();
await keyCacheService.ValidateAuthStateAsync(req.State);
RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestDiscordTokenAsync(
req.Code
);
User? user = await authService.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id);
if (user != null)
return Ok(await authService.GenerateUserTokenAsync(user));
_logger.Debug(
"Discord user {Username} ({Id}) authenticated with no local account",
remoteUser.Username,
remoteUser.Id
);
string ticket = AuthUtils.RandomToken();
await keyCacheService.SetKeyAsync(
$"discord:{ticket}",
remoteUser,
Duration.FromMinutes(20)
);
return Ok(new CallbackResponse(false, ticket, remoteUser.Username, null, null, null));
}
[HttpPost("register")]
[ProducesResponseType<AuthResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> RegisterAsync([FromBody] OauthRegisterRequest req)
{
RemoteAuthService.RemoteUser? remoteUser =
await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>(
$"discord:{req.Ticket}"
);
if (remoteUser == null)
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
if (
await db.AuthMethods.AnyAsync(a =>
a.AuthType == AuthType.Discord && a.RemoteId == remoteUser.Id
)
)
{
_logger.Error(
"Discord user {Id} has valid ticket but is already linked to an existing account",
remoteUser.Id
);
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
}
User user = await authService.CreateUserWithRemoteAuthAsync(
req.Username,
AuthType.Discord,
remoteUser.Id,
remoteUser.Username
);
return Ok(await authService.GenerateUserTokenAsync(user));
}
[HttpGet("add-account")]
[Authorize("*")]
public async Task<IActionResult> AddDiscordAccountAsync()
{
CheckRequirements();
string state = await remoteAuthService.ValidateAddAccountRequestAsync(
CurrentUser!.Id,
AuthType.Discord
);
string url =
"https://discord.com/oauth2/authorize?response_type=code"
+ $"&client_id={config.DiscordAuth.ClientId}&scope=identify"
+ $"&prompt=none&state={state}"
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}";
return Ok(new SingleUrlResponse(url));
}
[HttpPost("add-account/callback")]
[Authorize("*")]
public async Task<IActionResult> AddAccountCallbackAsync([FromBody] CallbackRequest req)
{
CheckRequirements();
await remoteAuthService.ValidateAddAccountStateAsync(
req.State,
CurrentUser!.Id,
AuthType.Discord
);
RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestDiscordTokenAsync(
req.Code
);
try
{
AuthMethod authMethod = await authService.AddAuthMethodAsync(
CurrentUser.Id,
AuthType.Discord,
remoteUser.Id,
remoteUser.Username
);
_logger.Debug(
"Added new Discord auth method {AuthMethodId} to user {UserId}",
authMethod.Id,
CurrentUser.Id
);
return Ok(
new AddOauthAccountResponse(
authMethod.Id,
AuthType.Discord,
authMethod.RemoteId,
authMethod.RemoteUsername
)
);
}
catch (UniqueConstraintException)
{
throw new ApiError(
"That account is already linked.",
HttpStatusCode.BadRequest,
ErrorCode.AccountAlreadyLinked
);
}
}
private void CheckRequirements()
{
if (!config.DiscordAuth.Enabled)
{
throw new ApiError.BadRequest(
"Discord authentication is not enabled on this instance."
);
}
}
}

View file

@ -0,0 +1,374 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Net;
using EntityFramework.Exceptions.Common;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth;
using Foxnouns.Backend.Utils;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace Foxnouns.Backend.Controllers.Authentication;
[Route("/api/internal/auth/email")]
[ApiExplorerSettings(IgnoreApi = true)]
public class EmailAuthController(
[UsedImplicitly] Config config,
DatabaseContext db,
AuthService authService,
MailService mailService,
EmailRateLimiter rateLimiter,
KeyCacheService keyCacheService,
UserRendererService userRenderer,
IClock clock,
ILogger logger
) : ApiControllerBase
{
private readonly ILogger _logger = logger.ForContext<EmailAuthController>();
[HttpPost("register/init")]
public async Task<IActionResult> RegisterInitAsync(
[FromBody] EmailRegisterRequest req,
CancellationToken ct = default
)
{
CheckRequirements();
if (!req.Email.Contains('@'))
throw new ApiError.BadRequest("Email is invalid", "email", req.Email);
string state = await keyCacheService.GenerateRegisterEmailStateAsync(req.Email, null);
// If there's already a user with that email address, pretend we sent an email but actually ignore it
if (
await db.AuthMethods.AnyAsync(
a => a.AuthType == AuthType.Email && a.RemoteId == req.Email,
ct
)
)
{
return NoContent();
}
if (IsRateLimited())
return NoContent();
mailService.QueueAccountCreationEmail(req.Email, state);
return NoContent();
}
[HttpPost("callback")]
public async Task<IActionResult> CallbackAsync([FromBody] EmailCallbackRequest req)
{
CheckRequirements();
RegisterEmailState? state = await keyCacheService.GetRegisterEmailStateAsync(req.State);
if (state is not { ExistingUserId: null })
throw new ApiError.BadRequest("Invalid state", "state", req.State);
string ticket = AuthUtils.RandomToken();
await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20));
return Ok(new CallbackResponse(false, ticket, state.Email, null, null, null));
}
[HttpPost("register")]
public async Task<IActionResult> CompleteRegistrationAsync(
[FromBody] EmailCompleteRegistrationRequest req
)
{
CheckRequirements();
string? email = await keyCacheService.GetKeyAsync($"email:{req.Ticket}");
if (email == null)
throw new ApiError.BadRequest("Unknown ticket", "ticket", req.Ticket);
User user = await authService.CreateUserWithPasswordAsync(
req.Username,
email,
req.Password
);
Application frontendApp = await db.GetFrontendApplicationAsync();
(string? tokenStr, Token? token) = authService.GenerateToken(
user,
frontendApp,
["*"],
clock.GetCurrentInstant() + Duration.FromDays(365)
);
db.Add(token);
await db.SaveChangesAsync();
await keyCacheService.DeleteKeyAsync($"email:{req.Ticket}");
return Ok(
new AuthResponse(
await userRenderer.RenderUserAsync(user, user, renderMembers: false),
tokenStr,
token.ExpiresAt
)
);
}
[HttpPost("login")]
[ProducesResponseType<AuthResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> LoginAsync(
[FromBody] EmailLoginRequest req,
CancellationToken ct = default
)
{
CheckRequirements();
(User? user, AuthService.EmailAuthenticationResult authenticationResult) =
await authService.AuthenticateUserAsync(req.Email, req.Password, ct);
if (authenticationResult == AuthService.EmailAuthenticationResult.MfaRequired)
throw new NotImplementedException("MFA is not implemented yet");
Application frontendApp = await db.GetFrontendApplicationAsync(ct);
_logger.Debug("Logging user {Id} in with email and password", user.Id);
(string? tokenStr, Token? token) = authService.GenerateToken(
user,
frontendApp,
["*"],
clock.GetCurrentInstant() + Duration.FromDays(365)
);
db.Add(token);
_logger.Debug("Generated token {TokenId} for {UserId}", token.Id, user.Id);
await db.SaveChangesAsync(ct);
return Ok(
new AuthResponse(
await userRenderer.RenderUserAsync(user, user, renderMembers: false, ct: ct),
tokenStr,
token.ExpiresAt
)
);
}
[HttpPost("change-password")]
[Authorize("*")]
public async Task<IActionResult> UpdatePasswordAsync([FromBody] EmailChangePasswordRequest req)
{
if (!await authService.ValidatePasswordAsync(CurrentUser!, req.Current))
throw new ApiError.Forbidden("Invalid password");
ValidationUtils.Validate([("new", ValidationUtils.ValidatePassword(req.New))]);
await authService.SetUserPasswordAsync(CurrentUser!, req.New);
await db.SaveChangesAsync();
return NoContent();
}
[HttpPost("forgot-password")]
public async Task<IActionResult> ForgotPasswordAsync([FromBody] EmailForgotPasswordRequest req)
{
CheckRequirements();
if (!req.Email.Contains('@'))
throw new ApiError.BadRequest("Email is invalid", "email", req.Email);
AuthMethod? authMethod = await db
.AuthMethods.Where(m => m.AuthType == AuthType.Email && m.RemoteId == req.Email)
.FirstOrDefaultAsync();
if (authMethod == null)
return NoContent();
string state = await keyCacheService.GenerateForgotPasswordStateAsync(
req.Email,
authMethod.UserId
);
if (IsRateLimited())
return NoContent();
mailService.QueueResetPasswordEmail(req.Email, state);
return NoContent();
}
[HttpPost("reset-password")]
public async Task<IActionResult> ResetPasswordAsync([FromBody] EmailResetPasswordRequest req)
{
ForgotPasswordState? state = await keyCacheService.GetForgotPasswordStateAsync(req.State);
if (state == null)
throw new ApiError.BadRequest("Unknown state", "state", req.State);
if (
!await db
.AuthMethods.Where(m =>
m.AuthType == AuthType.Email
&& m.RemoteId == state.Email
&& m.UserId == state.UserId
)
.AnyAsync()
)
{
throw new ApiError.BadRequest("Invalid state");
}
ValidationUtils.Validate([("password", ValidationUtils.ValidatePassword(req.Password))]);
User user = await db.Users.FirstAsync(u => u.Id == state.UserId);
await authService.SetUserPasswordAsync(user, req.Password);
await db.SaveChangesAsync();
mailService.QueuePasswordChangedEmail(state.Email);
return NoContent();
}
[HttpPost("add-account")]
[Authorize("*")]
public async Task<IActionResult> AddEmailAddressAsync([FromBody] AddEmailAddressRequest req)
{
CheckRequirements();
List<AuthMethod> emails = await db
.AuthMethods.Where(m => m.UserId == CurrentUser!.Id && m.AuthType == AuthType.Email)
.ToListAsync();
if (emails.Count > AuthUtils.MaxAuthMethodsPerType)
{
throw new ApiError.BadRequest(
"Too many email addresses, maximum of 3 per account.",
"email",
null
);
}
if (emails.Count != 0)
{
if (!await authService.ValidatePasswordAsync(CurrentUser!, req.Password))
throw new ApiError.Forbidden("Invalid password");
}
else
{
ValidationUtils.Validate(
[("password", ValidationUtils.ValidatePassword(req.Password))]
);
await authService.SetUserPasswordAsync(CurrentUser!, req.Password);
await db.SaveChangesAsync();
}
string state = await keyCacheService.GenerateRegisterEmailStateAsync(
req.Email,
CurrentUser!.Id
);
bool emailExists = await db
.AuthMethods.Where(m => m.AuthType == AuthType.Email && m.RemoteId == req.Email)
.AnyAsync();
if (emailExists)
{
return NoContent();
}
if (IsRateLimited())
return NoContent();
mailService.QueueAddEmailAddressEmail(req.Email, state, CurrentUser.Username);
return NoContent();
}
[HttpPost("add-account/callback")]
[Authorize("*")]
public async Task<IActionResult> AddEmailCallbackAsync([FromBody] EmailCallbackRequest req)
{
CheckRequirements();
RegisterEmailState? state = await keyCacheService.GetRegisterEmailStateAsync(req.State);
if (state?.ExistingUserId != CurrentUser!.Id)
throw new ApiError.BadRequest("Invalid state", "state", req.State);
try
{
AuthMethod authMethod = await authService.AddAuthMethodAsync(
CurrentUser.Id,
AuthType.Email,
state.Email
);
_logger.Debug(
"Added email auth {AuthId} for user {UserId}",
authMethod.Id,
CurrentUser.Id
);
return Ok(
new AddOauthAccountResponse(
authMethod.Id,
AuthType.Email,
authMethod.RemoteId,
null
)
);
}
catch (UniqueConstraintException)
{
throw new ApiError(
"That email address is already linked.",
HttpStatusCode.BadRequest,
ErrorCode.AccountAlreadyLinked
);
}
}
public record AddEmailAddressRequest(string Email, string Password);
private void CheckRequirements()
{
if (!config.EmailAuth.Enabled)
throw new ApiError.BadRequest("Email authentication is not enabled on this instance.");
}
/// <summary>
/// Checks whether the context's IP address is rate limited from dispatching emails.
/// </summary>
private bool IsRateLimited()
{
if (HttpContext.Connection.RemoteIpAddress == null)
{
_logger.Information(
"No remote IP address in HTTP context for email-related request, ignoring as we can't rate limit it"
);
return true;
}
if (
!rateLimiter.IsLimited(
HttpContext.Connection.RemoteIpAddress.ToString(),
out Duration retryAfter
)
)
{
return false;
}
_logger.Information(
"IP address cannot send email until {RetryAfter}, ignoring",
retryAfter
);
return true;
}
}

View file

@ -0,0 +1,204 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Net;
using EntityFramework.Exceptions.Common;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth;
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace Foxnouns.Backend.Controllers.Authentication;
[Route("/api/internal/auth/fediverse")]
[ApiExplorerSettings(IgnoreApi = true)]
public class FediverseAuthController(
ILogger logger,
DatabaseContext db,
FediverseAuthService fediverseAuthService,
AuthService authService,
RemoteAuthService remoteAuthService,
KeyCacheService keyCacheService
) : ApiControllerBase
{
private readonly ILogger _logger = logger.ForContext<FediverseAuthController>();
[HttpGet]
[ProducesResponseType<SingleUrlResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> GetFediverseUrlAsync(
[FromQuery] string instance,
[FromQuery(Name = "force-refresh")] bool forceRefresh = false
)
{
if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.'))
throw new ApiError.BadRequest("Not a valid domain.", "instance", instance);
string url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh);
return Ok(new SingleUrlResponse(url));
}
[HttpPost("callback")]
[ProducesResponseType<CallbackResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> FediverseCallbackAsync([FromBody] FediverseCallbackRequest req)
{
FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance);
FediverseAuthService.FediverseUser remoteUser =
await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code, req.State);
User? user = await authService.AuthenticateUserAsync(
AuthType.Fediverse,
remoteUser.Id,
app
);
if (user != null)
return Ok(await authService.GenerateUserTokenAsync(user));
string ticket = AuthUtils.RandomToken();
await keyCacheService.SetKeyAsync(
$"fediverse:{ticket}",
new FediverseTicketData(app.Id, remoteUser),
Duration.FromMinutes(20)
);
return Ok(
new CallbackResponse(
false,
ticket,
$"@{remoteUser.Username}@{app.Domain}",
null,
null,
null
)
);
}
[HttpPost("register")]
[ProducesResponseType<AuthResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> RegisterAsync([FromBody] OauthRegisterRequest req)
{
FediverseTicketData? ticketData = await keyCacheService.GetKeyAsync<FediverseTicketData>(
$"fediverse:{req.Ticket}"
);
if (ticketData == null)
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
FediverseApplication? app = await db.FediverseApplications.FindAsync(
ticketData.ApplicationId
);
if (app == null)
throw new FoxnounsError("Null application found for ticket");
if (
await db.AuthMethods.AnyAsync(a =>
a.AuthType == AuthType.Fediverse
&& a.RemoteId == ticketData.User.Id
&& a.FediverseApplicationId == app.Id
)
)
{
_logger.Error(
"Fediverse user {Id}/{ApplicationId} ({Username} on {Domain}) has valid ticket but is already linked to an existing account",
ticketData.User.Id,
ticketData.ApplicationId,
ticketData.User.Username,
app.Domain
);
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
}
User user = await authService.CreateUserWithRemoteAuthAsync(
req.Username,
AuthType.Fediverse,
ticketData.User.Id,
ticketData.User.Username,
app
);
return Ok(await authService.GenerateUserTokenAsync(user));
}
[HttpGet("add-account")]
[Authorize("*")]
public async Task<IActionResult> AddFediverseAccountAsync(
[FromQuery] string instance,
[FromQuery(Name = "force-refresh")] bool forceRefresh = false
)
{
if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.'))
throw new ApiError.BadRequest("Not a valid domain.", "instance", instance);
string state = await remoteAuthService.ValidateAddAccountRequestAsync(
CurrentUser!.Id,
AuthType.Fediverse,
instance
);
string url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh, state);
return Ok(new SingleUrlResponse(url));
}
[HttpPost("add-account/callback")]
[Authorize("*")]
public async Task<IActionResult> AddAccountCallbackAsync(
[FromBody] FediverseCallbackRequest req
)
{
FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance);
FediverseAuthService.FediverseUser remoteUser =
await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code);
try
{
AuthMethod authMethod = await authService.AddAuthMethodAsync(
CurrentUser!.Id,
AuthType.Fediverse,
remoteUser.Id,
remoteUser.Username,
app
);
_logger.Debug(
"Added new Fediverse auth method {AuthMethodId} to user {UserId}",
authMethod.Id,
CurrentUser.Id
);
return Ok(
new AddOauthAccountResponse(
authMethod.Id,
AuthType.Fediverse,
authMethod.RemoteId,
$"{authMethod.RemoteUsername}@{app.Domain}"
)
);
}
catch (UniqueConstraintException)
{
throw new ApiError(
"That account is already linked.",
HttpStatusCode.BadRequest,
ErrorCode.AccountAlreadyLinked
);
}
}
private record FediverseTicketData(
Snowflake ApplicationId,
FediverseAuthService.FediverseUser User
);
}

View file

@ -0,0 +1,179 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Net;
using System.Web;
using EntityFramework.Exceptions.Common;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth;
using Foxnouns.Backend.Utils;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace Foxnouns.Backend.Controllers.Authentication;
[Route("/api/internal/auth/google")]
[ApiExplorerSettings(IgnoreApi = true)]
public class GoogleAuthController(
[UsedImplicitly] Config config,
ILogger logger,
DatabaseContext db,
KeyCacheService keyCacheService,
AuthService authService,
RemoteAuthService remoteAuthService
) : ApiControllerBase
{
private readonly ILogger _logger = logger.ForContext<GoogleAuthController>();
[HttpPost("callback")]
[ProducesResponseType<CallbackResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> CallbackAsync([FromBody] CallbackRequest req)
{
CheckRequirements();
await keyCacheService.ValidateAuthStateAsync(req.State);
RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestGoogleTokenAsync(
req.Code
);
User? user = await authService.AuthenticateUserAsync(AuthType.Google, remoteUser.Id);
if (user != null)
return Ok(await authService.GenerateUserTokenAsync(user));
_logger.Debug(
"Google user {Username} ({Id}) authenticated with no local account",
remoteUser.Username,
remoteUser.Id
);
string ticket = AuthUtils.RandomToken();
await keyCacheService.SetKeyAsync($"google:{ticket}", remoteUser, Duration.FromMinutes(20));
return Ok(new CallbackResponse(false, ticket, remoteUser.Username, null, null, null));
}
[HttpPost("register")]
[ProducesResponseType<AuthResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> RegisterAsync([FromBody] OauthRegisterRequest req)
{
RemoteAuthService.RemoteUser? remoteUser =
await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>($"google:{req.Ticket}");
if (remoteUser == null)
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
if (
await db.AuthMethods.AnyAsync(a =>
a.AuthType == AuthType.Google && a.RemoteId == remoteUser.Id
)
)
{
_logger.Error(
"Google user {Id} has valid ticket but is already linked to an existing account",
remoteUser.Id
);
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
}
User user = await authService.CreateUserWithRemoteAuthAsync(
req.Username,
AuthType.Google,
remoteUser.Id,
remoteUser.Username
);
return Ok(await authService.GenerateUserTokenAsync(user));
}
[HttpGet("add-account")]
[Authorize("*")]
public async Task<IActionResult> AddGoogleAccountAsync()
{
CheckRequirements();
string state = await remoteAuthService.ValidateAddAccountRequestAsync(
CurrentUser!.Id,
AuthType.Google
);
string url =
"https://accounts.google.com/o/oauth2/auth?response_type=code"
+ $"&client_id={config.GoogleAuth.ClientId}"
+ $"&scope=openid+{HttpUtility.UrlEncode("https://www.googleapis.com/auth/userinfo.email")}"
+ $"&prompt=select_account&state={state}"
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/google")}";
return Ok(new SingleUrlResponse(url));
}
[HttpPost("add-account/callback")]
[Authorize("*")]
public async Task<IActionResult> AddAccountCallbackAsync([FromBody] CallbackRequest req)
{
CheckRequirements();
await remoteAuthService.ValidateAddAccountStateAsync(
req.State,
CurrentUser!.Id,
AuthType.Google
);
RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestGoogleTokenAsync(
req.Code
);
try
{
AuthMethod authMethod = await authService.AddAuthMethodAsync(
CurrentUser.Id,
AuthType.Google,
remoteUser.Id,
remoteUser.Username
);
_logger.Debug(
"Added new Google auth method {AuthMethodId} to user {UserId}",
authMethod.Id,
CurrentUser.Id
);
return Ok(
new AddOauthAccountResponse(
authMethod.Id,
AuthType.Google,
authMethod.RemoteId,
authMethod.RemoteUsername
)
);
}
catch (UniqueConstraintException)
{
throw new ApiError(
"That account is already linked.",
HttpStatusCode.BadRequest,
ErrorCode.AccountAlreadyLinked
);
}
}
private void CheckRequirements()
{
if (!config.GoogleAuth.Enabled)
{
throw new ApiError.BadRequest("Google authentication is not enabled on this instance.");
}
}
}

View file

@ -0,0 +1,178 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Net;
using System.Web;
using EntityFramework.Exceptions.Common;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth;
using Foxnouns.Backend.Utils;
using JetBrains.Annotations;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace Foxnouns.Backend.Controllers.Authentication;
[Route("/api/internal/auth/tumblr")]
[ApiExplorerSettings(IgnoreApi = true)]
public class TumblrAuthController(
[UsedImplicitly] Config config,
ILogger logger,
DatabaseContext db,
KeyCacheService keyCacheService,
AuthService authService,
RemoteAuthService remoteAuthService
) : ApiControllerBase
{
private readonly ILogger _logger = logger.ForContext<TumblrAuthController>();
[HttpPost("callback")]
[ProducesResponseType<CallbackResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> CallbackAsync([FromBody] CallbackRequest req)
{
CheckRequirements();
await keyCacheService.ValidateAuthStateAsync(req.State);
RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestTumblrTokenAsync(
req.Code
);
User? user = await authService.AuthenticateUserAsync(AuthType.Tumblr, remoteUser.Id);
if (user != null)
return Ok(await authService.GenerateUserTokenAsync(user));
_logger.Debug(
"Tumblr user {Username} ({Id}) authenticated with no local account",
remoteUser.Username,
remoteUser.Id
);
string ticket = AuthUtils.RandomToken();
await keyCacheService.SetKeyAsync($"tumblr:{ticket}", remoteUser, Duration.FromMinutes(20));
return Ok(new CallbackResponse(false, ticket, remoteUser.Username, null, null, null));
}
[HttpPost("register")]
[ProducesResponseType<AuthResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> RegisterAsync([FromBody] OauthRegisterRequest req)
{
RemoteAuthService.RemoteUser? remoteUser =
await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>($"tumblr:{req.Ticket}");
if (remoteUser == null)
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
if (
await db.AuthMethods.AnyAsync(a =>
a.AuthType == AuthType.Tumblr && a.RemoteId == remoteUser.Id
)
)
{
_logger.Error(
"Tumblr user {Id} has valid ticket but is already linked to an existing account",
remoteUser.Id
);
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
}
User user = await authService.CreateUserWithRemoteAuthAsync(
req.Username,
AuthType.Tumblr,
remoteUser.Id,
remoteUser.Username
);
return Ok(await authService.GenerateUserTokenAsync(user));
}
[HttpGet("add-account")]
[Authorize("*")]
public async Task<IActionResult> AddTumblrAccountAsync()
{
CheckRequirements();
string state = await remoteAuthService.ValidateAddAccountRequestAsync(
CurrentUser!.Id,
AuthType.Tumblr
);
string url =
"https://www.tumblr.com/oauth2/authorize?response_type=code"
+ $"&client_id={config.TumblrAuth.ClientId}"
+ $"&scope=basic&state={state}"
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/tumblr")}";
return Ok(new SingleUrlResponse(url));
}
[HttpPost("add-account/callback")]
[Authorize("*")]
public async Task<IActionResult> AddAccountCallbackAsync([FromBody] CallbackRequest req)
{
CheckRequirements();
await remoteAuthService.ValidateAddAccountStateAsync(
req.State,
CurrentUser!.Id,
AuthType.Tumblr
);
RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestTumblrTokenAsync(
req.Code
);
try
{
AuthMethod authMethod = await authService.AddAuthMethodAsync(
CurrentUser.Id,
AuthType.Tumblr,
remoteUser.Id,
remoteUser.Username
);
_logger.Debug(
"Added new Tumblr auth method {AuthMethodId} to user {UserId}",
authMethod.Id,
CurrentUser.Id
);
return Ok(
new AddOauthAccountResponse(
authMethod.Id,
AuthType.Tumblr,
authMethod.RemoteId,
authMethod.RemoteUsername
)
);
}
catch (UniqueConstraintException)
{
throw new ApiError(
"That account is already linked.",
HttpStatusCode.BadRequest,
ErrorCode.AccountAlreadyLinked
);
}
}
private void CheckRequirements()
{
if (!config.TumblrAuth.Enabled)
{
throw new ApiError.BadRequest("Tumblr authentication is not enabled on this instance.");
}
}
}

View file

@ -1,32 +0,0 @@
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Services;
using Microsoft.AspNetCore.Mvc;
using NodaTime;
namespace Foxnouns.Backend.Controllers;
[Route("/api/v2/debug")]
public class DebugController(DatabaseContext db, AuthService authSvc, IClock clock, ILogger logger) : ApiControllerBase
{
[HttpPost("users")]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(AuthResponse))]
public async Task<IActionResult> CreateUser([FromBody] CreateUserRequest req)
{
logger.Debug("Creating user with username {Username} and email {Email}", req.Username, req.Email);
var user = await authSvc.CreateUserWithPasswordAsync(req.Username, req.Email, req.Password);
var frontendApp = await db.GetFrontendApplicationAsync();
var (tokenStr, token) =
authSvc.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365));
db.Add(token);
await db.SaveChangesAsync();
return Ok(new AuthResponse(user.Id, user.Username, tokenStr));
}
public record CreateUserRequest(string Username, string Password, string Email);
public record AuthResponse(Snowflake Id, string Username, string Token);
}

View file

@ -0,0 +1,89 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Middleware;
using Microsoft.AspNetCore.Mvc;
using NodaTime;
namespace Foxnouns.Backend.Controllers;
[Route("/api/internal/self-delete")]
[Authorize("*")]
[ApiExplorerSettings(IgnoreApi = true)]
public class DeleteUserController(DatabaseContext db, IClock clock, ILogger logger)
: ApiControllerBase
{
private readonly ILogger _logger = logger.ForContext<DeleteUserController>();
[HttpPost("delete")]
public async Task<IActionResult> DeleteSelfAsync()
{
_logger.Information(
"User {UserId} has requested their account to be deleted",
CurrentUser!.Id
);
CurrentUser.Deleted = true;
CurrentUser.DeletedAt = clock.GetCurrentInstant();
db.Update(CurrentUser);
await db.SaveChangesAsync();
return NoContent();
}
[HttpPost("force")]
[Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> ForceDeleteAsync()
{
if (!CurrentUser!.Deleted)
throw new ApiError.BadRequest("Your account isn't deleted.");
_logger.Information(
"User {UserId} has requested an early full delete of their account",
CurrentUser.Id
);
// This is the easiest way to force delete a user, don't judge me
CurrentUser.DeletedAt = clock.GetCurrentInstant() - Duration.FromDays(365);
db.Update(CurrentUser);
await db.SaveChangesAsync();
return NoContent();
}
[HttpPost("undelete")]
[Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> UndeleteSelfAsync()
{
if (!CurrentUser!.Deleted)
throw new ApiError.BadRequest("Your account isn't deleted.");
if (CurrentUser!.DeletedBy != null)
{
throw new ApiError.BadRequest(
"Your account has been suspended and can't be reactivated by yourself."
);
}
_logger.Information(
"User {UserId} has requested to undelete their account",
CurrentUser.Id
);
CurrentUser.Deleted = false;
CurrentUser.DeletedAt = null;
db.Update(CurrentUser);
await db.SaveChangesAsync();
return NoContent();
}
}

View file

@ -0,0 +1,80 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Jobs;
using Foxnouns.Backend.Middleware;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace Foxnouns.Backend.Controllers;
[Route("/api/internal/data-exports")]
[Authorize("identify")]
[Limit(UsableByDeletedUsers = true)]
[ApiExplorerSettings(IgnoreApi = true)]
public class ExportsController(ILogger logger, Config config, IClock clock, DatabaseContext db)
: ApiControllerBase
{
private static readonly Duration MinimumTimeBetween = Duration.FromDays(1);
private readonly ILogger _logger = logger.ForContext<ExportsController>();
[HttpGet]
public async Task<IActionResult> GetDataExportsAsync()
{
DataExport? export = await db
.DataExports.Where(d => d.UserId == CurrentUser!.Id)
.OrderByDescending(d => d.Id)
.FirstOrDefaultAsync();
if (export == null)
return Ok(new DataExportResponse(null, null));
return Ok(
new DataExportResponse(
ExportUrl(CurrentUser!.Id, export.Filename),
export.Id.Time + DataExport.Expiration
)
);
}
private string ExportUrl(Snowflake userId, string filename) =>
$"{config.MediaBaseUrl}/data-exports/{userId}/{filename}/data-export.zip";
[HttpPost]
public async Task<IActionResult> QueueDataExportAsync()
{
var snowflakeToCheck = Snowflake.FromInstant(
clock.GetCurrentInstant() - MinimumTimeBetween
);
_logger.Debug(
"Checking if user {UserId} has data exports newer than {Snowflake}",
CurrentUser!.Id,
snowflakeToCheck
);
if (
await db.DataExports.AnyAsync(d =>
d.UserId == CurrentUser.Id && d.Id > snowflakeToCheck
)
)
{
throw new ApiError.BadRequest("You can't request a new data export so soon.");
}
CreateDataExportJob.Enqueue(CurrentUser.Id);
return NoContent();
}
}

View file

@ -0,0 +1,209 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Jobs;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using XidNet;
namespace Foxnouns.Backend.Controllers;
[Route("/api/v2/users/@me/flags")]
public class FlagsController(
DatabaseContext db,
UserRendererService userRenderer,
ISnowflakeGenerator snowflakeGenerator
) : ApiControllerBase
{
[HttpGet]
[Limit(UsableByDeletedUsers = true)]
[Authorize("user.read_flags")]
[ProducesResponseType<IEnumerable<PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default)
{
List<PrideFlag> flags = await db
.PrideFlags.Where(f => f.UserId == CurrentUser!.Id)
.OrderBy(f => f.Name)
.ThenBy(f => f.Id)
.ToListAsync(ct);
return Ok(flags.Select(userRenderer.RenderPrideFlag));
}
public const int MaxFlagCount = 500;
[HttpPost]
[Authorize("user.update_flags")]
[ProducesResponseType<PrideFlagResponse>(statusCode: StatusCodes.Status202Accepted)]
public async Task<IActionResult> CreateFlagAsync([FromBody] CreateFlagRequest req)
{
int flagCount = await db.PrideFlags.Where(f => f.UserId == CurrentUser!.Id).CountAsync();
if (flagCount >= MaxFlagCount)
throw new ApiError.BadRequest("Maximum number of flags reached");
ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, req.Image));
var flag = new PrideFlag
{
Id = snowflakeGenerator.GenerateSnowflake(),
LegacyId = Xid.NewXid().ToString(),
UserId = CurrentUser!.Id,
Name = req.Name,
Description = req.Description,
};
db.Add(flag);
await db.SaveChangesAsync();
CreateFlagJob.Enqueue(new CreateFlagPayload(flag.Id, CurrentUser!.Id, req.Image));
return Accepted(userRenderer.RenderPrideFlag(flag));
}
[HttpPatch("{id}")]
[Authorize("user.create_flags")]
[ProducesResponseType<PrideFlagResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateFlagAsync(Snowflake id, [FromBody] UpdateFlagRequest req)
{
ValidationUtils.Validate(ValidateFlag(req.Name, req.Description, null));
PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
f.Id == id && f.UserId == CurrentUser!.Id
);
if (flag == null)
throw new ApiError.NotFound("Unknown flag ID, or it's not your flag.");
if (req.Name != null)
flag.Name = req.Name;
if (req.HasProperty(nameof(req.Description)))
flag.Description = req.Description;
db.Update(flag);
await db.SaveChangesAsync();
return Ok(userRenderer.RenderPrideFlag(flag));
}
[HttpDelete("{id}")]
[Authorize("user.update_flags")]
public async Task<IActionResult> DeleteFlagAsync(Snowflake id)
{
PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
f.Id == id && f.UserId == CurrentUser!.Id
);
if (flag == null)
throw new ApiError.NotFound("Unknown flag ID, or it's not your flag.");
db.PrideFlags.Remove(flag);
await db.SaveChangesAsync();
return NoContent();
}
private static List<(string, ValidationError?)> ValidateFlag(
string? name,
string? description,
string? imageData
)
{
var errors = new List<(string, ValidationError?)>();
if (name != null)
{
switch (name.Length)
{
case < 1:
errors.Add(
(
"name",
ValidationError.LengthError("Name is too short", 1, 100, name.Length)
)
);
break;
case > 100:
errors.Add(
(
"name",
ValidationError.LengthError("Name is too long", 1, 100, name.Length)
)
);
break;
}
}
if (description != null)
{
switch (description.Length)
{
case < 1:
errors.Add(
(
"description",
ValidationError.LengthError(
"Description is too short",
1,
100,
description.Length
)
)
);
break;
case > 500:
errors.Add(
(
"description",
ValidationError.LengthError(
"Description is too long",
1,
100,
description.Length
)
)
);
break;
}
}
if (imageData != null)
{
switch (imageData.Length)
{
case 0:
errors.Add(
(
"image",
ValidationError.GenericValidationError("Image cannot be empty", null)
)
);
break;
case > 1_500_000:
errors.Add(
(
"image",
ValidationError.GenericValidationError("Image is too large", null)
)
);
break;
}
}
return errors;
}
}

View file

@ -0,0 +1,123 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Text.RegularExpressions;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Routing.Template;
namespace Foxnouns.Backend.Controllers;
[ApiController]
[Route("/api/internal")]
[ApiExplorerSettings(IgnoreApi = true)]
public partial class InternalController(DatabaseContext db) : ControllerBase
{
[GeneratedRegex(@"(\{\w+\})")]
private static partial Regex PathVarRegex();
[GeneratedRegex(@"\{id\}")]
private static partial Regex IdCountRegex();
private static string GetCleanedTemplate(string template)
{
if (template.StartsWith("api/v2"))
template = template["api/v2".Length..];
else if (template.StartsWith("api/v1"))
template = template["api/v1".Length..];
template = PathVarRegex()
.Replace(template, "{id}") // Replace all path variables (almost always IDs) with `{id}`
.Replace("@me", "{id}"); // Also replace hardcoded `@me` with `{id}`
// If there's at least one path parameter, we only return the *first* part of the path.
if (template.Contains("{id}"))
{
// However, if the path starts with /users/{id} *and* there's another path parameter (such as a member ID)
// we ignore the leading /users/{id}. This is because a lot of routes are scoped by user, but should have
// separate rate limits from other user-scoped routes.
if (template.StartsWith("/users/{id}/") && IdCountRegex().Count(template) >= 2)
template = template["/users/{id}".Length..];
return template.Split("{id}")[0] + "{id}";
}
return template;
}
[HttpPost("request-data")]
public async Task<IActionResult> GetRequestDataAsync([FromBody] RequestDataRequest req)
{
RouteEndpoint? endpoint = GetEndpoint(HttpContext, req.Path, req.Method);
if (endpoint == null)
throw new ApiError.BadRequest("Path/method combination is invalid");
ControllerActionDescriptor? actionDescriptor =
endpoint.Metadata.GetMetadata<ControllerActionDescriptor>();
string? template = actionDescriptor?.AttributeRouteInfo?.Template;
if (template == null)
throw new FoxnounsError("Template value was null on valid endpoint");
template = GetCleanedTemplate(template);
// If no token was supplied, or it isn't valid base 64, return a null user ID (limiting by IP)
if (!AuthUtils.TryParseToken(req.Token, out byte[]? rawToken))
return Ok(new RequestDataResponse(null, template));
Snowflake? userId = await db.GetTokenUserId(rawToken);
return Ok(new RequestDataResponse(userId, template));
}
private static RouteEndpoint? GetEndpoint(
HttpContext httpContext,
string url,
string requestMethod
)
{
EndpointDataSource? endpointDataSource =
httpContext.RequestServices.GetService<EndpointDataSource>();
if (endpointDataSource == null)
return null;
IEnumerable<RouteEndpoint> endpoints = endpointDataSource.Endpoints.OfType<RouteEndpoint>();
foreach (RouteEndpoint? endpoint in endpoints)
{
if (endpoint.RoutePattern.RawText == null)
continue;
var templateMatcher = new TemplateMatcher(
TemplateParser.Parse(endpoint.RoutePattern.RawText),
new RouteValueDictionary()
);
if (!templateMatcher.TryMatch(url, new RouteValueDictionary()))
continue;
HttpMethodAttribute? httpMethodAttribute =
endpoint.Metadata.GetMetadata<HttpMethodAttribute>();
if (
httpMethodAttribute?.HttpMethods.Any(x =>
x.Equals(requestMethod, StringComparison.OrdinalIgnoreCase)
) == false
)
{
continue;
}
return endpoint;
}
return null;
}
}

View file

@ -0,0 +1,322 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using EntityFramework.Exceptions.Common;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Jobs;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using NodaTime;
using XidNet;
namespace Foxnouns.Backend.Controllers;
[Route("/api/v2/users/{userRef}/members")]
public class MembersController(
ILogger logger,
DatabaseContext db,
MemberRendererService memberRenderer,
ISnowflakeGenerator snowflakeGenerator,
ObjectStorageService objectStorageService,
IClock clock,
ValidationService validationService,
Config config
) : ApiControllerBase
{
private readonly ILogger _logger = logger.ForContext<MembersController>();
[HttpGet]
[ProducesResponseType<IEnumerable<PartialMember>>(StatusCodes.Status200OK)]
[Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
{
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
return Ok(await memberRenderer.RenderUserMembersAsync(user, CurrentToken));
}
[HttpGet("{memberRef}")]
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
[Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> GetMemberAsync(
string userRef,
string memberRef,
CancellationToken ct = default
)
{
Member member = await db.ResolveMemberAsync(userRef, memberRef, CurrentToken, ct);
return Ok(memberRenderer.RenderMember(member, CurrentToken));
}
[HttpPost("/api/v2/users/@me/members")]
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
[Authorize("member.create")]
public async Task<IActionResult> CreateMemberAsync(
[FromBody] CreateMemberRequest req,
CancellationToken ct = default
)
{
ValidationUtils.Validate(
[
("name", validationService.ValidateMemberName(req.Name)),
("display_name", validationService.ValidateDisplayName(req.DisplayName)),
("bio", validationService.ValidateBio(req.Bio)),
("avatar", validationService.ValidateAvatar(req.Avatar)),
.. validationService.ValidateFields(req.Fields, CurrentUser!.CustomPreferences),
.. validationService.ValidateFieldEntries(
req.Names?.ToArray(),
CurrentUser!.CustomPreferences,
"names"
),
.. validationService.ValidatePronouns(
req.Pronouns?.ToArray(),
CurrentUser!.CustomPreferences
),
.. validationService.ValidateLinks(req.Links),
]
);
int memberCount = await db.Members.CountAsync(m => m.UserId == CurrentUser.Id, ct);
if (memberCount >= config.Limits.MaxMemberCount)
throw new ApiError.BadRequest("Maximum number of members reached");
var member = new Member
{
Id = snowflakeGenerator.GenerateSnowflake(),
LegacyId = Xid.NewXid().ToString(),
User = CurrentUser!,
Name = req.Name,
DisplayName = req.DisplayName,
Bio = req.Bio,
Links = req.Links ?? [],
Fields = req.Fields ?? [],
Names = req.Names ?? [],
Pronouns = req.Pronouns ?? [],
Unlisted = req.Unlisted ?? false,
Sid = null!,
};
db.Add(member);
_logger.Debug(
"Creating member {MemberName} ({Id}) for {UserId}",
member.Name,
member.Id,
CurrentUser!.Id
);
CurrentUser.LastActive = clock.GetCurrentInstant();
db.Update(CurrentUser);
try
{
await db.SaveChangesAsync(ct);
}
catch (UniqueConstraintException)
{
_logger.Debug("Could not create member {Id} due to name conflict", member.Id);
throw new ApiError.BadRequest(
"A member with that name already exists",
"name",
req.Name
);
}
if (req.Avatar != null)
{
MemberAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(member.Id, req.Avatar));
}
return Ok(memberRenderer.RenderMember(member, CurrentToken));
}
[HttpPatch("/api/v2/users/@me/members/{memberRef}")]
[ProducesResponseType<MemberResponse>(statusCode: StatusCodes.Status200OK)]
[Authorize("member.update")]
public async Task<IActionResult> UpdateMemberAsync(
string memberRef,
[FromBody] UpdateMemberRequest req
)
{
await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync();
Member member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
var errors = new List<(string, ValidationError?)>();
// We might add extra validations for names later down the line.
// These should only take effect when a member's name is changed, not on other changes.
if (req.Name != null && req.Name != member.Name)
{
errors.Add(("name", validationService.ValidateMemberName(req.Name)));
member.Name = req.Name;
}
if (req.HasProperty(nameof(req.DisplayName)))
{
errors.Add(("display_name", validationService.ValidateDisplayName(req.DisplayName)));
member.DisplayName = req.DisplayName;
}
if (req.HasProperty(nameof(req.Bio)))
{
errors.Add(("bio", validationService.ValidateBio(req.Bio)));
member.Bio = req.Bio;
}
if (req.HasProperty(nameof(req.Links)))
{
errors.AddRange(validationService.ValidateLinks(req.Links));
member.Links = req.Links ?? [];
}
if (req.HasProperty(nameof(req.Unlisted)))
member.Unlisted = req.Unlisted ?? false;
if (req.Names != null)
{
errors.AddRange(
validationService.ValidateFieldEntries(
req.Names,
CurrentUser!.CustomPreferences,
"names"
)
);
member.Names = req.Names.ToList();
}
if (req.Pronouns != null)
{
errors.AddRange(
validationService.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences)
);
member.Pronouns = req.Pronouns.ToList();
}
if (req.Fields != null)
{
errors.AddRange(
validationService.ValidateFields(
req.Fields.ToList(),
CurrentUser!.CustomPreferences
)
);
member.Fields = req.Fields.ToList();
}
if (req.Flags != null)
{
ValidationError? flagError = await db.SetMemberFlagsAsync(
CurrentUser!.Id,
member.Id,
req.Flags
);
if (flagError != null)
errors.Add(("flags", flagError));
}
if (req.HasProperty(nameof(req.Avatar)))
errors.Add(("avatar", validationService.ValidateAvatar(req.Avatar)));
ValidationUtils.Validate(errors);
// This is fired off regardless of whether the transaction is committed
// (atomic operations are hard when combined with background jobs)
// so it's in a separate block to the validation above.
if (req.HasProperty(nameof(req.Avatar)))
{
MemberAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(member.Id, req.Avatar));
}
CurrentUser.LastActive = clock.GetCurrentInstant();
db.Update(CurrentUser);
try
{
await db.SaveChangesAsync();
}
catch (UniqueConstraintException)
{
_logger.Debug(
"Could not update member {Id} due to name conflict ({CurrentName} / {NewName})",
member.Id,
member.Name,
req.Name
);
throw new ApiError.BadRequest(
"A member with that name already exists",
"name",
req.Name
);
}
await tx.CommitAsync();
return Ok(memberRenderer.RenderMember(member, CurrentToken));
}
[HttpDelete("/api/v2/users/@me/members/{memberRef}")]
[Authorize("member.update")]
public async Task<IActionResult> DeleteMemberAsync(string memberRef)
{
Member member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
int deleteCount = await db
.Members.Where(m => m.UserId == CurrentUser!.Id && m.Id == member.Id)
.ExecuteDeleteAsync();
if (deleteCount == 0)
{
_logger.Warning(
"Successfully resolved member {Id} but could not delete them",
member.Id
);
return NoContent();
}
if (member.Avatar != null)
await objectStorageService.DeleteMemberAvatarAsync(member.Id, member.Avatar);
return NoContent();
}
[HttpPost("/api/v2/users/@me/members/{memberRef}/reroll-sid")]
[Authorize("member.update")]
[ProducesResponseType<MemberResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> RerollSidAsync(string memberRef)
{
Member member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
Instant minTimeAgo = clock.GetCurrentInstant() - Duration.FromHours(1);
if (CurrentUser!.LastSidReroll > minTimeAgo)
throw new ApiError.BadRequest("Cannot reroll short ID yet");
// Using ExecuteUpdateAsync here as the new short ID is generated by the database
await db
.Members.Where(m => m.Id == member.Id)
.ExecuteUpdateAsync(s => s.SetProperty(m => m.Sid, _ => db.FindFreeMemberSid()));
await db
.Users.Where(u => u.Id == CurrentUser.Id)
.ExecuteUpdateAsync(s =>
s.SetProperty(u => u.LastSidReroll, clock.GetCurrentInstant())
.SetProperty(u => u.LastActive, clock.GetCurrentInstant())
);
// Fetch the new sid then pass that to RenderMember
string newSid = await db
.Members.Where(m => m.Id == member.Id)
.Select(m => m.Sid)
.FirstAsync();
return Ok(memberRenderer.RenderMember(member, CurrentToken, newSid));
}
}

View file

@ -0,0 +1,85 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Text.RegularExpressions;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Services.Caching;
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc;
namespace Foxnouns.Backend.Controllers;
[Route("/api/v2/meta")]
public partial class MetaController(Config config, NoticeCacheService noticeCache)
: ApiControllerBase
{
private const string Repository = "https://codeberg.org/pronounscc/pronouns.cc";
[HttpGet]
[ProducesResponseType<MetaResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> GetMeta(CancellationToken ct = default) =>
Ok(
new MetaResponse(
Repository,
BuildInfo.Version,
BuildInfo.Hash,
(int)FoxnounsMetrics.MemberCount.Value,
new UserInfoResponse(
(int)FoxnounsMetrics.UsersCount.Value,
(int)FoxnounsMetrics.UsersActiveMonthCount.Value,
(int)FoxnounsMetrics.UsersActiveWeekCount.Value,
(int)FoxnounsMetrics.UsersActiveDayCount.Value
),
new LimitsResponse(
config.Limits.MaxMemberCount,
config.Limits.MaxBioLength,
ValidationUtils.MaxCustomPreferences,
AuthUtils.MaxAuthMethodsPerType,
FlagsController.MaxFlagCount
),
Notice: NoticeResponse(await noticeCache.GetAsync(ct))
)
);
private static MetaNoticeResponse? NoticeResponse(Notice? notice) =>
notice == null ? null : new MetaNoticeResponse(notice.Id, notice.Message);
[HttpGet("page/{page}")]
public async Task<IActionResult> GetStaticPageAsync(string page, CancellationToken ct = default)
{
if (!PageRegex().IsMatch(page))
{
throw new ApiError.BadRequest("Invalid page name");
}
string path = Path.Join(Directory.GetCurrentDirectory(), "static-pages", $"{page}.md");
try
{
string text = await System.IO.File.ReadAllTextAsync(path, ct);
return Ok(text);
}
catch (FileNotFoundException)
{
throw new ApiError.NotFound("Page not found", code: ErrorCode.PageNotFound);
}
}
[HttpGet("/api/v2/coffee")]
public IActionResult BrewCoffee() =>
StatusCode(StatusCodes.Status418ImATeapot, "Sorry, I'm a teapot!");
[GeneratedRegex(@"^[a-z\-_]+$")]
private static partial Regex PageRegex();
}

View file

@ -0,0 +1,78 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Controllers.Moderation;
[Route("/api/v2/moderation/audit-log")]
[Authorize("user.moderation")]
[Limit(RequireModerator = true)]
public class AuditLogController(DatabaseContext db, ModerationRendererService moderationRenderer)
: ApiControllerBase
{
public async Task<IActionResult> GetAuditLogAsync(
[FromQuery] AuditLogEntryType? type = null,
[FromQuery] int? limit = null,
[FromQuery] Snowflake? before = null,
[FromQuery] Snowflake? after = null,
[FromQuery(Name = "by-moderator")] Snowflake? byModerator = null
)
{
limit = limit switch
{
> 100 => 100,
< 0 => 100,
null => 100,
_ => limit,
};
IQueryable<AuditLogEntry> query = db
.AuditLog.Include(e => e.Report)
.OrderByDescending(e => e.Id);
if (before != null)
query = query.Where(e => e.Id < before.Value);
else if (after != null)
query = query.Where(e => e.Id > after.Value);
if (type != null)
query = query.Where(e => e.Type == type);
if (byModerator != null)
query = query.Where(e => e.ModeratorId == byModerator.Value);
List<AuditLogEntry> entries = await query.Take(limit!.Value).ToListAsync();
return Ok(entries.Select(moderationRenderer.RenderAuditLogEntry));
}
[HttpGet("moderators")]
public async Task<IActionResult> GetModeratorsAsync(CancellationToken ct = default)
{
var moderators = await db
.Users.Where(u =>
!u.Deleted && (u.Role == UserRole.Admin || u.Role == UserRole.Moderator)
)
.Select(u => new { u.Id, u.Username })
.OrderBy(u => u.Id)
.ToListAsync(ct);
return Ok(moderators);
}
}

View file

@ -0,0 +1,96 @@
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Controllers.Moderation;
[Route("/api/v2/moderation/lookup")]
[Authorize("user.moderation")]
[Limit(RequireModerator = true)]
public class LookupController(
DatabaseContext db,
UserRendererService userRenderer,
ModerationService moderationService,
ModerationRendererService moderationRenderer
) : ApiControllerBase
{
[HttpPost]
public async Task<IActionResult> QueryUsersAsync(
[FromBody] QueryUsersRequest req,
CancellationToken ct = default
)
{
var query = db.Users.Select(u => new { u.Id, u.Username });
query = req.Fuzzy
? query.Where(u => u.Username.Contains(req.Query))
: query.Where(u => u.Username == req.Query);
var users = await query.OrderBy(u => u.Id).Take(100).ToListAsync(ct);
return Ok(users);
}
[HttpGet("{id}")]
public async Task<IActionResult> QueryUserAsync(Snowflake id, CancellationToken ct = default)
{
User user = await db.ResolveUserAsync(id, ct);
bool showSensitiveData = await moderationService.ShowSensitiveDataAsync(
CurrentUser!,
user,
ct
);
List<AuthMethod> authMethods = showSensitiveData
? await db
.AuthMethods.Where(a => a.UserId == user.Id)
.Include(a => a.FediverseApplication)
.ToListAsync(ct)
: [];
return Ok(
new QueryUserResponse(
User: await userRenderer.RenderUserAsync(
user,
renderMembers: false,
renderAuthMethods: false,
ct: ct
),
MemberListHidden: user.ListHidden,
LastActive: user.LastActive,
LastSidReroll: user.LastSidReroll,
Suspended: user is { Deleted: true, DeletedBy: not null },
Deleted: user.Deleted,
ShowSensitiveData: showSensitiveData,
AuthMethods: showSensitiveData
? authMethods.Select(UserRendererService.RenderAuthMethod)
: null
)
);
}
[HttpPost("{id}/sensitive")]
public async Task<IActionResult> QuerySensitiveUserDataAsync(
Snowflake id,
[FromBody] QuerySensitiveUserDataRequest req
)
{
User user = await db.ResolveUserAsync(id);
// Don't let mods accidentally spam the audit log
bool alreadyAuthorized = await moderationService.ShowSensitiveDataAsync(CurrentUser!, user);
if (alreadyAuthorized)
return NoContent();
AuditLogEntry entry = await moderationService.QuerySensitiveDataAsync(
CurrentUser!,
user,
req.Reason
);
return Ok(moderationRenderer.RenderAuditLogEntry(entry));
}
}

View file

@ -0,0 +1,138 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Net;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Controllers.Moderation;
[Route("/api/v2/moderation")]
[Authorize("user.moderation")]
[Limit(RequireModerator = true)]
public class ModActionsController(
DatabaseContext db,
ModerationService moderationService,
ModerationRendererService moderationRenderer
) : ApiControllerBase
{
[HttpPost("warnings/{id}")]
public async Task<IActionResult> WarnUserAsync(Snowflake id, [FromBody] WarnUserRequest req)
{
User user = await db.ResolveUserAsync(id);
if (user.Deleted)
{
throw new ApiError(
"This user is already deleted.",
HttpStatusCode.BadRequest,
ErrorCode.InvalidWarningTarget
);
}
if (user.Id == CurrentUser!.Id)
{
throw new ApiError(
"You can't warn yourself.",
HttpStatusCode.BadRequest,
ErrorCode.InvalidWarningTarget
);
}
Member? member = null;
if (req.MemberId != null)
{
member = await db.Members.FirstOrDefaultAsync(m =>
m.Id == req.MemberId && m.UserId == user.Id
);
if (member == null)
throw new ApiError.NotFound("No member with that ID found.");
}
Report? report = null;
if (req.ReportId != null)
{
report = await db.Reports.FindAsync(req.ReportId);
if (report is not { Status: ReportStatus.Open })
{
throw new ApiError.NotFound(
"No report with that ID found, or it's already closed."
);
}
}
AuditLogEntry entry = await moderationService.ExecuteWarningAsync(
CurrentUser,
user,
member,
report,
req.Reason,
req.ClearFields
);
return Ok(moderationRenderer.RenderAuditLogEntry(entry));
}
[HttpPost("suspensions/{id}")]
public async Task<IActionResult> SuspendUserAsync(
Snowflake id,
[FromBody] SuspendUserRequest req
)
{
User user = await db.ResolveUserAsync(id);
if (user.Deleted)
{
throw new ApiError(
"This user is already deleted.",
HttpStatusCode.BadRequest,
ErrorCode.InvalidWarningTarget
);
}
if (user.Id == CurrentUser!.Id)
{
throw new ApiError(
"You can't warn yourself.",
HttpStatusCode.BadRequest,
ErrorCode.InvalidWarningTarget
);
}
Report? report = null;
if (req.ReportId != null)
{
report = await db.Reports.FindAsync(req.ReportId);
if (report is not { Status: ReportStatus.Open })
{
throw new ApiError.NotFound(
"No report with that ID found, or it's already closed."
);
}
}
AuditLogEntry entry = await moderationService.ExecuteSuspensionAsync(
CurrentUser,
user,
report,
req.Reason,
req.ClearProfile
);
return Ok(moderationRenderer.RenderAuditLogEntry(entry));
}
}

View file

@ -0,0 +1,77 @@
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace Foxnouns.Backend.Controllers.Moderation;
[Route("/api/v2/notices")]
[Authorize("user.moderation")]
[Limit(RequireModerator = true)]
public class NoticesController(
DatabaseContext db,
UserRendererService userRenderer,
ISnowflakeGenerator snowflakeGenerator,
IClock clock
) : ApiControllerBase
{
[HttpGet]
public async Task<IActionResult> GetNoticesAsync(CancellationToken ct = default)
{
List<Notice> notices = await db
.Notices.Include(n => n.Author)
.OrderByDescending(n => n.Id)
.ToListAsync(ct);
return Ok(notices.Select(RenderNotice));
}
[HttpPost]
public async Task<IActionResult> CreateNoticeAsync(CreateNoticeRequest req)
{
Instant now = clock.GetCurrentInstant();
if (req.StartTime < now)
{
throw new ApiError.BadRequest(
"Start time cannot be in the past",
"start_time",
req.StartTime
);
}
if (req.EndTime < now)
{
throw new ApiError.BadRequest(
"End time cannot be in the past",
"end_time",
req.EndTime
);
}
var notice = new Notice
{
Id = snowflakeGenerator.GenerateSnowflake(),
Message = req.Message,
StartTime = req.StartTime ?? clock.GetCurrentInstant(),
EndTime = req.EndTime,
Author = CurrentUser!,
};
db.Add(notice);
await db.SaveChangesAsync();
return Ok(RenderNotice(notice));
}
private NoticeResponse RenderNotice(Notice notice) =>
new(
notice.Id,
notice.Message,
notice.StartTime,
notice.EndTime,
userRenderer.RenderPartialUser(notice.Author)
);
}

View file

@ -0,0 +1,277 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Net;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using NodaTime;
namespace Foxnouns.Backend.Controllers.Moderation;
[Route("/api/v2/moderation")]
public class ReportsController(
ILogger logger,
DatabaseContext db,
IClock clock,
ISnowflakeGenerator snowflakeGenerator,
UserRendererService userRenderer,
MemberRendererService memberRenderer,
ModerationRendererService moderationRenderer,
ModerationService moderationService
) : ApiControllerBase
{
private readonly ILogger _logger = logger.ForContext<ReportsController>();
private Snowflake MaxReportId() =>
Snowflake.FromInstant(clock.GetCurrentInstant() - Duration.FromHours(12));
[HttpPost("report-user/{id}")]
[Authorize("user.moderation")]
public async Task<IActionResult> ReportUserAsync(
Snowflake id,
[FromBody] CreateReportRequest req
)
{
ValidationUtils.Validate([("context", ValidationUtils.ValidateReportContext(req.Context))]);
User target = await db.ResolveUserAsync(id);
if (target.Id == CurrentUser!.Id)
{
throw new ApiError(
"You can't report yourself.",
HttpStatusCode.BadRequest,
ErrorCode.InvalidReportTarget
);
}
Snowflake reportCutoff = MaxReportId();
if (
await db
.Reports.Where(r =>
r.ReporterId == CurrentUser!.Id
&& r.TargetUserId == target.Id
&& r.Id > reportCutoff
)
.AnyAsync()
)
{
_logger.Debug(
"User {ReporterId} has already reported {TargetId} in the last 12 hours, ignoring report",
CurrentUser!.Id,
target.Id
);
return NoContent();
}
_logger.Information(
"Creating report on {TargetId} by {ReporterId}",
target.Id,
CurrentUser!.Id
);
string snapshot = JsonConvert.SerializeObject(
await userRenderer.RenderUserAsync(target, renderMembers: false)
);
var report = new Report
{
Id = snowflakeGenerator.GenerateSnowflake(),
ReporterId = CurrentUser.Id,
TargetUserId = target.Id,
TargetMemberId = null,
Reason = req.Reason,
Context = req.Context,
TargetType = ReportTargetType.User,
TargetSnapshot = snapshot,
};
db.Reports.Add(report);
await db.SaveChangesAsync();
return NoContent();
}
[HttpPost("report-member/{id}")]
[Authorize("user.moderation")]
public async Task<IActionResult> ReportMemberAsync(
Snowflake id,
[FromBody] CreateReportRequest req
)
{
ValidationUtils.Validate([("context", ValidationUtils.ValidateReportContext(req.Context))]);
Member target = await db.ResolveMemberAsync(id);
if (target.User.Id == CurrentUser!.Id)
{
throw new ApiError(
"You can't report yourself.",
HttpStatusCode.BadRequest,
ErrorCode.InvalidReportTarget
);
}
Snowflake reportCutoff = MaxReportId();
if (
await db
.Reports.Where(r =>
r.ReporterId == CurrentUser!.Id
&& r.TargetUserId == target.User.Id
&& r.Id > reportCutoff
)
.AnyAsync()
)
{
_logger.Debug(
"User {ReporterId} has already reported {TargetId} in the last 12 hours, ignoring report",
CurrentUser!.Id,
target.User.Id
);
return NoContent();
}
_logger.Information(
"Creating report on {TargetId} (member {TargetMemberId}) by {ReporterId}",
target.User.Id,
target.Id,
CurrentUser!.Id
);
string snapshot = JsonConvert.SerializeObject(memberRenderer.RenderMember(target));
var report = new Report
{
Id = snowflakeGenerator.GenerateSnowflake(),
ReporterId = CurrentUser.Id,
TargetUserId = target.User.Id,
TargetMemberId = target.Id,
Reason = req.Reason,
Context = req.Context,
TargetType = ReportTargetType.Member,
TargetSnapshot = snapshot,
};
db.Reports.Add(report);
await db.SaveChangesAsync();
return NoContent();
}
[HttpGet("reports")]
[Authorize("user.moderation")]
[Limit(RequireModerator = true)]
public async Task<IActionResult> GetReportsAsync(
[FromQuery] int? limit = null,
[FromQuery] Snowflake? before = null,
[FromQuery] Snowflake? after = null,
[FromQuery(Name = "by-reporter")] Snowflake? byReporter = null,
[FromQuery(Name = "by-target")] Snowflake? byTarget = null,
[FromQuery(Name = "include-closed")] bool includeClosed = false
)
{
limit = limit switch
{
> 100 => 100,
< 0 => 100,
null => 100,
_ => limit,
};
IQueryable<Report> query = db
.Reports.Include(r => r.Reporter)
.Include(r => r.TargetUser)
.Include(r => r.TargetMember);
if (byTarget != null && await db.Users.AnyAsync(u => u.Id == byTarget.Value))
query = query.Where(r => r.TargetUserId == byTarget.Value);
if (byReporter != null && await db.Users.AnyAsync(u => u.Id == byReporter.Value))
query = query.Where(r => r.ReporterId == byReporter.Value);
if (before != null)
query = query.Where(r => r.Id < before.Value).OrderByDescending(r => r.Id);
else if (after != null)
query = query.Where(r => r.Id > after.Value).OrderBy(r => r.Id);
else
query = query.OrderByDescending(r => r.Id);
if (!includeClosed)
query = query.Where(r => r.Status == ReportStatus.Open);
List<Report> reports = await query.Take(limit!.Value).ToListAsync();
return Ok(reports.Select(moderationRenderer.RenderReport));
}
[HttpGet("reports/{id}")]
[Authorize("user.moderation")]
[Limit(RequireModerator = true)]
public async Task<IActionResult> GetReportAsync(Snowflake id, CancellationToken ct = default)
{
Report? report = await db
.Reports.Include(r => r.Reporter)
.Include(r => r.TargetUser)
.Include(r => r.TargetMember)
.Include(r => r.AuditLogEntry)
.FirstOrDefaultAsync(r => r.Id == id, ct);
if (report == null)
throw new ApiError.NotFound("No report with that ID found.");
return Ok(
new ReportDetailResponse(
Report: moderationRenderer.RenderReport(report),
User: await userRenderer.RenderUserAsync(
report.TargetUser,
renderMembers: false,
ct: ct
),
Member: report.TargetMember != null
? memberRenderer.RenderMember(report.TargetMember)
: null,
AuditLogEntry: report.AuditLogEntry != null
? moderationRenderer.RenderAuditLogEntry(report.AuditLogEntry)
: null
)
);
}
[HttpPost("reports/{id}/ignore")]
[Authorize("user.moderation")]
[Limit(RequireModerator = true)]
public async Task<IActionResult> IgnoreReportAsync(
Snowflake id,
[FromBody] IgnoreReportRequest req
)
{
Report? report = await db.Reports.FindAsync(id);
if (report == null)
throw new ApiError.NotFound("No report with that ID found.");
if (report.Status != ReportStatus.Open)
throw new ApiError.BadRequest("That report has already been handled.");
AuditLogEntry entry = await moderationService.IgnoreReportAsync(
CurrentUser!,
report,
req.Reason
);
return Ok(moderationRenderer.RenderAuditLogEntry(entry));
}
}

View file

@ -0,0 +1,66 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace Foxnouns.Backend.Controllers;
[Route("/api/v2/notifications")]
public class NotificationsController(
DatabaseContext db,
ModerationRendererService moderationRenderer,
IClock clock
) : ApiControllerBase
{
[HttpGet]
[Authorize("user.moderation")]
[Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> GetNotificationsAsync([FromQuery] bool all = false)
{
IQueryable<Notification> query = db.Notifications.Where(n => n.TargetId == CurrentUser!.Id);
if (!all)
query = query.Where(n => n.AcknowledgedAt == null);
List<Notification> notifications = await query.OrderByDescending(n => n.Id).ToListAsync();
return Ok(notifications.Select(moderationRenderer.RenderNotification));
}
[HttpPut("{id}/ack")]
[Authorize("user.moderation")]
[Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> AcknowledgeNotificationAsync(Snowflake id)
{
Notification? notification = await db.Notifications.FirstOrDefaultAsync(n =>
n.TargetId == CurrentUser!.Id && n.Id == id
);
if (notification == null)
throw new ApiError.NotFound("Notification not found.");
if (notification.AcknowledgedAt != null)
return Ok(moderationRenderer.RenderNotification(notification));
notification.AcknowledgedAt = clock.GetCurrentInstant();
db.Update(notification);
await db.SaveChangesAsync();
return Ok(moderationRenderer.RenderNotification(notification));
}
}

View file

@ -0,0 +1,67 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Diagnostics.CodeAnalysis;
using Foxnouns.Backend.Database;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Controllers;
[Route("/sid")]
[SuppressMessage(
"Performance",
"CA1862:Use the \'StringComparison\' method overloads to perform case-insensitive string comparisons",
Justification = "Not usable with EFCore"
)]
[ApiExplorerSettings(IgnoreApi = true)]
public class SidController(Config config, DatabaseContext db) : ApiControllerBase
{
[HttpGet("{**id}")]
public async Task<IActionResult> ResolveSidAsync(string id, CancellationToken ct = default) =>
id.Length switch
{
5 => await ResolveUserSidAsync(id, ct),
6 => await ResolveMemberSidAsync(id, ct),
_ => Redirect(config.BaseUrl),
};
private async Task<IActionResult> ResolveUserSidAsync(string id, CancellationToken ct = default)
{
string? username = await db
.Users.Where(u => u.Sid == id.ToLowerInvariant() && !u.Deleted)
.Select(u => u.Username)
.FirstOrDefaultAsync(ct);
if (username == null)
return Redirect(config.BaseUrl);
return Redirect($"{config.BaseUrl}/@{username}");
}
private async Task<IActionResult> ResolveMemberSidAsync(
string id,
CancellationToken ct = default
)
{
var member = await db
.Members.Include(m => m.User)
.Where(m => m.Sid == id.ToLowerInvariant() && !m.User.Deleted)
.Select(m => new { m.Name, m.User.Username })
.FirstOrDefaultAsync(ct);
if (member == null)
return Redirect(config.BaseUrl);
return Redirect($"{config.BaseUrl}/@{member.Username}/{member.Name}");
}
}

View file

@ -1,34 +1,329 @@
using System.Diagnostics; // Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using EntityFramework.Exceptions.Common;
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto;
using Foxnouns.Backend.Jobs;
using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services; using Foxnouns.Backend.Services;
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;
using NodaTime;
namespace Foxnouns.Backend.Controllers; namespace Foxnouns.Backend.Controllers;
[Route("/api/v2/users")] [Route("/api/v2/users")]
public class UsersController(DatabaseContext db, UserRendererService userRendererService) : ApiControllerBase public class UsersController(
DatabaseContext db,
ILogger logger,
UserRendererService userRenderer,
ISnowflakeGenerator snowflakeGenerator,
IClock clock,
ValidationService validationService
) : ApiControllerBase
{ {
[HttpGet("{userRef}")] private readonly ILogger _logger = logger.ForContext<UsersController>();
public async Task<IActionResult> GetUser(string userRef)
{
var user = await db.ResolveUserAsync(userRef);
return Ok(await userRendererService.RenderUserAsync(user, selfUser: CurrentUser));
}
[HttpGet("@me")] [HttpGet("{userRef}")]
[Authorize("identify")] [ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> GetMe() [Limit(UsableByDeletedUsers = true)]
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
{ {
var user = await db.ResolveUserAsync(CurrentUser!.Id); User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
return Ok(await userRendererService.RenderUserAsync(user, selfUser: CurrentUser)); return Ok(
await userRenderer.RenderUserAsync(
user,
CurrentUser,
CurrentToken,
renderMembers: true,
renderAuthMethods: true,
renderSettings: true,
ct: ct
)
);
} }
[HttpPatch("@me")] [HttpPatch("@me")]
public Task<IActionResult> UpdateUser([FromBody] UpdateUserRequest req) [Authorize("user.update")]
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateUserAsync(
[FromBody] UpdateUserRequest req,
CancellationToken ct = default
)
{ {
throw new NotImplementedException(); await using IDbContextTransaction tx = await db.Database.BeginTransactionAsync(ct);
User user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct);
var errors = new List<(string, ValidationError?)>();
if (req.Username != null && req.Username != user.Username)
{
errors.Add(("username", validationService.ValidateUsername(req.Username)));
user.Username = req.Username;
}
if (req.HasProperty(nameof(req.DisplayName)))
{
errors.Add(("display_name", validationService.ValidateDisplayName(req.DisplayName)));
user.DisplayName = req.DisplayName;
}
if (req.HasProperty(nameof(req.Bio)))
{
errors.Add(("bio", validationService.ValidateBio(req.Bio)));
user.Bio = req.Bio;
}
if (req.HasProperty(nameof(req.Links)))
{
errors.AddRange(validationService.ValidateLinks(req.Links));
user.Links = req.Links ?? [];
}
if (req.Names != null)
{
errors.AddRange(
validationService.ValidateFieldEntries(
req.Names,
CurrentUser!.CustomPreferences,
"names"
)
);
user.Names = req.Names.ToList();
}
if (req.Pronouns != null)
{
errors.AddRange(
validationService.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences)
);
user.Pronouns = req.Pronouns.ToList();
}
if (req.Fields != null)
{
errors.AddRange(
validationService.ValidateFields(
req.Fields.ToList(),
CurrentUser!.CustomPreferences
)
);
user.Fields = req.Fields.ToList();
}
if (req.Flags != null)
{
ValidationError? flagError = await db.SetUserFlagsAsync(CurrentUser!.Id, req.Flags);
if (flagError != null)
errors.Add(("flags", flagError));
}
if (req.HasProperty(nameof(req.Avatar)))
errors.Add(("avatar", validationService.ValidateAvatar(req.Avatar)));
if (req.HasProperty(nameof(req.MemberTitle)))
{
if (string.IsNullOrEmpty(req.MemberTitle))
{
user.MemberTitle = null;
}
else
{
errors.Add(
("member_title", validationService.ValidateDisplayName(req.MemberTitle))
);
user.MemberTitle = req.MemberTitle;
}
}
if (req.HasProperty(nameof(req.MemberListHidden)))
user.ListHidden = req.MemberListHidden == true;
if (req.HasProperty(nameof(req.Timezone)))
{
if (string.IsNullOrEmpty(req.Timezone))
{
user.Timezone = null;
}
else
{
if (TimeZoneInfo.TryFindSystemTimeZoneById(req.Timezone, out _))
{
user.Timezone = req.Timezone;
}
else
{
errors.Add(
(
"timezone",
ValidationError.GenericValidationError("Invalid timezone", req.Timezone)
)
);
}
}
}
ValidationUtils.Validate(errors);
// This is fired off regardless of whether the transaction is committed
// (atomic operations are hard when combined with background jobs)
// so it's in a separate block to the validation above.
if (req.HasProperty(nameof(req.Avatar)))
{
UserAvatarUpdateJob.Enqueue(new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar));
}
user.LastActive = clock.GetCurrentInstant();
try
{
await db.SaveChangesAsync(ct);
}
catch (UniqueConstraintException)
{
_logger.Debug(
"Could not update user {Id} due to name conflict ({CurrentName} / {NewName})",
user.Id,
user.Username,
req.Username
);
throw new ApiError.BadRequest(
"That username is already taken.",
"username",
req.Username
);
}
await tx.CommitAsync(ct);
return Ok(
await userRenderer.RenderUserAsync(
user,
CurrentUser,
renderMembers: false,
renderAuthMethods: false,
ct: ct
)
);
} }
public record UpdateUserRequest(string? Username, string? DisplayName); [HttpPatch("@me/custom-preferences")]
} [Authorize("user.update")]
[ProducesResponseType<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateCustomPreferencesAsync(
[FromBody] List<CustomPreferenceUpdateRequest> req,
CancellationToken ct = default
)
{
ValidationUtils.Validate(ValidationUtils.ValidateCustomPreferences(req));
User user = await db.ResolveUserAsync(CurrentUser!.Id, ct);
var preferences = user
.CustomPreferences.Where(x => req.Any(r => r.Id == x.Key))
.ToDictionary();
foreach (CustomPreferenceUpdateRequest r in req)
{
if (r.Id != null && preferences.ContainsKey(r.Id.Value))
{
preferences[r.Id.Value] = new User.CustomPreference
{
Favourite = r.Favourite,
Icon = r.Icon,
Muted = r.Muted,
Size = r.Size,
Tooltip = r.Tooltip,
LegacyId = preferences[r.Id.Value].LegacyId,
};
}
else
{
preferences[snowflakeGenerator.GenerateSnowflake()] = new User.CustomPreference
{
Favourite = r.Favourite,
Icon = r.Icon,
Muted = r.Muted,
Size = r.Size,
Tooltip = r.Tooltip,
LegacyId = Guid.NewGuid(),
};
}
}
user.CustomPreferences = preferences;
user.LastActive = clock.GetCurrentInstant();
await db.SaveChangesAsync(ct);
return Ok(user.CustomPreferences);
}
[HttpPatch("@me/settings")]
[Authorize("user.read_hidden", "user.update")]
[ProducesResponseType<UserSettings>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateUserSettingsAsync(
[FromBody] UpdateUserSettingsRequest req,
CancellationToken ct = default
)
{
User user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct);
if (req.HasProperty(nameof(req.DarkMode)))
user.Settings.DarkMode = req.DarkMode;
if (req.HasProperty(nameof(req.LastReadNotice)))
user.Settings.LastReadNotice = req.LastReadNotice;
user.LastActive = clock.GetCurrentInstant();
db.Update(user);
await db.SaveChangesAsync(ct);
return Ok(user.Settings);
}
[HttpPost("@me/reroll-sid")]
[Authorize("user.update")]
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> RerollSidAsync()
{
Instant minTimeAgo = clock.GetCurrentInstant() - Duration.FromHours(1);
if (CurrentUser!.LastSidReroll > minTimeAgo)
throw new ApiError.BadRequest("Cannot reroll short ID yet");
// Using ExecuteUpdateAsync here as the new short ID is generated by the database
await db
.Users.Where(u => u.Id == CurrentUser.Id)
.ExecuteUpdateAsync(s =>
s.SetProperty(u => u.Sid, _ => db.FindFreeUserSid())
.SetProperty(u => u.LastSidReroll, clock.GetCurrentInstant())
.SetProperty(u => u.LastActive, clock.GetCurrentInstant())
);
// Get the user's new sid
string newSid = await db
.Users.Where(u => u.Id == CurrentUser.Id)
.Select(u => u.Sid)
.FirstAsync();
User user = await db.ResolveUserAsync(CurrentUser.Id);
return Ok(
await userRenderer.RenderUserAsync(
user,
CurrentUser,
CurrentToken,
false,
overrideSid: newSid
)
);
}
}

View file

@ -0,0 +1,120 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Dto.V1;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services.V1;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Controllers.V1;
[Route("/api/v1")]
public class V1ReadController(
UsersV1Service usersV1Service,
MembersV1Service membersV1Service,
DatabaseContext db
) : ApiControllerBase
{
[HttpGet("users/@me")]
[Authorize("identify")]
public async Task<IActionResult> GetMeAsync(CancellationToken ct = default)
{
User user = await usersV1Service.ResolveUserAsync("@me", CurrentToken, ct);
return Ok(await usersV1Service.RenderCurrentUserAsync(user, ct));
}
[HttpGet("users/{userRef}")]
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
{
User user = await usersV1Service.ResolveUserAsync(userRef, CurrentToken, ct);
return Ok(
await usersV1Service.RenderUserAsync(
user,
CurrentToken,
renderMembers: true,
renderFlags: true,
ct: ct
)
);
}
[HttpGet("members/{id}")]
public async Task<IActionResult> GetMemberAsync(string id, CancellationToken ct = default)
{
Member member = await membersV1Service.ResolveMemberAsync(id, ct);
return Ok(
await membersV1Service.RenderMemberAsync(
member,
CurrentToken,
renderFlags: true,
ct: ct
)
);
}
[HttpGet("users/{userRef}/members")]
public async Task<IActionResult> GetUserMembersAsync(
string userRef,
CancellationToken ct = default
)
{
User user = await usersV1Service.ResolveUserAsync(userRef, CurrentToken, ct);
List<Member> members = await db
.Members.Where(m => m.UserId == user.Id)
.OrderBy(m => m.Name)
.ToListAsync(ct);
List<MemberResponse> responses = [];
foreach (Member member in members)
{
responses.Add(
await membersV1Service.RenderMemberAsync(
member,
CurrentToken,
user,
renderFlags: true,
ct: ct
)
);
}
return Ok(responses);
}
[HttpGet("users/{userRef}/members/{memberRef}")]
public async Task<IActionResult> GetUserMemberAsync(
string userRef,
string memberRef,
CancellationToken ct = default
)
{
Member member = await membersV1Service.ResolveMemberAsync(
userRef,
memberRef,
CurrentToken,
ct
);
return Ok(
await membersV1Service.RenderMemberAsync(
member,
CurrentToken,
renderFlags: true,
ct: ct
)
);
}
}

View file

@ -1,6 +1,20 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
namespace Foxnouns.Backend.Database; namespace Foxnouns.Backend.Database;
public abstract class BaseModel public abstract class BaseModel
{ {
public required Snowflake Id { get; init; } = SnowflakeGenerator.Instance.GenerateSnowflake(); public required Snowflake Id { get; init; } = SnowflakeGenerator.Instance.GenerateSnowflake();
} }

View file

@ -1,3 +1,19 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Diagnostics.CodeAnalysis;
using EntityFramework.Exceptions.PostgreSQL;
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Extensions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -7,35 +23,57 @@ using Npgsql;
namespace Foxnouns.Backend.Database; namespace Foxnouns.Backend.Database;
public class DatabaseContext : DbContext public class DatabaseContext(DbContextOptions options) : DbContext(options)
{ {
private readonly NpgsqlDataSource _dataSource; private static string GenerateConnectionString(Config.DatabaseConfig config) =>
new NpgsqlConnectionStringBuilder(config.Url)
public DbSet<User> Users { get; set; }
public DbSet<Member> Members { get; set; }
public DbSet<AuthMethod> AuthMethods { get; set; }
public DbSet<FediverseApplication> FediverseApplications { get; set; }
public DbSet<Token> Tokens { get; set; }
public DbSet<Application> Applications { get; set; }
public DatabaseContext(Config config)
{
var connString = new NpgsqlConnectionStringBuilder(config.Database.Url)
{ {
Timeout = config.Database.Timeout ?? 5, Pooling = config.EnablePooling ?? true,
MaxPoolSize = config.Database.MaxPoolSize ?? 50, Timeout = config.Timeout ?? 5,
MaxPoolSize = config.MaxPoolSize ?? 50,
MinPoolSize = 0,
ConnectionPruningInterval = 10,
ConnectionIdleLifetime = 10,
}.ConnectionString; }.ConnectionString;
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connString); public static NpgsqlDataSource BuildDataSource(Config config)
{
var dataSourceBuilder = new NpgsqlDataSourceBuilder(
GenerateConnectionString(config.Database)
);
dataSourceBuilder.UseNodaTime(); dataSourceBuilder.UseNodaTime();
_dataSource = dataSourceBuilder.Build(); dataSourceBuilder.UseJsonNet();
return dataSourceBuilder.Build();
} }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) public static DbContextOptionsBuilder BuildOptions(
=> optionsBuilder DbContextOptionsBuilder options,
.ConfigureWarnings(c => c.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)) NpgsqlDataSource dataSource,
.UseNpgsql(_dataSource, o => o.UseNodaTime()) ILoggerFactory? loggerFactory
.UseSnakeCaseNamingConvention(); ) =>
options
.ConfigureWarnings(c => c.Ignore(CoreEventId.SaveChangesFailed))
.UseNpgsql(dataSource, o => o.UseNodaTime())
.UseLoggerFactory(loggerFactory)
.UseSnakeCaseNamingConvention()
.UseExceptionProcessor();
public DbSet<User> Users { get; init; } = null!;
public DbSet<Member> Members { get; init; } = null!;
public DbSet<AuthMethod> AuthMethods { get; init; } = null!;
public DbSet<FediverseApplication> FediverseApplications { get; init; } = null!;
public DbSet<Token> Tokens { get; init; } = null!;
public DbSet<Application> Applications { get; init; } = null!;
public DbSet<DataExport> DataExports { get; init; } = null!;
public DbSet<PrideFlag> PrideFlags { get; init; } = null!;
public DbSet<UserFlag> UserFlags { get; init; } = null!;
public DbSet<MemberFlag> MemberFlags { get; init; } = null!;
public DbSet<Report> Reports { get; init; } = null!;
public DbSet<AuditLogEntry> AuditLog { get; init; } = null!;
public DbSet<Notification> Notifications { get; init; } = null!;
public DbSet<Notice> Notices { get; init; } = null!;
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{ {
@ -46,31 +84,116 @@ public class DatabaseContext : DbContext
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
modelBuilder.Entity<User>().HasIndex(u => u.Username).IsUnique(); modelBuilder.Entity<User>().HasIndex(u => u.Username).IsUnique();
modelBuilder.Entity<User>().HasIndex(u => u.Sid).IsUnique();
modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique(); modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique();
modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique();
modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique();
modelBuilder.Entity<User>() // Two indexes on auth_methods, one for fediverse auth and one for all other types.
.OwnsOne(u => u.Fields, f => f.ToJson()) modelBuilder
.OwnsOne(u => u.Names, n => n.ToJson()) .Entity<AuthMethod>()
.OwnsOne(u => u.Pronouns, p => p.ToJson()); .HasIndex(m => new
{
m.AuthType,
m.RemoteId,
m.FediverseApplicationId,
})
.HasFilter("fediverse_application_id IS NOT NULL")
.IsUnique();
modelBuilder.Entity<Member>() modelBuilder
.OwnsOne(m => m.Fields, f => f.ToJson()) .Entity<AuthMethod>()
.OwnsOne(m => m.Names, n => n.ToJson()) .HasIndex(m => new { m.AuthType, m.RemoteId })
.OwnsOne(m => m.Pronouns, p => p.ToJson()); .HasFilter("fediverse_application_id IS NULL")
.IsUnique();
modelBuilder
.Entity<AuditLogEntry>()
.HasOne(e => e.Report)
.WithOne(e => e.AuditLogEntry)
.OnDelete(DeleteBehavior.SetNull);
modelBuilder.Entity<User>().Property(u => u.Sid).HasDefaultValueSql("find_free_user_sid()");
modelBuilder.Entity<User>().Property(u => u.Fields).HasColumnType("jsonb");
modelBuilder.Entity<User>().Property(u => u.Names).HasColumnType("jsonb");
modelBuilder.Entity<User>().Property(u => u.Pronouns).HasColumnType("jsonb");
modelBuilder.Entity<User>().Property(u => u.CustomPreferences).HasColumnType("jsonb");
modelBuilder.Entity<User>().Property(u => u.Settings).HasColumnType("jsonb");
modelBuilder
.Entity<Member>()
.Property(m => m.Sid)
.HasDefaultValueSql("find_free_member_sid()");
modelBuilder.Entity<Member>().Property(m => m.Fields).HasColumnType("jsonb");
modelBuilder.Entity<Member>().Property(m => m.Names).HasColumnType("jsonb");
modelBuilder.Entity<Member>().Property(m => m.Pronouns).HasColumnType("jsonb");
modelBuilder.Entity<UserFlag>().Navigation(f => f.PrideFlag).AutoInclude();
modelBuilder.Entity<MemberFlag>().Navigation(f => f.PrideFlag).AutoInclude();
modelBuilder
.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeUserSid))!)
.HasName("find_free_user_sid");
modelBuilder
.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeMemberSid))!)
.HasName("find_free_member_sid");
// Indexes for legacy IDs for APIv1
modelBuilder.Entity<User>().HasIndex(u => u.LegacyId).IsUnique();
modelBuilder.Entity<Member>().HasIndex(m => m.LegacyId).IsUnique();
modelBuilder.Entity<PrideFlag>().HasIndex(f => f.LegacyId).IsUnique();
// a UUID is not an xid, but this should always be set by the application anyway.
// we're just setting it here to shut EFCore up because squashing migrations is for nerds
modelBuilder
.Entity<User>()
.Property(u => u.LegacyId)
.HasDefaultValueSql("gen_random_uuid()");
modelBuilder
.Entity<Member>()
.Property(m => m.LegacyId)
.HasDefaultValueSql("gen_random_uuid()");
modelBuilder
.Entity<PrideFlag>()
.Property(f => f.LegacyId)
.HasDefaultValueSql("gen_random_uuid()");
} }
/// <summary>
/// Dummy method that calls <c>find_free_user_sid()</c> when used in an EF Core query.
/// </summary>
public string FindFreeUserSid() => throw new NotSupportedException();
/// <summary>
/// Dummy method that calls <c>find_free_member_sid()</c> when used in an EF Core query.
/// </summary>
public string FindFreeMemberSid() => throw new NotSupportedException();
} }
[SuppressMessage(
"ReSharper",
"UnusedType.Global",
Justification = "Used by EF Core's migration generator"
)]
public class DesignTimeDatabaseContextFactory : IDesignTimeDbContextFactory<DatabaseContext> public class DesignTimeDatabaseContextFactory : IDesignTimeDbContextFactory<DatabaseContext>
{ {
public DatabaseContext CreateDbContext(string[] args) public DatabaseContext CreateDbContext(string[] args)
{ {
// Read the configuration file // Read the configuration file
var config = new ConfigurationBuilder() Config config =
.AddConfiguration() new ConfigurationBuilder()
.Build() .AddConfiguration()
// Get the configuration as our config class .Build()
.Get<Config>() ?? new(); // Get the configuration as our config class
.Get<Config>() ?? new Config();
return new DatabaseContext(config); NpgsqlDataSource dataSource = DatabaseContext.BuildDataSource(config);
DbContextOptions options = DatabaseContext
.BuildOptions(new DbContextOptionsBuilder(), dataSource, null)
.Options;
return new DatabaseContext(options);
} }
} }

View file

@ -1,87 +1,205 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Security.Cryptography; using System.Security.Cryptography;
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace Foxnouns.Backend.Database; namespace Foxnouns.Backend.Database;
public static class DatabaseQueryExtensions public static class DatabaseQueryExtensions
{ {
public static async Task<User> ResolveUserAsync(this DatabaseContext context, string userRef) public static async Task<User> ResolveUserAsync(
this DatabaseContext context,
string userRef,
Token? token,
CancellationToken ct = default
)
{ {
User? user; if (userRef == "@me")
if (Snowflake.TryParse(userRef, out var snowflake))
{ {
user = await context.Users // Not filtering deleted users, as a suspended user should still be able to look at their own profile.
.FirstOrDefaultAsync(u => u.Id == snowflake); return token != null
if (user != null) return user; ? await context.Users.FirstAsync(u => u.Id == token.UserId, ct)
: throw new ApiError.Unauthorized(
"This endpoint requires an authenticated user.",
ErrorCode.AuthenticationRequired
);
} }
user = await context.Users User? user;
.FirstOrDefaultAsync(u => u.Username == userRef); if (Snowflake.TryParse(userRef, out Snowflake? snowflake))
if (user != null) return user; {
throw new ApiError.NotFound("No user with that ID or username found.", code: ErrorCode.UserNotFound); user = await context
.Users.Where(u => !u.Deleted || (token != null && token.UserId == u.Id))
.FirstOrDefaultAsync(u => u.Id == snowflake, ct);
if (user != null)
return user;
}
user = await context
.Users.Where(u => !u.Deleted || (token != null && token.UserId == u.Id))
.FirstOrDefaultAsync(u => u.Username == userRef, ct);
if (user != null)
return user;
throw new ApiError.NotFound(
"No user with that ID or username found.",
ErrorCode.UserNotFound
);
} }
public static async Task<User> ResolveUserAsync(this DatabaseContext context, Snowflake id) public static async Task<User> ResolveUserAsync(
this DatabaseContext context,
Snowflake id,
CancellationToken ct = default
)
{ {
var user = await context.Users User? user = await context
.FirstOrDefaultAsync(u => u.Id == id); .Users.Where(u => !u.Deleted)
if (user != null) return user; .FirstOrDefaultAsync(u => u.Id == id, ct);
throw new ApiError.NotFound("No user with that ID found.", code: ErrorCode.UserNotFound); if (user != null)
return user;
throw new ApiError.NotFound("No user with that ID found.", ErrorCode.UserNotFound);
} }
public static async Task<Member> ResolveMemberAsync(this DatabaseContext context, Snowflake id) public static async Task<Member> ResolveMemberAsync(
this DatabaseContext context,
Snowflake id,
CancellationToken ct = default
)
{ {
var member = await context.Members Member? member = await context
.Include(m => m.User) .Members.Include(m => m.User)
.FirstOrDefaultAsync(m => m.Id == id); .Where(m => !m.User.Deleted)
if (member != null) return member; .FirstOrDefaultAsync(m => m.Id == id, ct);
throw new ApiError.NotFound("No member with that ID found.", code: ErrorCode.MemberNotFound); if (member != null)
return member;
throw new ApiError.NotFound("No member with that ID found.", ErrorCode.MemberNotFound);
} }
public static async Task<Member> ResolveMemberAsync(this DatabaseContext context, string userRef, string memberRef) public static async Task<Member> ResolveMemberAsync(
this DatabaseContext context,
string userRef,
string memberRef,
Token? token,
CancellationToken ct = default
)
{ {
var user = await context.ResolveUserAsync(userRef); User user = await context.ResolveUserAsync(userRef, token, ct);
return await context.ResolveMemberAsync(user.Id, memberRef); return await context.ResolveMemberAsync(user.Id, memberRef, token, ct);
} }
public static async Task<Member> ResolveMemberAsync(this DatabaseContext context, Snowflake userId, public static async Task<Member> ResolveMemberAsync(
string memberRef) this DatabaseContext context,
Snowflake userId,
string memberRef,
Token? token = null,
CancellationToken ct = default
)
{ {
Member? member; Member? member;
if (Snowflake.TryParse(memberRef, out var snowflake)) if (Snowflake.TryParse(memberRef, out Snowflake? snowflake))
{ {
member = await context.Members member = await context
.Include(m => m.User) .Members.Include(m => m.User)
.FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId); .Include(m => m.ProfileFlags)
if (member != null) return member; // Return members if their user isn't deleted or the user querying it is the member's owner
.Where(m => !m.User.Deleted || (token != null && token.UserId == m.UserId))
.FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId, ct);
if (member != null)
return member;
} }
member = await context.Members member = await context
.Include(m => m.User) .Members.Include(m => m.User)
.FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId); .Include(m => m.ProfileFlags)
if (member != null) return member; // Return members if their user isn't deleted or the user querying it is the member's owner
throw new ApiError.NotFound("No member with that ID or name found.", code: ErrorCode.MemberNotFound); .Where(m => !m.User.Deleted || (token != null && token.UserId == m.UserId))
.FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId, ct);
if (member != null)
return member;
throw new ApiError.NotFound(
"No member with that ID or name found.",
ErrorCode.MemberNotFound
);
} }
public static async Task<Application> GetFrontendApplicationAsync(this DatabaseContext context) public static async Task<Application> GetFrontendApplicationAsync(
this DatabaseContext context,
CancellationToken ct = default
)
{ {
var app = await context.Applications.FirstOrDefaultAsync(a => a.Id == new Snowflake(0)); Application? app = await context.Applications.FirstOrDefaultAsync(
if (app != null) return app; a => a.Id == new Snowflake(0),
ct
);
if (app != null)
return app;
app = new Application app = new Application
{ {
Id = new Snowflake(0), Id = new Snowflake(0),
ClientId = RandomNumberGenerator.GetHexString(32, true), ClientId = RandomNumberGenerator.GetHexString(32, true),
ClientSecret = OauthUtils.RandomToken(48), ClientSecret = AuthUtils.RandomToken(),
Name = "pronouns.cc", Name = "pronouns.cc",
Scopes = ["*"], Scopes = ["*"],
RedirectUris = [], RedirectUris = [],
}; };
context.Add(app); context.Add(app);
await context.SaveChangesAsync(); await context.SaveChangesAsync(ct);
return app; return app;
} }
}
public static async Task<Token?> GetToken(
this DatabaseContext context,
byte[] rawToken,
CancellationToken ct = default
)
{
byte[] hash = SHA512.HashData(rawToken);
Token? oauthToken = await context
.Tokens.Include(t => t.Application)
.Include(t => t.User)
.FirstOrDefaultAsync(
t =>
t.Hash == hash
&& t.ExpiresAt > SystemClock.Instance.GetCurrentInstant()
&& !t.ManuallyExpired,
ct
);
return oauthToken;
}
public static async Task<Snowflake?> GetTokenUserId(
this DatabaseContext context,
byte[] rawToken,
CancellationToken ct = default
)
{
byte[] hash = SHA512.HashData(rawToken);
return await context
.Tokens.Where(t =>
t.Hash == hash
&& t.ExpiresAt > SystemClock.Instance.GetCurrentInstant()
&& !t.ManuallyExpired
)
.Select(t => t.UserId)
.FirstOrDefaultAsync(ct);
}
}

View file

@ -0,0 +1,36 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Npgsql;
using Serilog;
namespace Foxnouns.Backend.Database;
public static class DatabaseServiceExtensions
{
public static IServiceCollection AddFoxnounsDatabase(
this IServiceCollection serviceCollection,
Config config
)
{
NpgsqlDataSource dataSource = DatabaseContext.BuildDataSource(config);
ILoggerFactory loggerFactory = new LoggerFactory().AddSerilog(dispose: false);
serviceCollection.AddDbContext<DatabaseContext>(options =>
DatabaseContext.BuildOptions(options, dataSource, loggerFactory)
);
return serviceCollection;
}
}

View file

@ -0,0 +1,96 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database.Models;
using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Database;
public static class FlagQueryExtensions
{
private static async Task<List<PrideFlag>> GetFlagsAsync(
this DatabaseContext db,
Snowflake userId
) => await db.PrideFlags.Where(f => f.UserId == userId).OrderBy(f => f.Id).ToListAsync();
/// <summary>
/// Sets the user's profile flags to the given IDs. Returns a validation error if any of the flag IDs are unknown
/// or if too many IDs are given. Duplicates are allowed.
/// </summary>
public static async Task<ValidationError?> SetUserFlagsAsync(
this DatabaseContext db,
Snowflake userId,
Snowflake[] flagIds
)
{
List<UserFlag> currentFlags = await db
.UserFlags.Where(f => f.UserId == userId)
.ToListAsync();
foreach (UserFlag flag in currentFlags)
db.UserFlags.Remove(flag);
// If there's no new flags to set, we're done
if (flagIds.Length == 0)
return null;
if (flagIds.Length > 100)
return ValidationError.LengthError("Too many profile flags", 0, 100, flagIds.Length);
List<PrideFlag> flags = await db.GetFlagsAsync(userId);
Snowflake[] unknownFlagIds = flagIds.Where(id => flags.All(f => f.Id != id)).ToArray();
if (unknownFlagIds.Length != 0)
return ValidationError.GenericValidationError("Unknown flag IDs", unknownFlagIds);
IEnumerable<UserFlag> userFlags = flagIds.Select(id => new UserFlag
{
PrideFlagId = id,
UserId = userId,
});
db.UserFlags.AddRange(userFlags);
return null;
}
public static async Task<ValidationError?> SetMemberFlagsAsync(
this DatabaseContext db,
Snowflake userId,
Snowflake memberId,
Snowflake[] flagIds
)
{
List<MemberFlag> currentFlags = await db
.MemberFlags.Where(f => f.MemberId == memberId)
.ToListAsync();
foreach (MemberFlag flag in currentFlags)
db.MemberFlags.Remove(flag);
if (flagIds.Length == 0)
return null;
if (flagIds.Length > 100)
return ValidationError.LengthError("Too many profile flags", 0, 100, flagIds.Length);
List<PrideFlag> flags = await db.GetFlagsAsync(userId);
Snowflake[] unknownFlagIds = flagIds.Where(id => flags.All(f => f.Id != id)).ToArray();
if (unknownFlagIds.Length != 0)
return ValidationError.GenericValidationError("Unknown flag IDs", unknownFlagIds);
IEnumerable<MemberFlag> memberFlags = flagIds.Select(id => new MemberFlag
{
PrideFlagId = id,
MemberId = memberId,
});
db.MemberFlags.AddRange(memberFlags);
return null;
}
}

View file

@ -1,3 +1,17 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using NodaTime; using NodaTime;
namespace Foxnouns.Backend.Database; namespace Foxnouns.Backend.Database;
@ -5,4 +19,4 @@ namespace Foxnouns.Backend.Database;
public interface ISnowflakeGenerator public interface ISnowflakeGenerator
{ {
Snowflake GenerateSnowflake(Instant? time = null); Snowflake GenerateSnowflake(Instant? time = null);
} }

View file

@ -1,412 +0,0 @@
// <auto-generated />
using Foxnouns.Backend.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
[DbContext(typeof(DatabaseContext))]
[Migration("20240527132444_Init")]
partial class Init
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<int>("AuthType")
.HasColumnType("integer")
.HasColumnName("auth_type");
b.Property<long?>("FediverseApplicationId")
.HasColumnType("bigint")
.HasColumnName("fediverse_application_id");
b.Property<string>("RemoteId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("remote_id");
b.Property<string>("RemoteUsername")
.HasColumnType("text")
.HasColumnName("remote_username");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_auth_methods");
b.HasIndex("FediverseApplicationId")
.HasDatabaseName("ix_auth_methods_fediverse_application_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_auth_methods_user_id");
b.ToTable("auth_methods", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.FediverseApplication", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Domain")
.IsRequired()
.HasColumnType("text")
.HasColumnName("domain");
b.Property<int>("InstanceType")
.HasColumnType("integer")
.HasColumnName("instance_type");
b.HasKey("Id")
.HasName("pk_fediverse_applications");
b.ToTable("fediverse_applications", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<bool>("Unlisted")
.HasColumnType("boolean")
.HasColumnName("unlisted");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_members");
b.HasIndex("UserId", "Name")
.IsUnique()
.HasDatabaseName("ix_members_user_id_name");
b.ToTable("members", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<Instant>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<bool>("ManuallyExpired")
.HasColumnType("boolean")
.HasColumnName("manually_expired");
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_tokens");
b.HasIndex("UserId")
.HasDatabaseName("ix_tokens_user_id");
b.ToTable("tokens", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<string>("MemberTitle")
.HasColumnType("text")
.HasColumnName("member_title");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("Username")
.IsUnique()
.HasDatabaseName("ix_users_username");
b.ToTable("users", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication")
.WithMany()
.HasForeignKey("FediverseApplicationId")
.HasConstraintName("fk_auth_methods_fediverse_applications_fediverse_application_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany("AuthMethods")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_methods_users_user_id");
b.Navigation("FediverseApplication");
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany("Members")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_members_users_user_id");
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Field>", "Fields", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("fields");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.FieldEntry>", "Names", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Pronoun>", "Pronouns", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tokens_users_user_id");
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Fields#List", "Fields", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("fields");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Names#List", "Names", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Pronouns#List", "Pronouns", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Navigation("AuthMethods");
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime; using NodaTime;
#nullable disable #nullable disable
@ -6,6 +7,8 @@ using NodaTime;
namespace Foxnouns.Backend.Database.Migrations namespace Foxnouns.Backend.Database.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240527132444_Init")]
public partial class Init : Migration public partial class Init : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
@ -19,12 +22,10 @@ namespace Foxnouns.Backend.Database.Migrations
domain = table.Column<string>(type: "text", nullable: false), domain = table.Column<string>(type: "text", nullable: false),
client_id = table.Column<string>(type: "text", nullable: false), client_id = table.Column<string>(type: "text", nullable: false),
client_secret = table.Column<string>(type: "text", nullable: false), client_secret = table.Column<string>(type: "text", nullable: false),
instance_type = table.Column<int>(type: "integer", nullable: false) instance_type = table.Column<int>(type: "integer", nullable: false),
}, },
constraints: table => constraints: table => table.PrimaryKey("pk_fediverse_applications", x => x.id)
{ );
table.PrimaryKey("pk_fediverse_applications", x => x.id);
});
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "users", name: "users",
@ -40,12 +41,10 @@ namespace Foxnouns.Backend.Database.Migrations
role = table.Column<int>(type: "integer", nullable: false), role = table.Column<int>(type: "integer", nullable: false),
fields = table.Column<string>(type: "jsonb", nullable: false), fields = table.Column<string>(type: "jsonb", nullable: false),
names = table.Column<string>(type: "jsonb", nullable: false), names = table.Column<string>(type: "jsonb", nullable: false),
pronouns = table.Column<string>(type: "jsonb", nullable: false) pronouns = table.Column<string>(type: "jsonb", nullable: false),
}, },
constraints: table => constraints: table => table.PrimaryKey("pk_users", x => x.id)
{ );
table.PrimaryKey("pk_users", x => x.id);
});
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "auth_methods", name: "auth_methods",
@ -56,7 +55,7 @@ namespace Foxnouns.Backend.Database.Migrations
remote_id = table.Column<string>(type: "text", nullable: false), remote_id = table.Column<string>(type: "text", nullable: false),
remote_username = table.Column<string>(type: "text", nullable: true), remote_username = table.Column<string>(type: "text", nullable: true),
user_id = table.Column<long>(type: "bigint", nullable: false), user_id = table.Column<long>(type: "bigint", nullable: false),
fediverse_application_id = table.Column<long>(type: "bigint", nullable: true) fediverse_application_id = table.Column<long>(type: "bigint", nullable: true),
}, },
constraints: table => constraints: table =>
{ {
@ -65,14 +64,17 @@ namespace Foxnouns.Backend.Database.Migrations
name: "fk_auth_methods_fediverse_applications_fediverse_application_id", name: "fk_auth_methods_fediverse_applications_fediverse_application_id",
column: x => x.fediverse_application_id, column: x => x.fediverse_application_id,
principalTable: "fediverse_applications", principalTable: "fediverse_applications",
principalColumn: "id"); principalColumn: "id"
);
table.ForeignKey( table.ForeignKey(
name: "fk_auth_methods_users_user_id", name: "fk_auth_methods_users_user_id",
column: x => x.user_id, column: x => x.user_id,
principalTable: "users", principalTable: "users",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade
}); );
}
);
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "members", name: "members",
@ -88,7 +90,7 @@ namespace Foxnouns.Backend.Database.Migrations
user_id = table.Column<long>(type: "bigint", nullable: false), user_id = table.Column<long>(type: "bigint", nullable: false),
fields = table.Column<string>(type: "jsonb", nullable: false), fields = table.Column<string>(type: "jsonb", nullable: false),
names = table.Column<string>(type: "jsonb", nullable: false), names = table.Column<string>(type: "jsonb", nullable: false),
pronouns = table.Column<string>(type: "jsonb", nullable: false) pronouns = table.Column<string>(type: "jsonb", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
@ -98,18 +100,23 @@ namespace Foxnouns.Backend.Database.Migrations
column: x => x.user_id, column: x => x.user_id,
principalTable: "users", principalTable: "users",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade
}); );
}
);
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "tokens", name: "tokens",
columns: table => new columns: table => new
{ {
id = table.Column<long>(type: "bigint", nullable: false), id = table.Column<long>(type: "bigint", nullable: false),
expires_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false), expires_at = table.Column<Instant>(
type: "timestamp with time zone",
nullable: false
),
scopes = table.Column<string[]>(type: "text[]", nullable: false), scopes = table.Column<string[]>(type: "text[]", nullable: false),
manually_expired = table.Column<bool>(type: "boolean", nullable: false), manually_expired = table.Column<bool>(type: "boolean", nullable: false),
user_id = table.Column<long>(type: "bigint", nullable: false) user_id = table.Column<long>(type: "bigint", nullable: false),
}, },
constraints: table => constraints: table =>
{ {
@ -119,53 +126,56 @@ namespace Foxnouns.Backend.Database.Migrations
column: x => x.user_id, column: x => x.user_id,
principalTable: "users", principalTable: "users",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade
}); );
}
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_auth_methods_fediverse_application_id", name: "ix_auth_methods_fediverse_application_id",
table: "auth_methods", table: "auth_methods",
column: "fediverse_application_id"); column: "fediverse_application_id"
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_auth_methods_user_id", name: "ix_auth_methods_user_id",
table: "auth_methods", table: "auth_methods",
column: "user_id"); column: "user_id"
);
// EF Core doesn't support creating indexes on arbitrary expressions, so we have to create it manually. // EF Core doesn't support creating indexes on arbitrary expressions, so we have to create it manually.
// Due to historical reasons (I made a mistake while writing the initial migration for the Go version) // Due to historical reasons (I made a mistake while writing the initial migration for the Go version)
// only members have case-insensitive names. // only members have case-insensitive names.
migrationBuilder.Sql("CREATE UNIQUE INDEX ix_members_user_id_name ON members (user_id, lower(name))"); migrationBuilder.Sql(
"CREATE UNIQUE INDEX ix_members_user_id_name ON members (user_id, lower(name))"
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_tokens_user_id", name: "ix_tokens_user_id",
table: "tokens", table: "tokens",
column: "user_id"); column: "user_id"
);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_users_username", name: "ix_users_username",
table: "users", table: "users",
column: "username", column: "username",
unique: true); unique: true
);
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "auth_methods");
name: "auth_methods");
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "members");
name: "members");
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "tokens");
name: "tokens");
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "fediverse_applications");
name: "fediverse_applications");
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "users");
name: "users");
} }
} }
} }

View file

@ -1,470 +0,0 @@
// <auto-generated />
using Foxnouns.Backend.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
[DbContext(typeof(DatabaseContext))]
[Migration("20240528125310_AddApplications")]
partial class AddApplications
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string[]>("RedirectUris")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("redirect_uris");
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.HasKey("Id")
.HasName("pk_applications");
b.ToTable("applications", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<int>("AuthType")
.HasColumnType("integer")
.HasColumnName("auth_type");
b.Property<long?>("FediverseApplicationId")
.HasColumnType("bigint")
.HasColumnName("fediverse_application_id");
b.Property<string>("RemoteId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("remote_id");
b.Property<string>("RemoteUsername")
.HasColumnType("text")
.HasColumnName("remote_username");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_auth_methods");
b.HasIndex("FediverseApplicationId")
.HasDatabaseName("ix_auth_methods_fediverse_application_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_auth_methods_user_id");
b.ToTable("auth_methods", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.FediverseApplication", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Domain")
.IsRequired()
.HasColumnType("text")
.HasColumnName("domain");
b.Property<int>("InstanceType")
.HasColumnType("integer")
.HasColumnName("instance_type");
b.HasKey("Id")
.HasName("pk_fediverse_applications");
b.ToTable("fediverse_applications", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<bool>("Unlisted")
.HasColumnType("boolean")
.HasColumnName("unlisted");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_members");
b.HasIndex("UserId", "Name")
.IsUnique()
.HasDatabaseName("ix_members_user_id_name");
b.ToTable("members", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<long>("ApplicationId")
.HasColumnType("bigint")
.HasColumnName("application_id");
b.Property<Instant>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<byte[]>("Hash")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("hash");
b.Property<bool>("ManuallyExpired")
.HasColumnType("boolean")
.HasColumnName("manually_expired");
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_tokens");
b.HasIndex("ApplicationId")
.HasDatabaseName("ix_tokens_application_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_tokens_user_id");
b.ToTable("tokens", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<string>("MemberTitle")
.HasColumnType("text")
.HasColumnName("member_title");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("Username")
.IsUnique()
.HasDatabaseName("ix_users_username");
b.ToTable("users", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication")
.WithMany()
.HasForeignKey("FediverseApplicationId")
.HasConstraintName("fk_auth_methods_fediverse_applications_fediverse_application_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany("AuthMethods")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_methods_users_user_id");
b.Navigation("FediverseApplication");
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany("Members")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_members_users_user_id");
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Field>", "Fields", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("fields");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.FieldEntry>", "Names", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Pronoun>", "Pronouns", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application")
.WithMany()
.HasForeignKey("ApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tokens_applications_application_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tokens_users_user_id");
b.Navigation("Application");
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Fields#List", "Fields", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("fields");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Names#List", "Names", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Pronouns#List", "Pronouns", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Navigation("AuthMethods");
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -1,10 +1,13 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace Foxnouns.Backend.Database.Migrations namespace Foxnouns.Backend.Database.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240528125310_AddApplications")]
public partial class AddApplications : Migration public partial class AddApplications : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
@ -15,14 +18,16 @@ namespace Foxnouns.Backend.Database.Migrations
table: "tokens", table: "tokens",
type: "bigint", type: "bigint",
nullable: false, nullable: false,
defaultValue: 0L); defaultValue: 0L
);
migrationBuilder.AddColumn<byte[]>( migrationBuilder.AddColumn<byte[]>(
name: "hash", name: "hash",
table: "tokens", table: "tokens",
type: "bytea", type: "bytea",
nullable: false, nullable: false,
defaultValue: new byte[0]); defaultValue: Array.Empty<byte>()
);
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "applications", name: "applications",
@ -33,17 +38,16 @@ namespace Foxnouns.Backend.Database.Migrations
client_secret = table.Column<string>(type: "text", nullable: false), client_secret = table.Column<string>(type: "text", nullable: false),
name = table.Column<string>(type: "text", nullable: false), name = table.Column<string>(type: "text", nullable: false),
scopes = table.Column<string[]>(type: "text[]", nullable: false), scopes = table.Column<string[]>(type: "text[]", nullable: false),
redirect_uris = table.Column<string[]>(type: "text[]", nullable: false) redirect_uris = table.Column<string[]>(type: "text[]", nullable: false),
}, },
constraints: table => constraints: table => table.PrimaryKey("pk_applications", x => x.id)
{ );
table.PrimaryKey("pk_applications", x => x.id);
});
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "ix_tokens_application_id", name: "ix_tokens_application_id",
table: "tokens", table: "tokens",
column: "application_id"); column: "application_id"
);
migrationBuilder.AddForeignKey( migrationBuilder.AddForeignKey(
name: "fk_tokens_applications_application_id", name: "fk_tokens_applications_application_id",
@ -51,7 +55,8 @@ namespace Foxnouns.Backend.Database.Migrations
column: "application_id", column: "application_id",
principalTable: "applications", principalTable: "applications",
principalColumn: "id", principalColumn: "id",
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade
);
} }
/// <inheritdoc /> /// <inheritdoc />
@ -59,22 +64,16 @@ namespace Foxnouns.Backend.Database.Migrations
{ {
migrationBuilder.DropForeignKey( migrationBuilder.DropForeignKey(
name: "fk_tokens_applications_application_id", name: "fk_tokens_applications_application_id",
table: "tokens"); table: "tokens"
);
migrationBuilder.DropTable( migrationBuilder.DropTable(name: "applications");
name: "applications");
migrationBuilder.DropIndex( migrationBuilder.DropIndex(name: "ix_tokens_application_id", table: "tokens");
name: "ix_tokens_application_id",
table: "tokens");
migrationBuilder.DropColumn( migrationBuilder.DropColumn(name: "application_id", table: "tokens");
name: "application_id",
table: "tokens");
migrationBuilder.DropColumn( migrationBuilder.DropColumn(name: "hash", table: "tokens");
name: "hash",
table: "tokens");
} }
} }
} }

View file

@ -1,474 +0,0 @@
// <auto-generated />
using Foxnouns.Backend.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
[DbContext(typeof(DatabaseContext))]
[Migration("20240528145744_AddListHidden")]
partial class AddListHidden
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string[]>("RedirectUris")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("redirect_uris");
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.HasKey("Id")
.HasName("pk_applications");
b.ToTable("applications", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<int>("AuthType")
.HasColumnType("integer")
.HasColumnName("auth_type");
b.Property<long?>("FediverseApplicationId")
.HasColumnType("bigint")
.HasColumnName("fediverse_application_id");
b.Property<string>("RemoteId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("remote_id");
b.Property<string>("RemoteUsername")
.HasColumnType("text")
.HasColumnName("remote_username");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_auth_methods");
b.HasIndex("FediverseApplicationId")
.HasDatabaseName("ix_auth_methods_fediverse_application_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_auth_methods_user_id");
b.ToTable("auth_methods", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.FediverseApplication", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Domain")
.IsRequired()
.HasColumnType("text")
.HasColumnName("domain");
b.Property<int>("InstanceType")
.HasColumnType("integer")
.HasColumnName("instance_type");
b.HasKey("Id")
.HasName("pk_fediverse_applications");
b.ToTable("fediverse_applications", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<bool>("Unlisted")
.HasColumnType("boolean")
.HasColumnName("unlisted");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_members");
b.HasIndex("UserId", "Name")
.IsUnique()
.HasDatabaseName("ix_members_user_id_name");
b.ToTable("members", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<long>("ApplicationId")
.HasColumnType("bigint")
.HasColumnName("application_id");
b.Property<Instant>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<byte[]>("Hash")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("hash");
b.Property<bool>("ManuallyExpired")
.HasColumnType("boolean")
.HasColumnName("manually_expired");
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_tokens");
b.HasIndex("ApplicationId")
.HasDatabaseName("ix_tokens_application_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_tokens_user_id");
b.ToTable("tokens", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<bool>("ListHidden")
.HasColumnType("boolean")
.HasColumnName("list_hidden");
b.Property<string>("MemberTitle")
.HasColumnType("text")
.HasColumnName("member_title");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("Username")
.IsUnique()
.HasDatabaseName("ix_users_username");
b.ToTable("users", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication")
.WithMany()
.HasForeignKey("FediverseApplicationId")
.HasConstraintName("fk_auth_methods_fediverse_applications_fediverse_application_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany("AuthMethods")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_methods_users_user_id");
b.Navigation("FediverseApplication");
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany("Members")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_members_users_user_id");
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Field>", "Fields", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("fields");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.FieldEntry>", "Names", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Pronoun>", "Pronouns", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application")
.WithMany()
.HasForeignKey("ApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tokens_applications_application_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tokens_users_user_id");
b.Navigation("Application");
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Fields#List", "Fields", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("fields");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Names#List", "Names", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Pronouns#List", "Pronouns", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Navigation("AuthMethods");
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -1,10 +1,13 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace Foxnouns.Backend.Database.Migrations namespace Foxnouns.Backend.Database.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240528145744_AddListHidden")]
public partial class AddListHidden : Migration public partial class AddListHidden : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
@ -15,15 +18,14 @@ namespace Foxnouns.Backend.Database.Migrations
table: "users", table: "users",
type: "boolean", type: "boolean",
nullable: false, nullable: false,
defaultValue: false); defaultValue: false
);
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(name: "list_hidden", table: "users");
name: "list_hidden",
table: "users");
} }
} }
} }

View file

@ -1,478 +0,0 @@
// <auto-generated />
using Foxnouns.Backend.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
[DbContext(typeof(DatabaseContext))]
[Migration("20240604142522_AddPassword")]
partial class AddPassword
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.5")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string[]>("RedirectUris")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("redirect_uris");
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.HasKey("Id")
.HasName("pk_applications");
b.ToTable("applications", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<int>("AuthType")
.HasColumnType("integer")
.HasColumnName("auth_type");
b.Property<long?>("FediverseApplicationId")
.HasColumnType("bigint")
.HasColumnName("fediverse_application_id");
b.Property<string>("RemoteId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("remote_id");
b.Property<string>("RemoteUsername")
.HasColumnType("text")
.HasColumnName("remote_username");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_auth_methods");
b.HasIndex("FediverseApplicationId")
.HasDatabaseName("ix_auth_methods_fediverse_application_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_auth_methods_user_id");
b.ToTable("auth_methods", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.FediverseApplication", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Domain")
.IsRequired()
.HasColumnType("text")
.HasColumnName("domain");
b.Property<int>("InstanceType")
.HasColumnType("integer")
.HasColumnName("instance_type");
b.HasKey("Id")
.HasName("pk_fediverse_applications");
b.ToTable("fediverse_applications", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<bool>("Unlisted")
.HasColumnType("boolean")
.HasColumnName("unlisted");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_members");
b.HasIndex("UserId", "Name")
.IsUnique()
.HasDatabaseName("ix_members_user_id_name");
b.ToTable("members", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<long>("ApplicationId")
.HasColumnType("bigint")
.HasColumnName("application_id");
b.Property<Instant>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<byte[]>("Hash")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("hash");
b.Property<bool>("ManuallyExpired")
.HasColumnType("boolean")
.HasColumnName("manually_expired");
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_tokens");
b.HasIndex("ApplicationId")
.HasDatabaseName("ix_tokens_application_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_tokens_user_id");
b.ToTable("tokens", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<bool>("ListHidden")
.HasColumnType("boolean")
.HasColumnName("list_hidden");
b.Property<string>("MemberTitle")
.HasColumnType("text")
.HasColumnName("member_title");
b.Property<string>("Password")
.HasColumnType("text")
.HasColumnName("password");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("Username")
.IsUnique()
.HasDatabaseName("ix_users_username");
b.ToTable("users", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication")
.WithMany()
.HasForeignKey("FediverseApplicationId")
.HasConstraintName("fk_auth_methods_fediverse_applications_fediverse_application_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany("AuthMethods")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_methods_users_user_id");
b.Navigation("FediverseApplication");
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany("Members")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_members_users_user_id");
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Field>", "Fields", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("fields");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.FieldEntry>", "Names", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Pronoun>", "Pronouns", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application")
.WithMany()
.HasForeignKey("ApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tokens_applications_application_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tokens_users_user_id");
b.Navigation("Application");
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Fields#List", "Fields", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("fields");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Names#List", "Names", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Pronouns#List", "Pronouns", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Navigation("AuthMethods");
b.Navigation("Members");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -1,10 +1,13 @@
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
namespace Foxnouns.Backend.Database.Migrations namespace Foxnouns.Backend.Database.Migrations
{ {
/// <inheritdoc /> /// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240604142522_AddPassword")]
public partial class AddPassword : Migration public partial class AddPassword : Migration
{ {
/// <inheritdoc /> /// <inheritdoc />
@ -14,15 +17,14 @@ namespace Foxnouns.Backend.Database.Migrations
name: "password", name: "password",
table: "users", table: "users",
type: "text", type: "text",
nullable: true); nullable: true
);
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropColumn( migrationBuilder.DropColumn(name: "password", table: "users");
name: "password",
table: "users");
} }
} }
} }

View file

@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240611225328_AddTemporaryKeyCache")]
public partial class AddTemporaryKeyCache : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "temporary_keys",
columns: table => new
{
id = table
.Column<long>(type: "bigint", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
key = table.Column<string>(type: "text", nullable: false),
value = table.Column<string>(type: "text", nullable: false),
expires = table.Column<Instant>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table => table.PrimaryKey("pk_temporary_keys", x => x.id)
);
migrationBuilder.CreateIndex(
name: "ix_temporary_keys_key",
table: "temporary_keys",
column: "key",
unique: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "temporary_keys");
}
}
}

View file

@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240712233806_AddUserLastActive")]
public partial class AddUserLastActive : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Instant>(
name: "last_active",
table: "users",
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "now()"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "last_active", table: "users");
}
}
}

View file

@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240713000719_AddDeleted")]
public partial class AddDeleted : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "deleted",
table: "users",
type: "boolean",
nullable: false,
defaultValue: false
);
migrationBuilder.AddColumn<Instant>(
name: "deleted_at",
table: "users",
type: "timestamp with time zone",
nullable: true
);
migrationBuilder.AddColumn<long>(
name: "deleted_by",
table: "users",
type: "bigint",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "deleted", table: "users");
migrationBuilder.DropColumn(name: "deleted_at", table: "users");
migrationBuilder.DropColumn(name: "deleted_by", table: "users");
}
}
}

View file

@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using Foxnouns.Backend.Database.Models;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240821210355_AddCustomPreferences")]
public partial class AddCustomPreferences : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Dictionary<Guid, User.CustomPreference>>(
name: "custom_preferences",
table: "users",
type: "jsonb",
nullable: false,
defaultValueSql: "'{}'"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "custom_preferences", table: "users");
}
}
}

View file

@ -0,0 +1,32 @@
using Foxnouns.Backend.Database.Models;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240905191709_AddUserSettings")]
public partial class AddUserSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<UserSettings>(
name: "settings",
table: "users",
type: "jsonb",
nullable: false,
defaultValueSql: "'{}'"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "settings", table: "users");
}
}
}

View file

@ -0,0 +1,102 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240926124950_AddSids")]
public partial class AddSids : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "sid",
table: "users",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<Instant>(
name: "last_sid_reroll",
table: "users",
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "now() - '1 hour'::interval"
);
migrationBuilder.AddColumn<string>(
name: "sid",
table: "members",
type: "text",
nullable: true
);
migrationBuilder.CreateIndex(
name: "ix_users_sid",
table: "users",
column: "sid",
unique: true
);
migrationBuilder.CreateIndex(
name: "ix_members_sid",
table: "members",
column: "sid",
unique: true
);
migrationBuilder.Sql(
@"create function generate_sid(len int) returns text as $$
select string_agg(substr('abcdefghijklmnopqrstuvwxyz', ceil(random() * 26)::integer, 1), '') from generate_series(1, len)
$$ language sql volatile;
"
);
migrationBuilder.Sql(
@"create function find_free_user_sid() returns text as $$
declare new_sid text;
begin
loop
new_sid := generate_sid(5);
if not exists (select 1 from users where sid = new_sid) then return new_sid; end if;
end loop;
end
$$ language plpgsql volatile;
"
);
migrationBuilder.Sql(
@"create function find_free_member_sid() returns text as $$
declare new_sid text;
begin
loop
new_sid := generate_sid(6);
if not exists (select 1 from members where sid = new_sid) then return new_sid; end if;
end loop;
end
$$ language plpgsql volatile;"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("drop function find_free_member_sid;");
migrationBuilder.Sql("drop function find_free_user_sid;");
migrationBuilder.Sql("drop function generate_sid;");
migrationBuilder.DropIndex(name: "ix_users_sid", table: "users");
migrationBuilder.DropIndex(name: "ix_members_sid", table: "members");
migrationBuilder.DropColumn(name: "sid", table: "users");
migrationBuilder.DropColumn(name: "last_sid_reroll", table: "users");
migrationBuilder.DropColumn(name: "sid", table: "members");
}
}
}

View file

@ -0,0 +1,64 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240926130208_NonNullableSids")]
public partial class NonNullableSids : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "sid",
table: "users",
type: "text",
nullable: false,
defaultValueSql: "find_free_user_sid()",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true
);
migrationBuilder.AlterColumn<string>(
name: "sid",
table: "members",
type: "text",
nullable: false,
defaultValueSql: "find_free_member_sid()",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "sid",
table: "users",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldDefaultValueSql: "find_free_user_sid()"
);
migrationBuilder.AlterColumn<string>(
name: "sid",
table: "members",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldDefaultValueSql: "find_free_member_sid()"
);
}
}
}

View file

@ -0,0 +1,147 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20240926180037_AddFlags")]
public partial class AddFlags : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "pride_flags",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false),
user_id = table.Column<long>(type: "bigint", nullable: false),
hash = table.Column<string>(type: "text", nullable: false),
name = table.Column<string>(type: "text", nullable: false),
description = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("pk_pride_flags", x => x.id);
table.ForeignKey(
name: "fk_pride_flags_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "member_flags",
columns: table => new
{
id = table
.Column<long>(type: "bigint", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
member_id = table.Column<long>(type: "bigint", nullable: false),
pride_flag_id = table.Column<long>(type: "bigint", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("pk_member_flags", x => x.id);
table.ForeignKey(
name: "fk_member_flags_members_member_id",
column: x => x.member_id,
principalTable: "members",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
table.ForeignKey(
name: "fk_member_flags_pride_flags_pride_flag_id",
column: x => x.pride_flag_id,
principalTable: "pride_flags",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "user_flags",
columns: table => new
{
id = table
.Column<long>(type: "bigint", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
user_id = table.Column<long>(type: "bigint", nullable: false),
pride_flag_id = table.Column<long>(type: "bigint", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("pk_user_flags", x => x.id);
table.ForeignKey(
name: "fk_user_flags_pride_flags_pride_flag_id",
column: x => x.pride_flag_id,
principalTable: "pride_flags",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
table.ForeignKey(
name: "fk_user_flags_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "ix_member_flags_member_id",
table: "member_flags",
column: "member_id"
);
migrationBuilder.CreateIndex(
name: "ix_member_flags_pride_flag_id",
table: "member_flags",
column: "pride_flag_id"
);
migrationBuilder.CreateIndex(
name: "ix_pride_flags_user_id",
table: "pride_flags",
column: "user_id"
);
migrationBuilder.CreateIndex(
name: "ix_user_flags_pride_flag_id",
table: "user_flags",
column: "pride_flag_id"
);
migrationBuilder.CreateIndex(
name: "ix_user_flags_user_id",
table: "user_flags",
column: "user_id"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "member_flags");
migrationBuilder.DropTable(name: "user_flags");
migrationBuilder.DropTable(name: "pride_flags");
}
}
}

View file

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241006125003_AddFediverseAccessTokens")]
public partial class AddFediverseAccessTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "access_token",
table: "fediverse_applications",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<Instant>(
name: "token_valid_until",
table: "fediverse_applications",
type: "timestamp with time zone",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "access_token", table: "fediverse_applications");
migrationBuilder.DropColumn(name: "token_valid_until", table: "fediverse_applications");
}
}
}

View file

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241123210306_RemoveFediverseApplicationTokens")]
public partial class RemoveFediverseApplicationTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "access_token", table: "fediverse_applications");
migrationBuilder.DropColumn(name: "token_valid_until", table: "fediverse_applications");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "access_token",
table: "fediverse_applications",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<Instant>(
name: "token_valid_until",
table: "fediverse_applications",
type: "timestamp with time zone",
nullable: true
);
}
}
}

View file

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241124201309_AddUserTimezone")]
public partial class AddUserTimezone : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "timezone",
table: "users",
type: "text",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "timezone", table: "users");
}
}
}

View file

@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241128202508_AddAuthMethodUniqueIndex")]
public partial class AddAuthMethodUniqueIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "ix_auth_methods_auth_type_remote_id",
table: "auth_methods",
columns: new[] { "auth_type", "remote_id" },
unique: true,
filter: "fediverse_application_id IS NULL"
);
migrationBuilder.CreateIndex(
name: "ix_auth_methods_auth_type_remote_id_fediverse_application_id",
table: "auth_methods",
columns: new[] { "auth_type", "remote_id", "fediverse_application_id" },
unique: true,
filter: "fediverse_application_id IS NOT NULL"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_auth_methods_auth_type_remote_id",
table: "auth_methods"
);
migrationBuilder.DropIndex(
name: "ix_auth_methods_auth_type_remote_id_fediverse_application_id",
table: "auth_methods"
);
}
}
}

View file

@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241202153736_AddDataExports")]
public partial class AddDataExports : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "data_exports",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false),
user_id = table.Column<long>(type: "bigint", nullable: false),
filename = table.Column<string>(type: "text", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("pk_data_exports", x => x.id);
table.ForeignKey(
name: "fk_data_exports_users_user_id",
column: x => x.user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "ix_data_exports_filename",
table: "data_exports",
column: "filename",
unique: true
);
migrationBuilder.CreateIndex(
name: "ix_data_exports_user_id",
table: "data_exports",
column: "user_id"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "data_exports");
}
}
}

View file

@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241209134148_NullableFlagHash")]
public partial class NullableFlagHash : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "hash",
table: "pride_flags",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "hash",
table: "pride_flags",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true
);
}
}
}

View file

@ -0,0 +1,53 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241211193653_AddSentEmailCache")]
public partial class AddSentEmailCache : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "sent_emails",
columns: table => new
{
id = table
.Column<int>(type: "integer", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
email = table.Column<string>(type: "text", nullable: false),
sent_at = table.Column<Instant>(
type: "timestamp with time zone",
nullable: false
),
},
constraints: table =>
{
table.PrimaryKey("pk_sent_emails", x => x.id);
}
);
migrationBuilder.CreateIndex(
name: "ix_sent_emails_email_sent_at",
table: "sent_emails",
columns: new[] { "email", "sent_at" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "sent_emails");
}
}
}

View file

@ -0,0 +1,161 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241217010207_AddReports")]
public partial class AddReports : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterDatabase().Annotation("Npgsql:PostgresExtension:hstore", ",,");
migrationBuilder.CreateTable(
name: "notifications",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false),
target_id = table.Column<long>(type: "bigint", nullable: false),
type = table.Column<int>(type: "integer", nullable: false),
message = table.Column<string>(type: "text", nullable: true),
localization_key = table.Column<string>(type: "text", nullable: true),
localization_params = table.Column<Dictionary<string, string>>(
type: "hstore",
nullable: false
),
acknowledged_at = table.Column<Instant>(
type: "timestamp with time zone",
nullable: true
),
},
constraints: table =>
{
table.PrimaryKey("pk_notifications", x => x.id);
table.ForeignKey(
name: "fk_notifications_users_target_id",
column: x => x.target_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "reports",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false),
reporter_id = table.Column<long>(type: "bigint", nullable: false),
target_user_id = table.Column<long>(type: "bigint", nullable: false),
target_member_id = table.Column<long>(type: "bigint", nullable: true),
status = table.Column<int>(type: "integer", nullable: false),
reason = table.Column<int>(type: "integer", nullable: false),
target_type = table.Column<int>(type: "integer", nullable: false),
target_snapshot = table.Column<string>(type: "text", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("pk_reports", x => x.id);
table.ForeignKey(
name: "fk_reports_members_target_member_id",
column: x => x.target_member_id,
principalTable: "members",
principalColumn: "id"
);
table.ForeignKey(
name: "fk_reports_users_reporter_id",
column: x => x.reporter_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
table.ForeignKey(
name: "fk_reports_users_target_user_id",
column: x => x.target_user_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateTable(
name: "audit_log",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false),
moderator_id = table.Column<long>(type: "bigint", nullable: false),
moderator_username = table.Column<string>(type: "text", nullable: false),
target_user_id = table.Column<long>(type: "bigint", nullable: true),
target_username = table.Column<string>(type: "text", nullable: true),
target_member_id = table.Column<long>(type: "bigint", nullable: true),
target_member_name = table.Column<string>(type: "text", nullable: true),
report_id = table.Column<long>(type: "bigint", nullable: true),
type = table.Column<int>(type: "integer", nullable: false),
reason = table.Column<string>(type: "text", nullable: true),
cleared_fields = table.Column<string[]>(type: "text[]", nullable: true),
},
constraints: table =>
{
table.PrimaryKey("pk_audit_log", x => x.id);
table.ForeignKey(
name: "fk_audit_log_reports_report_id",
column: x => x.report_id,
principalTable: "reports",
principalColumn: "id"
);
}
);
migrationBuilder.CreateIndex(
name: "ix_audit_log_report_id",
table: "audit_log",
column: "report_id"
);
migrationBuilder.CreateIndex(
name: "ix_notifications_target_id",
table: "notifications",
column: "target_id"
);
migrationBuilder.CreateIndex(
name: "ix_reports_reporter_id",
table: "reports",
column: "reporter_id"
);
migrationBuilder.CreateIndex(
name: "ix_reports_target_member_id",
table: "reports",
column: "target_member_id"
);
migrationBuilder.CreateIndex(
name: "ix_reports_target_user_id",
table: "reports",
column: "target_user_id"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "audit_log");
migrationBuilder.DropTable(name: "notifications");
migrationBuilder.DropTable(name: "reports");
migrationBuilder.AlterDatabase().OldAnnotation("Npgsql:PostgresExtension:hstore", ",,");
}
}
}

View file

@ -0,0 +1,51 @@
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241217195351_AddFediAppForceRefresh")]
public partial class AddFediAppForceRefresh : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<Dictionary<string, string>>(
name: "localization_params",
table: "notifications",
type: "hstore",
nullable: false,
oldClrType: typeof(Dictionary<string, string>),
oldType: "hstore",
oldNullable: true
);
migrationBuilder.AddColumn<bool>(
name: "force_refresh",
table: "fediverse_applications",
type: "boolean",
nullable: false,
defaultValue: false
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "force_refresh", table: "fediverse_applications");
migrationBuilder.AlterColumn<Dictionary<string, string>>(
name: "localization_params",
table: "notifications",
type: "hstore",
nullable: true,
oldClrType: typeof(Dictionary<string, string>),
oldType: "hstore"
);
}
}
}

View file

@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241218195457_AddContextToReports")]
public partial class AddContextToReports : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "context",
table: "reports",
type: "text",
nullable: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "context", table: "reports");
}
}
}

View file

@ -0,0 +1,65 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241218201855_MakeAuditLogReportsNullable")]
public partial class MakeAuditLogReportsNullable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_audit_log_reports_report_id",
table: "audit_log"
);
migrationBuilder.DropIndex(name: "ix_audit_log_report_id", table: "audit_log");
migrationBuilder.CreateIndex(
name: "ix_audit_log_report_id",
table: "audit_log",
column: "report_id",
unique: true
);
migrationBuilder.AddForeignKey(
name: "fk_audit_log_reports_report_id",
table: "audit_log",
column: "report_id",
principalTable: "reports",
principalColumn: "id",
onDelete: ReferentialAction.SetNull
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "fk_audit_log_reports_report_id",
table: "audit_log"
);
migrationBuilder.DropIndex(name: "ix_audit_log_report_id", table: "audit_log");
migrationBuilder.CreateIndex(
name: "ix_audit_log_report_id",
table: "audit_log",
column: "report_id"
);
migrationBuilder.AddForeignKey(
name: "fk_audit_log_reports_report_id",
table: "audit_log",
column: "report_id",
principalTable: "reports",
principalColumn: "id"
);
}
}
}

View file

@ -0,0 +1,78 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241225155818_AddLegacyIds")]
public partial class AddLegacyIds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "legacy_id",
table: "users",
type: "text",
nullable: false,
defaultValueSql: "gen_random_uuid()"
);
migrationBuilder.AddColumn<string>(
name: "legacy_id",
table: "pride_flags",
type: "text",
nullable: false,
defaultValueSql: "gen_random_uuid()"
);
migrationBuilder.AddColumn<string>(
name: "legacy_id",
table: "members",
type: "text",
nullable: false,
defaultValueSql: "gen_random_uuid()"
);
migrationBuilder.CreateIndex(
name: "ix_users_legacy_id",
table: "users",
column: "legacy_id",
unique: true
);
migrationBuilder.CreateIndex(
name: "ix_pride_flags_legacy_id",
table: "pride_flags",
column: "legacy_id",
unique: true
);
migrationBuilder.CreateIndex(
name: "ix_members_legacy_id",
table: "members",
column: "legacy_id",
unique: true
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(name: "ix_users_legacy_id", table: "users");
migrationBuilder.DropIndex(name: "ix_pride_flags_legacy_id", table: "pride_flags");
migrationBuilder.DropIndex(name: "ix_members_legacy_id", table: "members");
migrationBuilder.DropColumn(name: "legacy_id", table: "users");
migrationBuilder.DropColumn(name: "legacy_id", table: "pride_flags");
migrationBuilder.DropColumn(name: "legacy_id", table: "members");
}
}
}

View file

@ -0,0 +1,55 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20250304155708_RemoveTemporaryKeys")]
public partial class RemoveTemporaryKeys : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "temporary_keys");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "temporary_keys",
columns: table => new
{
id = table
.Column<long>(type: "bigint", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
expires = table.Column<Instant>(
type: "timestamp with time zone",
nullable: false
),
key = table.Column<string>(type: "text", nullable: false),
value = table.Column<string>(type: "text", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("pk_temporary_keys", x => x.id);
}
);
migrationBuilder.CreateIndex(
name: "ix_temporary_keys_key",
table: "temporary_keys",
column: "key",
unique: true
);
}
}
}

View file

@ -0,0 +1,915 @@
// <auto-generated />
using System.Collections.Generic;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
[DbContext(typeof(DatabaseContext))]
[Migration("20250329131053_AddNotices")]
partial class AddNotices
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.PrimitiveCollection<string[]>("RedirectUris")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("redirect_uris");
b.PrimitiveCollection<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.HasKey("Id")
.HasName("pk_applications");
b.ToTable("applications", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.PrimitiveCollection<string[]>("ClearedFields")
.HasColumnType("text[]")
.HasColumnName("cleared_fields");
b.Property<long>("ModeratorId")
.HasColumnType("bigint")
.HasColumnName("moderator_id");
b.Property<string>("ModeratorUsername")
.IsRequired()
.HasColumnType("text")
.HasColumnName("moderator_username");
b.Property<string>("Reason")
.HasColumnType("text")
.HasColumnName("reason");
b.Property<long?>("ReportId")
.HasColumnType("bigint")
.HasColumnName("report_id");
b.Property<long?>("TargetMemberId")
.HasColumnType("bigint")
.HasColumnName("target_member_id");
b.Property<string>("TargetMemberName")
.HasColumnType("text")
.HasColumnName("target_member_name");
b.Property<long?>("TargetUserId")
.HasColumnType("bigint")
.HasColumnName("target_user_id");
b.Property<string>("TargetUsername")
.HasColumnType("text")
.HasColumnName("target_username");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_audit_log");
b.HasIndex("ReportId")
.IsUnique()
.HasDatabaseName("ix_audit_log_report_id");
b.ToTable("audit_log", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<int>("AuthType")
.HasColumnType("integer")
.HasColumnName("auth_type");
b.Property<long?>("FediverseApplicationId")
.HasColumnType("bigint")
.HasColumnName("fediverse_application_id");
b.Property<string>("RemoteId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("remote_id");
b.Property<string>("RemoteUsername")
.HasColumnType("text")
.HasColumnName("remote_username");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_auth_methods");
b.HasIndex("FediverseApplicationId")
.HasDatabaseName("ix_auth_methods_fediverse_application_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_auth_methods_user_id");
b.HasIndex("AuthType", "RemoteId")
.IsUnique()
.HasDatabaseName("ix_auth_methods_auth_type_remote_id")
.HasFilter("fediverse_application_id IS NULL");
b.HasIndex("AuthType", "RemoteId", "FediverseApplicationId")
.IsUnique()
.HasDatabaseName("ix_auth_methods_auth_type_remote_id_fediverse_application_id")
.HasFilter("fediverse_application_id IS NOT NULL");
b.ToTable("auth_methods", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("text")
.HasColumnName("filename");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_data_exports");
b.HasIndex("Filename")
.IsUnique()
.HasDatabaseName("ix_data_exports_filename");
b.HasIndex("UserId")
.HasDatabaseName("ix_data_exports_user_id");
b.ToTable("data_exports", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.FediverseApplication", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Domain")
.IsRequired()
.HasColumnType("text")
.HasColumnName("domain");
b.Property<bool>("ForceRefresh")
.HasColumnType("boolean")
.HasColumnName("force_refresh");
b.Property<int>("InstanceType")
.HasColumnType("integer")
.HasColumnName("instance_type");
b.HasKey("Id")
.HasName("pk_fediverse_applications");
b.ToTable("fediverse_applications", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<List<Field>>("Fields")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("fields");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.PrimitiveCollection<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<List<FieldEntry>>("Names")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("names");
b.Property<List<Pronoun>>("Pronouns")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("pronouns");
b.Property<string>("Sid")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("sid")
.HasDefaultValueSql("find_free_member_sid()");
b.Property<bool>("Unlisted")
.HasColumnType("boolean")
.HasColumnName("unlisted");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_members");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_members_legacy_id");
b.HasIndex("Sid")
.IsUnique()
.HasDatabaseName("ix_members_sid");
b.HasIndex("UserId", "Name")
.IsUnique()
.HasDatabaseName("ix_members_user_id_name");
b.ToTable("members", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("MemberId")
.HasColumnType("bigint")
.HasColumnName("member_id");
b.Property<long>("PrideFlagId")
.HasColumnType("bigint")
.HasColumnName("pride_flag_id");
b.HasKey("Id")
.HasName("pk_member_flags");
b.HasIndex("MemberId")
.HasDatabaseName("ix_member_flags_member_id");
b.HasIndex("PrideFlagId")
.HasDatabaseName("ix_member_flags_pride_flag_id");
b.ToTable("member_flags", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<long>("AuthorId")
.HasColumnType("bigint")
.HasColumnName("author_id");
b.Property<Instant>("EndTime")
.HasColumnType("timestamp with time zone")
.HasColumnName("end_time");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text")
.HasColumnName("message");
b.Property<Instant>("StartTime")
.HasColumnType("timestamp with time zone")
.HasColumnName("start_time");
b.HasKey("Id")
.HasName("pk_notices");
b.HasIndex("AuthorId")
.HasDatabaseName("ix_notices_author_id");
b.ToTable("notices", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<Instant?>("AcknowledgedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acknowledged_at");
b.Property<string>("LocalizationKey")
.HasColumnType("text")
.HasColumnName("localization_key");
b.Property<Dictionary<string, string>>("LocalizationParams")
.IsRequired()
.HasColumnType("hstore")
.HasColumnName("localization_params");
b.Property<string>("Message")
.HasColumnType("text")
.HasColumnName("message");
b.Property<long>("TargetId")
.HasColumnType("bigint")
.HasColumnName("target_id");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_notifications");
b.HasIndex("TargetId")
.HasDatabaseName("ix_notifications_target_id");
b.ToTable("notifications", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<string>("Hash")
.HasColumnType("text")
.HasColumnName("hash");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_pride_flags");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_pride_flags_legacy_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_pride_flags_user_id");
b.ToTable("pride_flags", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Context")
.HasColumnType("text")
.HasColumnName("context");
b.Property<int>("Reason")
.HasColumnType("integer")
.HasColumnName("reason");
b.Property<long>("ReporterId")
.HasColumnType("bigint")
.HasColumnName("reporter_id");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<long?>("TargetMemberId")
.HasColumnType("bigint")
.HasColumnName("target_member_id");
b.Property<string>("TargetSnapshot")
.HasColumnType("text")
.HasColumnName("target_snapshot");
b.Property<int>("TargetType")
.HasColumnType("integer")
.HasColumnName("target_type");
b.Property<long>("TargetUserId")
.HasColumnType("bigint")
.HasColumnName("target_user_id");
b.HasKey("Id")
.HasName("pk_reports");
b.HasIndex("ReporterId")
.HasDatabaseName("ix_reports_reporter_id");
b.HasIndex("TargetMemberId")
.HasDatabaseName("ix_reports_target_member_id");
b.HasIndex("TargetUserId")
.HasDatabaseName("ix_reports_target_user_id");
b.ToTable("reports", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<long>("ApplicationId")
.HasColumnType("bigint")
.HasColumnName("application_id");
b.Property<Instant>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<byte[]>("Hash")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("hash");
b.Property<bool>("ManuallyExpired")
.HasColumnType("boolean")
.HasColumnName("manually_expired");
b.PrimitiveCollection<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_tokens");
b.HasIndex("ApplicationId")
.HasDatabaseName("ix_tokens_application_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_tokens_user_id");
b.ToTable("tokens", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<Dictionary<Snowflake, User.CustomPreference>>("CustomPreferences")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("custom_preferences");
b.Property<bool>("Deleted")
.HasColumnType("boolean")
.HasColumnName("deleted");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasColumnName("deleted_by");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<List<Field>>("Fields")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("fields");
b.Property<Instant>("LastActive")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_active");
b.Property<Instant>("LastSidReroll")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_sid_reroll");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.PrimitiveCollection<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<bool>("ListHidden")
.HasColumnType("boolean")
.HasColumnName("list_hidden");
b.Property<string>("MemberTitle")
.HasColumnType("text")
.HasColumnName("member_title");
b.Property<List<FieldEntry>>("Names")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("names");
b.Property<string>("Password")
.HasColumnType("text")
.HasColumnName("password");
b.Property<List<Pronoun>>("Pronouns")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("pronouns");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<UserSettings>("Settings")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("settings");
b.Property<string>("Sid")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("sid")
.HasDefaultValueSql("find_free_user_sid()");
b.Property<string>("Timezone")
.HasColumnType("text")
.HasColumnName("timezone");
b.Property<string>("Username")
.IsRequired()
.HasColumnType("text")
.HasColumnName("username");
b.HasKey("Id")
.HasName("pk_users");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_users_legacy_id");
b.HasIndex("Sid")
.IsUnique()
.HasDatabaseName("ix_users_sid");
b.HasIndex("Username")
.IsUnique()
.HasDatabaseName("ix_users_username");
b.ToTable("users", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("PrideFlagId")
.HasColumnType("bigint")
.HasColumnName("pride_flag_id");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_user_flags");
b.HasIndex("PrideFlagId")
.HasDatabaseName("ix_user_flags_pride_flag_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_user_flags_user_id");
b.ToTable("user_flags", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.Report", "Report")
.WithOne("AuditLogEntry")
.HasForeignKey("Foxnouns.Backend.Database.Models.AuditLogEntry", "ReportId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_audit_log_reports_report_id");
b.Navigation("Report");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication")
.WithMany()
.HasForeignKey("FediverseApplicationId")
.HasConstraintName("fk_auth_methods_fediverse_applications_fediverse_application_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany("AuthMethods")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_auth_methods_users_user_id");
b.Navigation("FediverseApplication");
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany("DataExports")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_data_exports_users_user_id");
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany("Members")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_members_users_user_id");
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.Member", null)
.WithMany("ProfileFlags")
.HasForeignKey("MemberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_member_flags_members_member_id");
b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag")
.WithMany()
.HasForeignKey("PrideFlagId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_member_flags_pride_flags_pride_flag_id");
b.Navigation("PrideFlag");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "Author")
.WithMany()
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_notices_users_author_id");
b.Navigation("Author");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "Target")
.WithMany()
.HasForeignKey("TargetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_notifications_users_target_id");
b.Navigation("Target");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", null)
.WithMany("Flags")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_pride_flags_users_user_id");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "Reporter")
.WithMany()
.HasForeignKey("ReporterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_reports_users_reporter_id");
b.HasOne("Foxnouns.Backend.Database.Models.Member", "TargetMember")
.WithMany()
.HasForeignKey("TargetMemberId")
.HasConstraintName("fk_reports_members_target_member_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", "TargetUser")
.WithMany()
.HasForeignKey("TargetUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_reports_users_target_user_id");
b.Navigation("Reporter");
b.Navigation("TargetMember");
b.Navigation("TargetUser");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application")
.WithMany()
.HasForeignKey("ApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tokens_applications_application_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_tokens_users_user_id");
b.Navigation("Application");
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag")
.WithMany()
.HasForeignKey("PrideFlagId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_flags_pride_flags_pride_flag_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", null)
.WithMany("ProfileFlags")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_flags_users_user_id");
b.Navigation("PrideFlag");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
{
b.Navigation("ProfileFlags");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
{
b.Navigation("AuditLogEntry");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{
b.Navigation("AuthMethods");
b.Navigation("DataExports");
b.Navigation("Flags");
b.Navigation("Members");
b.Navigation("ProfileFlags");
});
#pragma warning restore 612, 618
}
}
}

View file

@ -0,0 +1,56 @@
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
public partial class AddNotices : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "notices",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false),
message = table.Column<string>(type: "text", nullable: false),
start_time = table.Column<Instant>(
type: "timestamp with time zone",
nullable: false
),
end_time = table.Column<Instant>(
type: "timestamp with time zone",
nullable: false
),
author_id = table.Column<long>(type: "bigint", nullable: false),
},
constraints: table =>
{
table.PrimaryKey("pk_notices", x => x.id);
table.ForeignKey(
name: "fk_notices_users_author_id",
column: x => x.author_id,
principalTable: "users",
principalColumn: "id",
onDelete: ReferentialAction.Cascade
);
}
);
migrationBuilder.CreateIndex(
name: "ix_notices_author_id",
table: "notices",
column: "author_id"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "notices");
}
}
}

View file

@ -1,5 +1,7 @@
// <auto-generated /> // <auto-generated />
using System.Collections.Generic;
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@ -17,9 +19,10 @@ namespace Foxnouns.Backend.Database.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "8.0.5") .HasAnnotation("ProductVersion", "9.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "hstore");
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b =>
@ -43,12 +46,12 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("name"); .HasColumnName("name");
b.Property<string[]>("RedirectUris") b.PrimitiveCollection<string[]>("RedirectUris")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
.HasColumnName("redirect_uris"); .HasColumnName("redirect_uris");
b.Property<string[]>("Scopes") b.PrimitiveCollection<string[]>("Scopes")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
.HasColumnName("scopes"); .HasColumnName("scopes");
@ -59,6 +62,63 @@ namespace Foxnouns.Backend.Database.Migrations
b.ToTable("applications", (string)null); b.ToTable("applications", (string)null);
}); });
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.PrimitiveCollection<string[]>("ClearedFields")
.HasColumnType("text[]")
.HasColumnName("cleared_fields");
b.Property<long>("ModeratorId")
.HasColumnType("bigint")
.HasColumnName("moderator_id");
b.Property<string>("ModeratorUsername")
.IsRequired()
.HasColumnType("text")
.HasColumnName("moderator_username");
b.Property<string>("Reason")
.HasColumnType("text")
.HasColumnName("reason");
b.Property<long?>("ReportId")
.HasColumnType("bigint")
.HasColumnName("report_id");
b.Property<long?>("TargetMemberId")
.HasColumnType("bigint")
.HasColumnName("target_member_id");
b.Property<string>("TargetMemberName")
.HasColumnType("text")
.HasColumnName("target_member_name");
b.Property<long?>("TargetUserId")
.HasColumnType("bigint")
.HasColumnName("target_user_id");
b.Property<string>("TargetUsername")
.HasColumnType("text")
.HasColumnName("target_username");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_audit_log");
b.HasIndex("ReportId")
.IsUnique()
.HasDatabaseName("ix_audit_log_report_id");
b.ToTable("audit_log", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@ -95,9 +155,47 @@ namespace Foxnouns.Backend.Database.Migrations
b.HasIndex("UserId") b.HasIndex("UserId")
.HasDatabaseName("ix_auth_methods_user_id"); .HasDatabaseName("ix_auth_methods_user_id");
b.HasIndex("AuthType", "RemoteId")
.IsUnique()
.HasDatabaseName("ix_auth_methods_auth_type_remote_id")
.HasFilter("fediverse_application_id IS NULL");
b.HasIndex("AuthType", "RemoteId", "FediverseApplicationId")
.IsUnique()
.HasDatabaseName("ix_auth_methods_auth_type_remote_id_fediverse_application_id")
.HasFilter("fediverse_application_id IS NOT NULL");
b.ToTable("auth_methods", (string)null); b.ToTable("auth_methods", (string)null);
}); });
modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Filename")
.IsRequired()
.HasColumnType("text")
.HasColumnName("filename");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_data_exports");
b.HasIndex("Filename")
.IsUnique()
.HasDatabaseName("ix_data_exports_filename");
b.HasIndex("UserId")
.HasDatabaseName("ix_data_exports_user_id");
b.ToTable("data_exports", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.FediverseApplication", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.FediverseApplication", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@ -119,6 +217,10 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("domain"); .HasColumnName("domain");
b.Property<bool>("ForceRefresh")
.HasColumnType("boolean")
.HasColumnName("force_refresh");
b.Property<int>("InstanceType") b.Property<int>("InstanceType")
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("instance_type"); .HasColumnName("instance_type");
@ -147,7 +249,19 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("display_name"); .HasColumnName("display_name");
b.Property<string[]>("Links") b.Property<List<Field>>("Fields")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("fields");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.PrimitiveCollection<string[]>("Links")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
.HasColumnName("links"); .HasColumnName("links");
@ -157,6 +271,23 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("name"); .HasColumnName("name");
b.Property<List<FieldEntry>>("Names")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("names");
b.Property<List<Pronoun>>("Pronouns")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("pronouns");
b.Property<string>("Sid")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("sid")
.HasDefaultValueSql("find_free_member_sid()");
b.Property<bool>("Unlisted") b.Property<bool>("Unlisted")
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("unlisted"); .HasColumnName("unlisted");
@ -168,6 +299,14 @@ namespace Foxnouns.Backend.Database.Migrations
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_members"); .HasName("pk_members");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_members_legacy_id");
b.HasIndex("Sid")
.IsUnique()
.HasDatabaseName("ix_members_sid");
b.HasIndex("UserId", "Name") b.HasIndex("UserId", "Name")
.IsUnique() .IsUnique()
.HasDatabaseName("ix_members_user_id_name"); .HasDatabaseName("ix_members_user_id_name");
@ -175,6 +314,203 @@ namespace Foxnouns.Backend.Database.Migrations
b.ToTable("members", (string)null); b.ToTable("members", (string)null);
}); });
modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("MemberId")
.HasColumnType("bigint")
.HasColumnName("member_id");
b.Property<long>("PrideFlagId")
.HasColumnType("bigint")
.HasColumnName("pride_flag_id");
b.HasKey("Id")
.HasName("pk_member_flags");
b.HasIndex("MemberId")
.HasDatabaseName("ix_member_flags_member_id");
b.HasIndex("PrideFlagId")
.HasDatabaseName("ix_member_flags_pride_flag_id");
b.ToTable("member_flags", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<long>("AuthorId")
.HasColumnType("bigint")
.HasColumnName("author_id");
b.Property<Instant>("EndTime")
.HasColumnType("timestamp with time zone")
.HasColumnName("end_time");
b.Property<string>("Message")
.IsRequired()
.HasColumnType("text")
.HasColumnName("message");
b.Property<Instant>("StartTime")
.HasColumnType("timestamp with time zone")
.HasColumnName("start_time");
b.HasKey("Id")
.HasName("pk_notices");
b.HasIndex("AuthorId")
.HasDatabaseName("ix_notices_author_id");
b.ToTable("notices", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<Instant?>("AcknowledgedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("acknowledged_at");
b.Property<string>("LocalizationKey")
.HasColumnType("text")
.HasColumnName("localization_key");
b.Property<Dictionary<string, string>>("LocalizationParams")
.IsRequired()
.HasColumnType("hstore")
.HasColumnName("localization_params");
b.Property<string>("Message")
.HasColumnType("text")
.HasColumnName("message");
b.Property<long>("TargetId")
.HasColumnType("bigint")
.HasColumnName("target_id");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.HasKey("Id")
.HasName("pk_notifications");
b.HasIndex("TargetId")
.HasDatabaseName("ix_notifications_target_id");
b.ToTable("notifications", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<string>("Hash")
.HasColumnType("text")
.HasColumnName("hash");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_pride_flags");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_pride_flags_legacy_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_pride_flags_user_id");
b.ToTable("pride_flags", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
{
b.Property<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Context")
.HasColumnType("text")
.HasColumnName("context");
b.Property<int>("Reason")
.HasColumnType("integer")
.HasColumnName("reason");
b.Property<long>("ReporterId")
.HasColumnType("bigint")
.HasColumnName("reporter_id");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<long?>("TargetMemberId")
.HasColumnType("bigint")
.HasColumnName("target_member_id");
b.Property<string>("TargetSnapshot")
.HasColumnType("text")
.HasColumnName("target_snapshot");
b.Property<int>("TargetType")
.HasColumnType("integer")
.HasColumnName("target_type");
b.Property<long>("TargetUserId")
.HasColumnType("bigint")
.HasColumnName("target_user_id");
b.HasKey("Id")
.HasName("pk_reports");
b.HasIndex("ReporterId")
.HasDatabaseName("ix_reports_reporter_id");
b.HasIndex("TargetMemberId")
.HasDatabaseName("ix_reports_target_member_id");
b.HasIndex("TargetUserId")
.HasDatabaseName("ix_reports_target_user_id");
b.ToTable("reports", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@ -198,7 +534,7 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("manually_expired"); .HasColumnName("manually_expired");
b.Property<string[]>("Scopes") b.PrimitiveCollection<string[]>("Scopes")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
.HasColumnName("scopes"); .HasColumnName("scopes");
@ -233,11 +569,48 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("bio"); .HasColumnName("bio");
b.Property<Dictionary<Snowflake, User.CustomPreference>>("CustomPreferences")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("custom_preferences");
b.Property<bool>("Deleted")
.HasColumnType("boolean")
.HasColumnName("deleted");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasColumnName("deleted_by");
b.Property<string>("DisplayName") b.Property<string>("DisplayName")
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("display_name"); .HasColumnName("display_name");
b.Property<string[]>("Links") b.Property<List<Field>>("Fields")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("fields");
b.Property<Instant>("LastActive")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_active");
b.Property<Instant>("LastSidReroll")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_sid_reroll");
b.Property<string>("LegacyId")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("legacy_id")
.HasDefaultValueSql("gen_random_uuid()");
b.PrimitiveCollection<string[]>("Links")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
.HasColumnName("links"); .HasColumnName("links");
@ -250,14 +623,40 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("member_title"); .HasColumnName("member_title");
b.Property<List<FieldEntry>>("Names")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("names");
b.Property<string>("Password") b.Property<string>("Password")
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("password"); .HasColumnName("password");
b.Property<List<Pronoun>>("Pronouns")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("pronouns");
b.Property<int>("Role") b.Property<int>("Role")
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("role"); .HasColumnName("role");
b.Property<UserSettings>("Settings")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("settings");
b.Property<string>("Sid")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("sid")
.HasDefaultValueSql("find_free_user_sid()");
b.Property<string>("Timezone")
.HasColumnType("text")
.HasColumnName("timezone");
b.Property<string>("Username") b.Property<string>("Username")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text")
@ -266,6 +665,14 @@ namespace Foxnouns.Backend.Database.Migrations
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_users"); .HasName("pk_users");
b.HasIndex("LegacyId")
.IsUnique()
.HasDatabaseName("ix_users_legacy_id");
b.HasIndex("Sid")
.IsUnique()
.HasDatabaseName("ix_users_sid");
b.HasIndex("Username") b.HasIndex("Username")
.IsUnique() .IsUnique()
.HasDatabaseName("ix_users_username"); .HasDatabaseName("ix_users_username");
@ -273,6 +680,46 @@ namespace Foxnouns.Backend.Database.Migrations
b.ToTable("users", (string)null); b.ToTable("users", (string)null);
}); });
modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("PrideFlagId")
.HasColumnType("bigint")
.HasColumnName("pride_flag_id");
b.Property<long>("UserId")
.HasColumnType("bigint")
.HasColumnName("user_id");
b.HasKey("Id")
.HasName("pk_user_flags");
b.HasIndex("PrideFlagId")
.HasDatabaseName("ix_user_flags_pride_flag_id");
b.HasIndex("UserId")
.HasDatabaseName("ix_user_flags_user_id");
b.ToTable("user_flags", (string)null);
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuditLogEntry", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.Report", "Report")
.WithOne("AuditLogEntry")
.HasForeignKey("Foxnouns.Backend.Database.Models.AuditLogEntry", "ReportId")
.OnDelete(DeleteBehavior.SetNull)
.HasConstraintName("fk_audit_log_reports_report_id");
b.Navigation("Report");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
{ {
b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication") b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication")
@ -292,6 +739,18 @@ namespace Foxnouns.Backend.Database.Migrations
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("Foxnouns.Backend.Database.Models.DataExport", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
.WithMany("DataExports")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_data_exports_users_user_id");
b.Navigation("User");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
{ {
b.HasOne("Foxnouns.Backend.Database.Models.User", "User") b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
@ -301,75 +760,90 @@ namespace Foxnouns.Backend.Database.Migrations
.IsRequired() .IsRequired()
.HasConstraintName("fk_members_users_user_id"); .HasConstraintName("fk_members_users_user_id");
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Field>", "Fields", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("fields");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.FieldEntry>", "Names", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Pronoun>", "Pronouns", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("MemberId");
b1.ToTable("members");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("MemberId")
.HasConstraintName("fk_members_members_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.Member", null)
.WithMany("ProfileFlags")
.HasForeignKey("MemberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_member_flags_members_member_id");
b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag")
.WithMany()
.HasForeignKey("PrideFlagId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_member_flags_pride_flags_pride_flag_id");
b.Navigation("PrideFlag");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notice", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "Author")
.WithMany()
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_notices_users_author_id");
b.Navigation("Author");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Notification", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "Target")
.WithMany()
.HasForeignKey("TargetId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_notifications_users_target_id");
b.Navigation("Target");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", null)
.WithMany("Flags")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_pride_flags_users_user_id");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
{
b.HasOne("Foxnouns.Backend.Database.Models.User", "Reporter")
.WithMany()
.HasForeignKey("ReporterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_reports_users_reporter_id");
b.HasOne("Foxnouns.Backend.Database.Models.Member", "TargetMember")
.WithMany()
.HasForeignKey("TargetMemberId")
.HasConstraintName("fk_reports_members_target_member_id");
b.HasOne("Foxnouns.Backend.Database.Models.User", "TargetUser")
.WithMany()
.HasForeignKey("TargetUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_reports_users_target_user_id");
b.Navigation("Reporter");
b.Navigation("TargetMember");
b.Navigation("TargetUser");
});
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
{ {
b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application") b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application")
@ -391,83 +865,46 @@ namespace Foxnouns.Backend.Database.Migrations
b.Navigation("User"); b.Navigation("User");
}); });
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b =>
{ {
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Fields#List", "Fields", b1 => b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag")
{ .WithMany()
b1.Property<long>("UserId") .HasForeignKey("PrideFlagId")
.HasColumnType("bigint"); .OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_flags_pride_flags_pride_flag_id");
b1.Property<int>("Capacity") b.HasOne("Foxnouns.Backend.Database.Models.User", null)
.HasColumnType("integer"); .WithMany("ProfileFlags")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_user_flags_users_user_id");
b1.HasKey("UserId") b.Navigation("PrideFlag");
.HasName("pk_users"); });
b1.ToTable("users"); modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b =>
{
b.Navigation("ProfileFlags");
});
b1.ToJson("fields"); modelBuilder.Entity("Foxnouns.Backend.Database.Models.Report", b =>
{
b1.WithOwner() b.Navigation("AuditLogEntry");
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Names#List", "Names", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("names");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Pronouns#List", "Pronouns", b1 =>
{
b1.Property<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("Capacity")
.HasColumnType("integer");
b1.HasKey("UserId")
.HasName("pk_users");
b1.ToTable("users");
b1.ToJson("pronouns");
b1.WithOwner()
.HasForeignKey("UserId")
.HasConstraintName("fk_users_users_user_id");
});
b.Navigation("Fields")
.IsRequired();
b.Navigation("Names")
.IsRequired();
b.Navigation("Pronouns")
.IsRequired();
}); });
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b => modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
{ {
b.Navigation("AuthMethods"); b.Navigation("AuthMethods");
b.Navigation("DataExports");
b.Navigation("Flags");
b.Navigation("Members"); b.Navigation("Members");
b.Navigation("ProfileFlags");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }

View file

@ -1,3 +1,17 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.Security.Cryptography; using System.Security.Cryptography;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
@ -9,22 +23,32 @@ public class Application : BaseModel
public required string ClientSecret { get; init; } public required string ClientSecret { get; init; }
public required string Name { get; init; } public required string Name { get; init; }
public required string[] Scopes { get; init; } public required string[] Scopes { get; init; }
public required string[] RedirectUris { get; set; } public required string[] RedirectUris { get; init; }
public static Application Create(ISnowflakeGenerator snowflakeGenerator, string name, string[] scopes, public static Application Create(
string[] redirectUrls) ISnowflakeGenerator snowflakeGenerator,
string name,
string[] scopes,
string[] redirectUrls
)
{ {
var clientId = RandomNumberGenerator.GetHexString(32, true); string clientId = RandomNumberGenerator.GetHexString(32, true);
var clientSecret = OauthUtils.RandomToken(); string clientSecret = AuthUtils.RandomToken();
if (scopes.Except(OauthUtils.ApplicationScopes).Any()) if (scopes.Except(AuthUtils.ApplicationScopes).Any())
{ {
throw new ArgumentException("Invalid scopes passed to Application.Create", nameof(scopes)); throw new ArgumentException(
"Invalid scopes passed to Application.Create",
nameof(scopes)
);
} }
if (redirectUrls.Any(s => !OauthUtils.ValidateRedirectUri(s))) if (redirectUrls.Any(s => !AuthUtils.ValidateRedirectUri(s)))
{ {
throw new ArgumentException("Invalid redirect URLs passed to Application.Create", nameof(redirectUrls)); throw new ArgumentException(
"Invalid redirect URLs passed to Application.Create",
nameof(redirectUrls)
);
} }
return new Application return new Application
@ -34,7 +58,7 @@ public class Application : BaseModel
ClientSecret = clientSecret, ClientSecret = clientSecret,
Name = name, Name = name,
Scopes = scopes, Scopes = scopes,
RedirectUris = redirectUrls RedirectUris = redirectUrls,
}; };
} }
} }

View file

@ -0,0 +1,45 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.ComponentModel.DataAnnotations.Schema;
using Foxnouns.Backend.Utils;
using Newtonsoft.Json;
namespace Foxnouns.Backend.Database.Models;
public class AuditLogEntry : BaseModel
{
public Snowflake ModeratorId { get; init; }
public string ModeratorUsername { get; init; } = string.Empty;
public Snowflake? TargetUserId { get; init; }
public string? TargetUsername { get; init; }
public Snowflake? TargetMemberId { get; init; }
public string? TargetMemberName { get; init; }
public Snowflake? ReportId { get; init; }
public Report? Report { get; init; }
public AuditLogEntryType Type { get; init; }
public string? Reason { get; init; }
public string[]? ClearedFields { get; init; }
}
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public enum AuditLogEntryType
{
IgnoreReport,
WarnUser,
WarnUserAndClearProfile,
SuspendUser,
QuerySensitiveUserData,
}

View file

@ -1,3 +1,17 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
namespace Foxnouns.Backend.Database.Models; namespace Foxnouns.Backend.Database.Models;
public class AuthMethod : BaseModel public class AuthMethod : BaseModel
@ -20,4 +34,4 @@ public enum AuthType
Tumblr, Tumblr,
Fediverse, Fediverse,
Email, Email,
} }

View file

@ -0,0 +1,26 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using NodaTime;
namespace Foxnouns.Backend.Database.Models;
public class DataExport : BaseModel
{
public Snowflake UserId { get; init; }
public User User { get; init; } = null!;
public required string Filename { get; init; }
public static readonly Duration Expiration = Duration.FromDays(15);
}

View file

@ -1,3 +1,17 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
namespace Foxnouns.Backend.Database.Models; namespace Foxnouns.Backend.Database.Models;
public class FediverseApplication : BaseModel public class FediverseApplication : BaseModel
@ -6,10 +20,11 @@ public class FediverseApplication : BaseModel
public required string ClientId { get; set; } public required string ClientId { get; set; }
public required string ClientSecret { get; set; } public required string ClientSecret { get; set; }
public required FediverseInstanceType InstanceType { get; set; } public required FediverseInstanceType InstanceType { get; set; }
public bool ForceRefresh { get; set; }
} }
public enum FediverseInstanceType public enum FediverseInstanceType
{ {
MastodonApi, MastodonApi,
MisskeyApi MisskeyApi,
} }

View file

@ -1,3 +1,17 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
namespace Foxnouns.Backend.Database.Models; namespace Foxnouns.Backend.Database.Models;
public class Field public class Field
@ -15,4 +29,4 @@ public class FieldEntry
public class Pronoun : FieldEntry public class Pronoun : FieldEntry
{ {
public string? DisplayText { get; set; } public string? DisplayText { get; set; }
} }

View file

@ -1,8 +1,24 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
namespace Foxnouns.Backend.Database.Models; namespace Foxnouns.Backend.Database.Models;
public class Member : BaseModel public class Member : BaseModel
{ {
public required string Name { get; set; } public required string Name { get; set; }
public string Sid { get; set; } = string.Empty;
public required string LegacyId { get; init; }
public string? DisplayName { get; set; } public string? DisplayName { get; set; }
public string? Bio { get; set; } public string? Bio { get; set; }
public string? Avatar { get; set; } public string? Avatar { get; set; }
@ -13,6 +29,8 @@ public class Member : BaseModel
public List<Pronoun> Pronouns { get; set; } = []; public List<Pronoun> Pronouns { get; set; } = [];
public List<Field> Fields { get; set; } = []; public List<Field> Fields { get; set; } = [];
public List<MemberFlag> ProfileFlags { get; set; } = [];
public Snowflake UserId { get; init; } public Snowflake UserId { get; init; }
public User User { get; init; } = null!; public User User { get; init; } = null!;
} }

View file

@ -0,0 +1,13 @@
using NodaTime;
namespace Foxnouns.Backend.Database.Models;
public class Notice : BaseModel
{
public required string Message { get; set; }
public required Instant StartTime { get; set; }
public required Instant EndTime { get; set; }
public Snowflake AuthorId { get; init; }
public User Author { get; init; } = null!;
}

View file

@ -0,0 +1,41 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Utils;
using Newtonsoft.Json;
using NodaTime;
namespace Foxnouns.Backend.Database.Models;
public class Notification : BaseModel
{
public Snowflake TargetId { get; init; }
public User Target { get; init; } = null!;
public NotificationType Type { get; init; }
public string? Message { get; init; }
public string? LocalizationKey { get; init; }
public Dictionary<string, string> LocalizationParams { get; init; } = [];
public Instant? AcknowledgedAt { get; set; }
}
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public enum NotificationType
{
Notice,
Warning,
Suspension,
}

View file

@ -0,0 +1,42 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
namespace Foxnouns.Backend.Database.Models;
public class PrideFlag : BaseModel
{
public required Snowflake UserId { get; init; }
public required string LegacyId { get; init; }
// A null hash means the flag hasn't been processed yet.
public string? Hash { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
}
public class UserFlag
{
public long Id { get; init; }
public required Snowflake UserId { get; init; }
public required Snowflake PrideFlagId { get; init; }
public PrideFlag PrideFlag { get; init; } = null!;
}
public class MemberFlag
{
public long Id { get; init; }
public required Snowflake MemberId { get; init; }
public required Snowflake PrideFlagId { get; init; }
public PrideFlag PrideFlag { get; init; } = null!;
}

View file

@ -0,0 +1,76 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Utils;
using Newtonsoft.Json;
namespace Foxnouns.Backend.Database.Models;
public class Report : BaseModel
{
public Snowflake ReporterId { get; init; }
public User Reporter { get; init; } = null!;
public Snowflake TargetUserId { get; init; }
public User TargetUser { get; init; } = null!;
public Snowflake? TargetMemberId { get; init; }
public Member? TargetMember { get; init; }
public ReportStatus Status { get; set; }
public ReportReason Reason { get; init; }
public string? Context { get; init; }
public ReportTargetType TargetType { get; init; }
public string? TargetSnapshot { get; init; }
public AuditLogEntry? AuditLogEntry { get; set; }
}
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public enum ReportTargetType
{
User,
Member,
}
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public enum ReportStatus
{
Open,
Closed,
}
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public enum ReportReason
{
Totalitarianism,
HateSpeech,
Racism,
Homophobia,
Transphobia,
Queerphobia,
Exclusionism,
Sexism,
Ableism,
ChildPornography,
PedophiliaAdvocacy,
Harassment,
Impersonation,
Doxxing,
EncouragingSelfHarm,
Spam,
Trolling,
Advertisement,
CopyrightViolation,
}

View file

@ -1,3 +1,17 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using NodaTime; using NodaTime;
namespace Foxnouns.Backend.Database.Models; namespace Foxnouns.Backend.Database.Models;
@ -14,4 +28,4 @@ public class Token : BaseModel
public Snowflake ApplicationId { get; set; } public Snowflake ApplicationId { get; set; }
public Application Application { get; set; } = null!; public Application Application { get; set; } = null!;
} }

View file

@ -1,24 +1,81 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// ReSharper disable UnusedAutoPropertyAccessor.Global
using System.ComponentModel.DataAnnotations.Schema;
using Foxnouns.Backend.Utils;
using Newtonsoft.Json;
using NodaTime;
namespace Foxnouns.Backend.Database.Models; namespace Foxnouns.Backend.Database.Models;
public class User : BaseModel public class User : BaseModel
{ {
public required string Username { get; set; } public required string Username { get; set; }
public string Sid { get; set; } = string.Empty;
public required string LegacyId { get; init; }
public string? DisplayName { get; set; } public string? DisplayName { get; set; }
public string? Bio { get; set; } public string? Bio { get; set; }
public string? MemberTitle { get; set; } public string? MemberTitle { get; set; }
public string? Avatar { get; set; } public string? Avatar { get; set; }
public string[] Links { get; set; } = []; public string[] Links { get; set; } = [];
public bool ListHidden { get; set; } public bool ListHidden { get; set; }
public string? Timezone { get; set; }
public List<FieldEntry> Names { get; set; } = []; public List<FieldEntry> Names { get; set; } = [];
public List<Pronoun> Pronouns { get; set; } = []; public List<Pronoun> Pronouns { get; set; } = [];
public List<Field> Fields { get; set; } = []; public List<Field> Fields { get; set; } = [];
public Dictionary<Snowflake, CustomPreference> CustomPreferences { get; set; } = [];
public List<PrideFlag> Flags { get; set; } = [];
public List<UserFlag> ProfileFlags { get; set; } = [];
public UserRole Role { get; set; } = UserRole.User; public UserRole Role { get; set; } = UserRole.User;
public string? Password { get; set; } // Password may be null if the user doesn't authenticate with an email address public string? Password { get; set; } // Password may be null if the user doesn't authenticate with an email address
public List<Member> Members { get; } = []; public List<Member> Members { get; } = [];
public List<AuthMethod> AuthMethods { get; } = []; public List<AuthMethod> AuthMethods { get; } = [];
public List<DataExport> DataExports { get; } = [];
public UserSettings Settings { get; set; } = new();
public required Instant LastActive { get; set; }
public Instant LastSidReroll { get; set; }
public bool Deleted { get; set; }
public Instant? DeletedAt { get; set; }
public Snowflake? DeletedBy { get; set; }
[NotMapped]
public bool? SelfDelete => Deleted ? DeletedBy != null : null;
public class CustomPreference
{
public required string Icon { get; set; }
public required string Tooltip { get; set; }
public bool Muted { get; set; }
public bool Favourite { get; set; }
// This type is generally serialized directly, so the converter is applied here.
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
public PreferenceSize Size { get; set; }
public Guid LegacyId { get; init; } = Guid.NewGuid();
}
public static readonly Duration DeleteAfter = Duration.FromDays(30);
public static readonly Duration DeleteSuspendedAfter = Duration.FromDays(180);
} }
public enum UserRole public enum UserRole
@ -26,4 +83,17 @@ public enum UserRole
User, User,
Moderator, Moderator,
Admin, Admin,
} }
public enum PreferenceSize
{
Large,
Normal,
Small,
}
public class UserSettings
{
public bool? DarkMode { get; set; }
public Snowflake? LastReadNotice { get; set; }
}

View file

@ -1,12 +1,32 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Text.Json;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Newtonsoft.Json; using Newtonsoft.Json;
using NodaTime; using NodaTime;
using JsonSerializer = Newtonsoft.Json.JsonSerializer;
namespace Foxnouns.Backend.Database; namespace Foxnouns.Backend.Database;
[JsonConverter(typeof(JsonConverter))] [JsonConverter(typeof(JsonConverter))]
public readonly struct Snowflake(ulong value) [System.Text.Json.Serialization.JsonConverter(typeof(SystemJsonConverter))]
[TypeConverter(typeof(TypeConverter))]
public readonly struct Snowflake(ulong value) : IEquatable<Snowflake>
{ {
public const long Epoch = 1_640_995_200_000; // 2022-01-01 at 00:00:00 UTC public const long Epoch = 1_640_995_200_000; // 2022-01-01 at 00:00:00 UTC
public readonly ulong Value = value; public readonly ulong Value = value;
@ -37,47 +57,102 @@ public readonly struct Snowflake(ulong value)
public short Increment => (short)(Value & 0xFFF); public short Increment => (short)(Value & 0xFFF);
public static bool operator <(Snowflake arg1, Snowflake arg2) => arg1.Value < arg2.Value; public static bool operator <(Snowflake arg1, Snowflake arg2) => arg1.Value < arg2.Value;
public static bool operator >(Snowflake arg1, Snowflake arg2) => arg1.Value > arg2.Value; public static bool operator >(Snowflake arg1, Snowflake arg2) => arg1.Value > arg2.Value;
public static bool operator ==(Snowflake arg1, Snowflake arg2) => arg1.Value == arg2.Value; public static bool operator ==(Snowflake arg1, Snowflake arg2) => arg1.Value == arg2.Value;
public static bool operator !=(Snowflake arg1, Snowflake arg2) => arg1.Value != arg2.Value; public static bool operator !=(Snowflake arg1, Snowflake arg2) => arg1.Value != arg2.Value;
public static implicit operator ulong(Snowflake s) => s.Value; public static implicit operator ulong(Snowflake s) => s.Value;
public static implicit operator long(Snowflake s) => (long)s.Value; public static implicit operator long(Snowflake s) => (long)s.Value;
public static implicit operator Snowflake(ulong n) => new(n); public static implicit operator Snowflake(ulong n) => new(n);
public static implicit operator Snowflake(long n) => new((ulong)n); public static implicit operator Snowflake(long n) => new((ulong)n);
public static bool TryParse(string input, [NotNullWhen(true)] out Snowflake? snowflake) public static bool TryParse(string input, [NotNullWhen(true)] out Snowflake? snowflake)
{ {
snowflake = null; snowflake = null;
if (!ulong.TryParse(input, out var res)) return false; if (!ulong.TryParse(input, out ulong res))
return false;
snowflake = new Snowflake(res); snowflake = new Snowflake(res);
return true; return true;
} }
public static Snowflake FromInstant(Instant instant) =>
new((ulong)(instant.ToUnixTimeMilliseconds() - Epoch) << 22);
public override bool Equals(object? obj) => obj is Snowflake other && Value == other.Value; public override bool Equals(object? obj) => obj is Snowflake other && Value == other.Value;
public bool Equals(Snowflake other) => Value == other.Value;
public override int GetHashCode() => Value.GetHashCode(); public override int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString();
/// <summary> /// <summary>
/// An Entity Framework ValueConverter for Snowflakes to longs. /// An Entity Framework ValueConverter for Snowflakes to longs.
/// </summary> /// </summary>
// ReSharper disable once ClassNeverInstantiated.Global // ReSharper disable once ClassNeverInstantiated.Global
public class ValueConverter() : ValueConverter<Snowflake, long>( public class ValueConverter() : ValueConverter<Snowflake, long>(x => x, x => x);
convertToProviderExpression: x => x,
convertFromProviderExpression: x => x
);
private class JsonConverter : JsonConverter<Snowflake> private class SystemJsonConverter : System.Text.Json.Serialization.JsonConverter<Snowflake>
{ {
public override void WriteJson(JsonWriter writer, Snowflake value, JsonSerializer serializer) public override Snowflake Read(
ref Utf8JsonReader reader,
Type typeToConvert,
JsonSerializerOptions options
) => ulong.Parse(reader.GetString()!);
public override void Write(
Utf8JsonWriter writer,
Snowflake value,
JsonSerializerOptions options
) => writer.WriteStringValue(value.Value.ToString());
}
private class JsonConverter : JsonConverter<Snowflake?>
{
public override void WriteJson(
JsonWriter writer,
Snowflake? value,
JsonSerializer serializer
)
{ {
writer.WriteValue(value.Value.ToString()); if (value != null)
writer.WriteValue(value.Value.ToString());
else
writer.WriteNull();
} }
public override Snowflake ReadJson(JsonReader reader, Type objectType, Snowflake existingValue, public override Snowflake? ReadJson(
JsonReader reader,
Type objectType,
Snowflake? existingValue,
bool hasExistingValue, bool hasExistingValue,
JsonSerializer serializer) JsonSerializer serializer
{ ) =>
return ulong.Parse((string)reader.Value!); reader.TokenType is not (JsonToken.None or JsonToken.Null)
} ? ulong.Parse((string)reader.Value!)
: null;
} }
}
private class TypeConverter : System.ComponentModel.TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) =>
sourceType == typeof(string);
public override bool CanConvertTo(
ITypeDescriptorContext? context,
[NotNullWhen(true)] Type? destinationType
) => destinationType == typeof(Snowflake);
public override object? ConvertFrom(
ITypeDescriptorContext? context,
CultureInfo? culture,
object value
) => TryParse((string)value, out Snowflake? snowflake) ? snowflake : null;
}
}

View file

@ -1,3 +1,17 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
using NodaTime; using NodaTime;
namespace Foxnouns.Backend.Database; namespace Foxnouns.Backend.Database;
@ -28,18 +42,21 @@ public class SnowflakeGenerator : ISnowflakeGenerator
public Snowflake GenerateSnowflake(Instant? time = null) public Snowflake GenerateSnowflake(Instant? time = null)
{ {
time ??= SystemClock.Instance.GetCurrentInstant(); time ??= SystemClock.Instance.GetCurrentInstant();
var increment = Interlocked.Increment(ref _increment); long increment = Interlocked.Increment(ref _increment);
var threadId = Environment.CurrentManagedThreadId % 32; int threadId = Environment.CurrentManagedThreadId % 32;
var timestamp = time.Value.ToUnixTimeMilliseconds() - Snowflake.Epoch; long timestamp = time.Value.ToUnixTimeMilliseconds() - Snowflake.Epoch;
return (timestamp << 22) | (uint)(_processId << 17) | (uint)(threadId << 12) | (increment % 4096); return (timestamp << 22)
| (uint)(_processId << 17)
| (uint)(threadId << 12)
| (increment % 4096);
} }
} }
public static class SnowflakeGeneratorServiceExtensions public static class SnowflakeGeneratorServiceExtensions
{ {
public static IServiceCollection AddSnowflakeGenerator(this IServiceCollection services, int? processId = null) public static IServiceCollection AddSnowflakeGenerator(
{ this IServiceCollection services,
return services.AddSingleton<ISnowflakeGenerator>(new SnowflakeGenerator(processId)); int? processId = null
} ) => services.AddSingleton<ISnowflakeGenerator>(new SnowflakeGenerator(processId));
} }

View file

@ -0,0 +1,30 @@
#!/bin/bash
set -e
# Original script by zotan for Iceshrimp.NET
# Source: https://iceshrimp.dev/iceshrimp/Iceshrimp.NET/src/commit/7c93dcf79dda54fc1a4ea9772e3f80874e6bcefb/Iceshrimp.Backend/Core/Database/prune-designer-cs-files.sh
if [[ $(uname) == "Darwin" ]]; then
SED="gsed"
else
SED="sed"
fi
import="using Microsoft.EntityFrameworkCore.Infrastructure;"
dbc=" [DbContext(typeof(DatabaseContext))]"
for file in $(find "$(dirname $0)/Migrations" -name '*.Designer.cs'); do
echo "$file"
csfile="${file%.Designer.cs}.cs"
if [[ ! -f $csfile ]]; then
echo "$csfile doesn't exist, exiting"
exit 1
fi
lineno=$($SED -n '/^{/=' "$csfile")
((lineno+=2))
migr=$(grep "\[Migration" "$file")
$SED -i "${lineno}i \\$migr" "$csfile"
$SED -i "${lineno}i \\$dbc" "$csfile"
$SED -i "2i $import" "$csfile"
rm "$file"
done

View file

@ -0,0 +1,66 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// ReSharper disable NotAccessedPositionalProperty.Global
// ReSharper disable ClassNeverInstantiated.Global
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Utils;
using Newtonsoft.Json;
using NodaTime;
namespace Foxnouns.Backend.Dto;
public record CallbackResponse(
bool HasAccount,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Ticket,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] UserResponse? User,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Token,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? ExpiresAt
);
public record UrlsResponse(bool EmailEnabled, string? Discord, string? Google, string? Tumblr);
public record AuthResponse(UserResponse User, string Token, Instant ExpiresAt);
public record SingleUrlResponse(string Url);
public record AddOauthAccountResponse(
Snowflake Id,
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,
string RemoteId,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername
);
public record OauthRegisterRequest(string Ticket, string Username);
public record CallbackRequest(string Code, string State);
public record EmailLoginRequest(string Email, string Password);
public record EmailRegisterRequest(string Email);
public record EmailCompleteRegistrationRequest(string Ticket, string Username, string Password);
public record EmailCallbackRequest(string State);
public record EmailChangePasswordRequest(string Current, string New);
public record EmailForgotPasswordRequest(string Email);
public record EmailResetPasswordRequest(string State, string Password);
public record FediverseCallbackRequest(string Instance, string Code, string? State = null);

View file

@ -0,0 +1,21 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// ReSharper disable NotAccessedPositionalProperty.Global
using NodaTime;
namespace Foxnouns.Backend.Dto;
public record DataExportResponse(string? Url, Instant? ExpiresAt);

View file

@ -0,0 +1,31 @@
// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// ReSharper disable NotAccessedPositionalProperty.Global
// ReSharper disable UnusedAutoPropertyAccessor.Global
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Utils;
namespace Foxnouns.Backend.Dto;
public record PrideFlagResponse(Snowflake Id, string? ImageUrl, string Name, string? Description);
public record CreateFlagRequest(string Name, string Image, string? Description);
public class UpdateFlagRequest : PatchRequest
{
public string? Name { get; init; }
public string? Description { get; init; }
}

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