Compare commits

..
151 Commits
Author SHA1 Message Date
HampusandGitHub ec681e6061 fix(desktop): retry an update that never installed (#3354) 2026-10-09 15:03:36 +02:00
HampusandGitHub 0fc6fad11d fix(ui): report the shown carousel step so report flows react (#3353) 2026-10-09 14:53:08 +02:00
HampusandGitHub eb329f2cd0 fix(app): add missing data-flx attributes (#3351) 2026-10-09 14:18:01 +02:00
HampusandGitHub 9a20821332 feat(app,api): let group DM owners allow mature content (#3350) 2026-10-09 14:12:13 +02:00
HampusandGitHub 4749eb7f86 ci(desktop): use a clean profile for each Windows update check (#3349) 2026-10-09 08:32:45 +02:00
HampusandGitHub 2d83fd2a93 ci(desktop): keep the logs from Windows update checks (#3348) 2026-10-09 08:22:21 +02:00
HampusandGitHub d180af6ba2 fix(desktop): clean up release check runs on Windows (#3347) 2026-10-09 06:53:50 +02:00
HampusandGitHub 4810eb3e24 feat(ci): run the release check on built Windows shells (#3346) 2026-10-09 05:40:01 +02:00
HampusandGitHub 48a19dedd8 fix(desktop): stop requiring the removed win-toast at startup (#3345) 2026-10-09 05:39:56 +02:00
HampusandGitHub cadf7ea532 feat(schema): add double tap action to synced preferences (#3344) 2026-10-09 04:09:24 +02:00
HampusandGitHub fc60d05f17 feat(app): load heavy desktop assets on demand, update in place (#3343) 2026-10-09 03:53:57 +02:00
HampusandGitHub 5072488f1e feat(desktop): prefer the bundled renderer, own update prompts (#3341) 2026-10-09 03:01:15 +02:00
HampusandGitHub 7a5b9a0ded docs(threads): publish threads, forums and media channels (#3342) 2026-10-09 02:44:06 +02:00
HampusandGitHub 4ce4d4e09a feat(ci): bundle the renderer in the desktop shell (#3339) 2026-10-09 02:31:37 +02:00
HampusandGitHub 82abd03a37 feat(reports): delete reports and configure retention (#3338) 2026-10-09 01:41:47 +02:00
HampusandGitHub 93d1f8af46 refactor: remove dead code (#3337) 2026-10-09 01:41:26 +02:00
HampusandGitHub a19002652b docs: use British English throughout (#3336) 2026-10-09 01:40:55 +02:00
HampusandGitHub 6534ee4300 feat(admin): require a resolution and fix panel layout and copy (#3335) 2026-10-09 01:40:34 +02:00
HampusandGitHub 4db13b0375 fix(app,api): kick reasons, blocked counts and locale fallbacks (#3334) 2026-10-09 01:40:08 +02:00
HampusandGitHub a5c1362f4a fix(self-hosting): use instance contacts and live product name (#3333) 2026-10-09 01:39:35 +02:00
HampusandGitHub 454fefdb92 chore(moderation): remove the built-in NCMEC integration (#3332) 2026-10-09 01:39:04 +02:00
HampusandGitHub f390e610b9 fix(tooling): repair test, i18n and docs verification gates (#3331) 2026-10-09 01:38:34 +02:00
HampusandGitHub ece622e800 feat(reports): add server-driven report flows (#3330) 2026-10-09 01:38:00 +02:00
HampusandGitHub bc17922b02 fix(gateway): wire presence grace, request limits and voice sync (#3329) 2026-10-09 01:37:25 +02:00
HampusandGitHub a32d8e4f52 fix(i18n): use the web term for instance in id, tr and vi (#3328) 2026-10-09 01:36:57 +02:00
HampusandGitHub 12daeb758d fix(app): retry failed images at once when back online (#3327) 2026-10-08 23:39:18 +02:00
HampusandGitHub fff67ed9af fix(desktop): fetch theme CSS via the local resource proxy (#3326) 2026-10-08 23:21:27 +02:00
HampusandGitHub d0b4816a5f fix(media-proxy): allow the desktop app origin and preflight (#3325) 2026-10-08 23:21:10 +02:00
HampusandGitHub 0febad324c fix(repo): match editorconfig to each formatter (#3324) 2026-10-08 22:07:46 +02:00
HampusandGitHub dcc3273889 feat(app): ask everyone to review who can message them (#3323) 2026-10-08 20:47:44 +02:00
HampusandGitHub e9a4f33a7e feat(api): track privacy setup and open community DMs by default (#3322) 2026-10-08 20:47:22 +02:00
HampusandGitHub ae820ba4ea chore(i18n): refresh catalog references (#3320) 2026-10-08 20:34:03 +02:00
HampusandGitHub 94f1239050 fix(premium): reflect limit config flags in plan comparisons (#3319) 2026-10-08 20:33:20 +02:00
HampusandGitHub acc1392a53 fix(ui): reopen a popout clicked while it is closing (#3318) 2026-10-08 20:33:04 +02:00
HampusandGitHub a6a9789091 fix(ui): stop clicks passing through the user area footer (#3317) 2026-10-08 20:32:49 +02:00
HampusandGitHub fdb7410976 fix(threads): light up joined threads in muted communities (#3316) 2026-10-08 20:32:33 +02:00
HampusandGitHub b146b8ee33 fix(desktop): trust locally added CAs in Chromium on Linux (#3315) 2026-10-08 20:21:04 +02:00
HampusandGitHub 4b1afc953f fix(app): redraw the favicon badge when branding changes (#3314) 2026-10-08 20:20:49 +02:00
HampusandGitHub 4ecbe9603c fix(api): store branding images as media references (#3313) 2026-10-08 20:20:34 +02:00
HampusandGitHub 847d449882 chore(i18n): refresh catalog references (#3312) 2026-10-08 20:06:41 +02:00
HampusandGitHub bef20cba85 fix(links): warn on unhandled middle clicks and desktop links (#3311) 2026-10-08 20:05:54 +02:00
JiraliteandGitHub ebf91ccc76 fix(links): warn on embed link middle clicks (#3305) 2026-10-08 20:05:27 +02:00
HampusandGitHub abfaff16c2 fix(guild): fit text icon initials to the icon by measured width (#3310) 2026-10-08 20:05:09 +02:00
HampusandGitHub 89fa895a0a fix(forum): keep closed posts visible without a reload (#3309) 2026-10-08 20:04:53 +02:00
HampusandGitHub 334fef52e1 fix(desktop): keep slow connects racing instead of aborting them (#3308) 2026-10-08 20:04:39 +02:00
HampusandGitHub aa42bf13ce fix(threads): announce threads above the latest five messages (#3307) 2026-10-08 20:04:23 +02:00
HampusandGitHub c486c2d3d0 revert(admin): restore user type toggles (#3306) 2026-10-08 20:04:01 +02:00
HampusandGitHub 0ec1cee99e test(gateway): keep the recheck timer out of the eunit process (#3304) 2026-10-08 18:14:47 +02:00
HampusandGitHub ccbe4857a1 docs(auth): document the desktop handoff deep link and deny (#3303) 2026-10-08 18:00:17 +02:00
HampusandGitHub 7f8b0afedb chore(i18n): refresh catalog references (#3302) 2026-10-08 17:58:50 +02:00
JiraliteandGitHub e94b7d7ad8 fix(threads): add a debug thread button (#3276) 2026-10-08 17:57:47 +02:00
HampusandGitHub 27baa38fd1 fix(threads): open the thread menu on browser right-click (#3301) 2026-10-08 17:45:33 +02:00
HampusandGitHub 4af10dd3e3 fix(threads): keep header actions clear of the channel name (#3300) 2026-10-08 17:44:48 +02:00
HampusandGitHub 269d33cab2 fix(threads): confirm before leaving a private thread (#3299) 2026-10-08 17:44:00 +02:00
HampusandGitHub 170e12595b fix(threads): let the thread members popout scroll (#3298) 2026-10-08 17:42:56 +02:00
HampusandGitHub b82bbaa2dd fix(ui): drop the switch label tab stop with no focus ring (#3297) 2026-10-08 17:42:11 +02:00
HampusandGitHub b8f3dbddbf fix(threads): keep Tab and focus inside the thread create pane (#3296) 2026-10-08 17:41:14 +02:00
HampusandGitHub 663572fd10 fix(forum): keep attachments on forum and media posts (#3295) 2026-10-08 17:40:29 +02:00
HampusandGitHub 5033cf7203 fix(app): follow theme library changes made in other windows (#3294) 2026-10-08 17:39:34 +02:00
HampusandGitHub 0fcab2f8f0 fix(app): hold the switched view until its route commits (#3293) 2026-10-08 17:38:36 +02:00
HampusandGitHub c3d790f664 fix(app): refocus the editor before text context menu actions (#3292) 2026-10-08 17:37:40 +02:00
HampusandGitHub 0906a85d6f fix(app): keep mature content consent across account switches (#3291) 2026-10-08 17:36:55 +02:00
HampusandGitHub 4da8dec4ac fix(app): ignore leftover mentions in unseen channels (#3290) 2026-10-08 17:35:59 +02:00
HampusandGitHub d6e3254a16 feat(app): show instance branding in switchers and pickers (#3289) 2026-10-08 17:34:56 +02:00
HampusandGitHub 6dc698ff5e fix(app): follow instance branding in tab title and favicon (#3288) 2026-10-08 17:33:28 +02:00
HampusandGitHub 39e48458da fix(app): keep a global define from breaking bundled modules (#3287) 2026-10-08 17:32:40 +02:00
HampusandGitHub c5453f83e8 feat(desktop): sign in with the browser without typing a code (#3286) 2026-10-08 17:31:45 +02:00
HampusandGitHub 510cc1b916 fix(desktop): show the text context menu on password fields (#3285) 2026-10-08 17:30:11 +02:00
HampusandGitHub e7859fefb1 fix(desktop): leave full screen before hiding on macOS close (#3284) 2026-10-08 17:28:58 +02:00
HampusandGitHub 46a356cc40 fix(media): keep self-host media loading after restarts (#3283) 2026-10-08 17:28:18 +02:00
HampusandGitHub bd56174870 fix(desktop): frame streamed request bodies for every method (#3282) 2026-10-08 17:26:41 +02:00
HampusandGitHub 965bb5f634 fix(desktop): trust the system CA store in main requests (#3281) 2026-10-08 17:26:12 +02:00
HampusandGitHub 7937d5f802 fix(desktop): update from the splash only after the user clicks (#3280) 2026-10-08 17:25:34 +02:00
HampusandGitHub f1373a63c3 feat(ci): publish renderer modules without a shell build (#3279) 2026-10-08 17:23:59 +02:00
HampusandGitHub e0d7625c39 feat(api): hand desktop sign-in back with a deep link grant (#3278) 2026-10-08 17:23:28 +02:00
HampusandGitHub f8505d19f7 feat(threads): ship threads and the Plutonium page to everyone (#3277) 2026-10-08 16:30:39 +02:00
HampusandGitHub 1515a8487f fix(desktop): fall back across resolved addresses on connect (#3275) 2026-10-08 05:08:00 +02:00
HampusandGitHub 7a4aa42d4b feat(premium): native Nordic prices and subscribing during grace (#3274) 2026-10-08 04:21:54 +02:00
HampusandGitHub 0ef1cd14c3 feat(desktop): show download progress and diagnostics on splash (#3273) 2026-10-08 03:06:06 +02:00
HampusandGitHub 795e190bda fix(desktop): let the Theme Studio popout resolve its runtime (#3272) 2026-10-08 03:05:37 +02:00
HampusandGitHub d00389ab30 fix(desktop): reach split-horizon instances on their LAN IP (#3271) 2026-10-08 03:05:15 +02:00
HampusandGitHub 3071bc7525 fix(desktop): honour the system proxy in main-process requests (#3270) 2026-10-08 03:04:55 +02:00
HampusandGitHub 6de6be2358 fix(threads): open the thread menu on thread chip right-click (#3269) 2026-10-08 03:04:35 +02:00
HampusandGitHub c51c222c47 fix(voice): stop stale mute echoes from toggling mute (#3268) 2026-10-08 03:04:10 +02:00
HampusandGitHub e218ba21de fix(app): load saved account avatars from their own instance (#3267) 2026-10-08 03:03:47 +02:00
HampusandGitHub de358c0def fix(app): let action toolbar overrides win over its defaults (#3266) 2026-10-08 03:03:26 +02:00
HampusandGitHub 4b88d64b2d fix(app): show the official badge and a visible account menu (#3265) 2026-10-08 03:03:05 +02:00
HampusandGitHub ee1c861eb2 perf(desktop): cache display media and retain shown images (#3264) 2026-10-08 03:02:41 +02:00
HampusandGitHub 6a24ac3f4e fix(desktop): fetch media bytes via the local resource proxy (#3263) 2026-10-08 03:02:15 +02:00
HampusandGitHub 8b312c610e fix(app): tag only desktop proxy uploads with a local upload id (#3262) 2026-10-08 00:40:51 +02:00
HampusandGitHub 2e3e3a1536 fix(desktop): fix manual update wording and retry Velopack (#3261) 2026-10-08 00:04:58 +02:00
HampusandGitHub 5c63d81ffd fix(app): show the setup wizard on new instances, not sign-in (#3260) 2026-10-07 23:28:04 +02:00
HampusandGitHub c402c52a8a fix(app): read instance config safely in startup skeletons (#3259) 2026-10-07 23:27:59 +02:00
HampusandGitHub 317fedeef9 fix(ci): resolve a bare drive WORKDIR to the drive root (#3258) 2026-10-07 21:44:18 +02:00
HampusandGitHub 002502a7fa feat(admin): one threads toggle, hide hosted-only rollouts (#3257) 2026-10-07 21:32:45 +02:00
HampusandGitHub 1997850d55 fix(threads): let administrators grant thread permissions (#3256) 2026-10-07 21:32:39 +02:00
HampusandGitHub d346eb0e88 fix(desktop): generate build channel in tests, bump yoke-derive (#3255) 2026-10-07 19:57:20 +02:00
HampusandGitHub 7c5fa2180a feat(desktop): bundled renderer, modules and instance accounts (#3254) 2026-10-07 16:48:07 +02:00
HampusandGitHub c748c8af4e feat(channels): redesign the create channel modal (#3253) 2026-10-07 15:26:11 +02:00
HampusandGitHub ba59a13149 feat(threads): thread and forum UI on web (#3252) 2026-10-07 15:25:43 +02:00
HampusandGitHub 3f4160b138 feat(threads): thread and forum API, admin, schemas and docs (#3251) 2026-10-07 15:25:12 +02:00
HampusandGitHub 5f4295e399 feat(threads): gateway, messages and push thread support (#3250) 2026-10-07 15:24:40 +02:00
HampusandGitHub 4e730832c7 fix(installer): pull images before the first start (#3248) 2026-10-07 02:37:35 +02:00
HampusandGitHub d7c00d4556 fix(schema): keep template topics optional after trimming (#3247) 2026-10-07 01:42:37 +02:00
HampusandGitHub 154b65afe5 docs(self-hosting): fix LiveKit CSP and backup guidance (#3246) 2026-10-07 00:09:39 +02:00
HampusandGitHub fcc2a3f64b docs(github): keep vulnerability reports out of chats (#3245) 2026-10-06 23:25:53 +02:00
HampusandGitHub 80456861ac fix(api): accept long forum topics in imported templates (#3244) 2026-10-06 22:46:47 +02:00
0c4f016ba2 feat(config): read secrets from NAME_FILE variables (#1421)
Co-authored-by: Hampus <[email protected]>
2026-10-06 21:43:08 +02:00
HampusandGitHub cc5545c333 fix(api): sync stripe customer email on change (#3243) 2026-10-06 21:38:25 +02:00
HampusandGitHub 6e28092cdc fix(app): keep mention highlight when mentions are suppressed (#3242) 2026-10-06 20:58:28 +02:00
HampusandGitHub 8b6910d505 chore(github): send bug reports and ideas to feedback.fluxer.com (#3241) 2026-10-06 19:14:26 +02:00
HampusandGitHub d87e31efaf fix(app): drop the reply when its target message is deleted (#3237) 2026-10-06 02:14:00 +02:00
HampusandGitHub 6618a6baf4 fix(installer): replace a stale installer before upgrading (#3235) 2026-10-05 21:50:16 +02:00
HampusandGitHub 22b8f5454b fix(app): skip forwarded messages when editing with arrow up (#3234) 2026-10-05 21:34:05 +02:00
HampusandGitHub 801bd3f106 fix(app): cycle dms in sidebar order with the keyboard (#3233) 2026-10-05 21:17:40 +02:00
HampusandGitHub e26c8c870d feat(api): archive and schedule automated message deletion (#3231) 2026-10-05 21:03:29 +02:00
HampusandGitHub f4e545e090 fix(app): reorder dm list immediately on pin and unpin (#3230) 2026-10-05 20:45:50 +02:00
HampusandGitHub 2006fc0d8d feat(push): allow listed hosts to resolve to private addresses (#3228) 2026-10-05 20:11:08 +02:00
HampusandGitHub d456048e69 fix(svc): respect FLUXER_POSTGRES_SSL=false with a Postgres URL (#3227) 2026-10-05 20:00:50 +02:00
HampusandGitHub fd35b4da24 fix(api): stop counting one refund twice against the allowance (#3226) 2026-10-05 17:41:28 +02:00
HampusandGitHub 283d179b05 fix(app): strip youtube is= share tracking param (#3225) 2026-10-05 17:33:35 +02:00
HampusandGitHub 3093e7334b feat(api): derive stable placeholder names for hidden profiles (#3224) 2026-10-05 16:57:25 +02:00
HampusandGitHub 02c82f0038 fix(api): limit report auto-resolution on scheduled deletion (#3221) 2026-10-05 14:16:08 +02:00
HampusandGitHub e1eecc3b6c feat(auth): add username sign-in mode and recovery kits (#3215) 2026-10-05 14:10:07 +02:00
HampusandGitHub bf3d73a5f7 fix(ci): drop removed preapproval docs and format a test (#3220) 2026-10-05 13:36:33 +02:00
HampusandGitHub 05257d6439 feat(api): add moderation events and visibility actions (#3219) 2026-10-05 13:24:19 +02:00
HampusandGitHub 532e828fe6 perf(app): restore preloading channels and guilds on hover (#3208) 2026-10-04 19:46:49 +02:00
HampusandGitHub 12a407aca8 fix(api): drop localized card checks and require pix for brazil (#3203) 2026-10-04 18:03:14 +02:00
HampusandGitHub 5ca458dada fix(mentions): ignore @everyone and @here in one-to-one DMs (#3199) 2026-10-04 16:59:04 +02:00
HampusandGitHub 0aeff01c2d feat(api): scope forwarded client ip trust per caller (#3198) 2026-10-04 16:36:41 +02:00
HampusandGitHub 2ac164d5b8 fix(voice): keep the mic graph on the real audio clock (#3197) 2026-10-04 16:18:34 +02:00
HampusandGitHub 14d475df9a fix(app): explain how direct input and desktop shortcuts relate (#3196) 2026-10-04 15:12:52 +02:00
HampusandGitHub f3c777b244 fix(gateway,api): reach NATS over IPv4 and survive boot races (#3189) 2026-10-04 03:06:12 +02:00
HampusandGitHub 1544e58e76 fix(desktop): show unsupported when non-GNOME portal bind fails (#3188) 2026-10-04 02:38:06 +02:00
HampusandGitHub 5d0c9c7cbe fix(desktop): stub the build channel in the Linux session test (#3186) 2026-10-04 01:14:18 +02:00
HampusandGitHub 8f58fcc4c4 feat(desktop): portal-based Linux global shortcuts and PTT (#3185) 2026-10-04 01:08:51 +02:00
HampusandGitHub 5799ef705d fix(app): send expired sessions to login on oauth authorize (#3181) 2026-10-03 20:39:34 +02:00
omsterandGitHub 0de7dde1ce feat(app): reveal external link destinations on hover (#3176) 2026-10-03 19:44:03 +02:00
HampusandGitHub 7b39e5a79d fix(api): treat typographic quotes as exact phrase search (#3180) 2026-10-03 19:43:08 +02:00
HampusandGitHub 583c791016 fix(app): keep the updater polling after async native results (#3179) 2026-10-03 19:42:41 +02:00
HampusandGitHub bc5dcdfe21 feat(app): show paused-messaging banner across the app (#3178) 2026-10-03 19:31:43 +02:00
HampusandGitHub 973aaced96 chore(static): drop unused fluxer_static assets (#3175) 2026-10-03 18:22:03 +02:00
HampusandGitHub 3e9ee908f8 fix(ci): accept the static image's compound license label (#3174) 2026-10-03 18:20:58 +02:00
HampusandGitHub e7347b582c chore(license): relicense artwork and move non-free media out (#3173) 2026-10-03 17:53:42 +02:00
HampusandGitHub 71b7cffabc fix(i18n): more natural French paused-messaging notice (#3172) 2026-10-03 17:04:40 +02:00
HampusandGitHub da9e9ff0be chore: tidy request handling across services (#3168) 2026-10-03 15:36:33 +02:00
HampusandGitHub c6941d5905 style: run rustfmt on attachment url signature tests (#3166) 2026-10-03 15:00:45 +02:00
HampusandGitHub a3cf960660 docs(discovery): document the channel preview route (#3165) 2026-10-03 14:48:09 +02:00
HampusandGitHub 7eebfca20b feat(blocklist): add url-domain host patterns (#3164) 2026-10-03 14:46:08 +02:00
HampusandGitHub e9167d96ec feat(admin): add optional expiry to admin IP bans (#3163) 2026-10-03 14:41:00 +02:00
HampusandGitHub 1664050ef7 fix(app): use sidebar channel icons in forwarded-from source (#3162) 2026-10-03 14:25:56 +02:00
4344 changed files with 519728 additions and 246487 deletions
+2
View File
@@ -50,6 +50,8 @@
/app-dist-output/
/artifacts/
/desktop-shared-assets/
/desktop-modules/
/s3_payload/
/upload_staging/
+27
View File
@@ -9,6 +9,33 @@ max_line_length = 120
indent_style = tab
indent_size = 2
[*.rs]
indent_style = space
indent_size = 4
max_line_length = 100
[*.{erl,hrl,app.src,escript}]
indent_style = space
indent_size = 4
max_line_length = 96
[fluxer_gateway/{rebar.config,elvis.config,.erlfmt}]
indent_style = space
indent_size = 4
max_line_length = 96
[*.{c,h,py}]
indent_style = space
indent_size = 4
[fluxer_app/rust/libfluxwebp/**.{c,h}]
indent_style = space
indent_size = 2
[*.{md,mdx,proto,tpl}]
indent_style = space
indent_size = 2
[*.{yml,yaml,Dockerfile}]
indent_style = space
indent_size = 2
+9 -9
View File
@@ -1,24 +1,24 @@
# Contributing to Fluxer
This policy applies to all issues, discussions, commits and pull requests.
This policy applies to all commits and pull requests.
## Scope
To prevent spam, only approved contributors may submit pull requests.
To request approval, comment on an existing issue and ask to implement it. For work that extends beyond a defect fix, open a [discussion](https://github.com/orgs/fluxerapp/discussions) first.
To request approval, comment on the [feedback.fluxer.com](https://feedback.fluxer.com) post you want to implement and ask to work on it. For work that extends beyond a defect fix, post a feature request there first.
Every pull request must:
- Target the repository's default branch.
- Include a closing reference for each repository issue it resolves.
- Link each feedback.fluxer.com post it resolves.
- Receive approval from a maintainer before it is merged.
Place each closing reference on a separate line:
Place each link on a separate line:
```text
Closes #123
Closes #456
Resolves https://feedback.fluxer.com/p/123
Resolves https://feedback.fluxer.com/p/456
```
## Authorship
@@ -78,11 +78,11 @@ Complete every section of the pull request template. Clearly describe:
## Reports and other contributions
Use the [bug report form](https://github.com/fluxerapp/fluxer/issues/new?template=bug-report.yaml) to report reproducible defects.
Report bugs and request features at [feedback.fluxer.com](https://feedback.fluxer.com).
Report security vulnerabilities privately through the channels specified in the [security policy](https://github.com/fluxerapp/fluxer/blob/main/.github/SECURITY.md). Do not report vulnerabilities in public issues or discussions.
Report security vulnerabilities privately through [fluxer.app/security](https://fluxer.app/security). Never post them publicly.
Use [discussions](https://github.com/orgs/fluxerapp/discussions) for feature proposals and self-hosting questions.
Read the [operator documentation](https://fluxer.dev) for self-hosting questions.
Submit translations through [Weblate](https://weblate.fluxer.tools), not through pull requests.
-41
View File
@@ -1,41 +0,0 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-discussion.json
body:
- type: markdown
attributes:
value: |
Search existing discussions before posting a feature proposal.
Report vulnerabilities through the [private form](https://github.com/fluxerapp/fluxer/security/advisories/new) or <[email protected]>.
- type: textarea
id: problem
attributes:
label: Current problem
description: State what you are trying to do and what prevents it.
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed change
description: State the expected behaviour.
validations:
required: true
- type: textarea
id: notes
attributes:
label: Additional information
description: Optional. Include constraints, trade-offs, related discussions, screenshots or mockups.
validations:
required: false
- type: checkboxes
id: checks
attributes:
label: Acknowledgements
options:
- label: I searched existing discussions.
required: true
+7 -1
View File
@@ -16,4 +16,10 @@ Every commit made by a contributor must include the [Developer Certificate of Or
## Name and marks
The AGPL does not grant permission to use the Fluxer name, logo or other branding. Forks must use a distinct name and branding unless Fluxer Platform AB grants permission otherwise.
Fluxer and the Fluxer logo are trademarks of Fluxer Platform AB. Neither the AGPL nor the CC BY-SA 4.0 licence on Fluxer artwork grants trademark rights. Fluxer Platform AB grants everyone the following permissions.
- You may distribute unmodified builds of Fluxer, or builds with light patches, under the Fluxer name and logo. Light patches are changes for packaging, portability, security and bug fixes, configuration defaults and translations. Linux distributions, nixpkgs, Flathub and container images are all covered.
- A self-hosted instance running such a build may show the Fluxer name and logo under the instance's own name and domain, as long as it does not imply affiliation with or endorsement by Fluxer Platform AB.
- You may refer to Fluxer by name to describe compatibility, for example "works with Fluxer".
Forks with substantive functional changes must use their own name and logo. Any other use needs permission from Fluxer Platform AB. Contact support@fluxer.com.
-83
View File
@@ -1,83 +0,0 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-forms.json
name: Bug report
description: Report a reproducible defect in Fluxer.
type: Bug
body:
- type: markdown
attributes:
value: |
Search [open and closed issues](https://github.com/fluxerapp/fluxer/issues?q=is%3Aissue) before filing a report.
Report vulnerabilities through the [private form](https://github.com/fluxerapp/fluxer/security/advisories/new) or <[email protected]>. Send account and billing requests to <[email protected]>.
- type: textarea
id: summary
attributes:
label: Observed behaviour
description: State what happened and what you expected.
validations:
required: true
- type: textarea
id: steps
attributes:
label: Reproduction steps
description: Give numbered steps starting from a fresh app or session.
placeholder: |
1. Go to ...
2. Select ...
3. Observe ...
validations:
required: true
- type: input
id: build
attributes:
label: Build information
description: >-
Open User Settings, scroll to the bottom of the left sidebar, and select
the build information. Fluxer copies it to the clipboard.
validations:
required: true
- type: dropdown
id: surface
attributes:
label: Affected surface
multiple: true
options:
- Desktop app
- Web app
- Voice, video, or Go Live
- Self-hosted instance
- HTTP API or Gateway
- Documentation site
validations:
required: true
- type: input
id: instance
attributes:
label: Instance
description: For a self-hosted instance, include the release tag and database backend.
placeholder: fluxer.app
validations:
required: false
- type: textarea
id: evidence
attributes:
label: Evidence
description: Attach relevant logs, screenshots or recordings. Remove tokens, keys, private messages and other personal data. Configuration files may contain secrets.
validations:
required: false
- type: checkboxes
id: checks
attributes:
label: Acknowledgements
options:
- label: I searched open and closed issues.
required: true
- label: I removed secrets and unrelated personal data from the report.
required: true
-18
View File
@@ -1,18 +0,0 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-config.json
blank_issues_enabled: false
contact_links:
- name: Mobile client bugs
url: https://github.com/fluxerapp/flutter_client#bug-reporting
about: Read the reporting instructions for the Fluxer mobile client.
- name: Account and billing support
url: https://fluxer.app/help
about: Find account help and support contact details.
- name: Feature proposals
url: https://github.com/orgs/fluxerapp/discussions
about: Propose a feature in a discussion.
- name: Translations
url: https://weblate.fluxer.tools
about: Improve an existing locale or start a new one.
- name: Self-hosting support
url: https://fluxer.dev
about: Read the operator documentation, then open a discussion if the problem remains.
-44
View File
@@ -1,44 +0,0 @@
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-forms.json
name: Documentation
description: Report incorrect, missing or unclear documentation.
type: Task
labels:
- docs
body:
- type: markdown
attributes:
value: |
This form covers <https://fluxer.dev> and operator documentation.
- type: textarea
id: issue
attributes:
label: Documentation defect
description: State what the page says and what is correct. For missing content, state what information you needed.
validations:
required: true
- type: input
id: location
attributes:
label: Location
description: Provide the page URL or file path and heading.
placeholder: https://fluxer.dev/gateway/overview/
validations:
required: false
- type: textarea
id: suggestion
attributes:
label: Proposed wording
description: Optional.
validations:
required: false
- type: checkboxes
id: checks
attributes:
label: Acknowledgements
options:
- label: I searched open and closed issues.
required: true
+2 -2
View File
@@ -1,7 +1,7 @@
# Security policy
Do not report a vulnerability in an issue, pull request, or discussion.
Do not report a vulnerability in a pull request, on feedback.fluxer.com, in a Fluxer community, or in a direct message to staff.
Submit a report through [GitHub private vulnerability reporting](https://github.com/fluxerapp/fluxer/security/advisories/new) or email <security@fluxer.com>. Include the affected component, impact, reproduction steps, and supporting evidence. Remove unrelated personal data and secrets.
Submit a report through <https://fluxer.app/security> or email <security@fluxer.com>. Include the affected component, impact, reproduction steps, and supporting evidence. Remove unrelated personal data and secrets.
The programme scope, testing rules, safe harbour, disclosure process, and reward terms are published at <https://fluxer.app/security>. That page is authoritative.
+2 -2
View File
@@ -1,6 +1,6 @@
Closes #
Resolves https://feedback.fluxer.com/p/
<!-- Repeat this line for each resolved issue, up to 20. Remove the placeholder only if no issue is resolved and the approval gate does not apply. -->
<!-- Repeat this line for each feedback.fluxer.com post this resolves, up to 20. Remove the placeholder only if no post is resolved and the approval gate does not apply. -->
## Summary
@@ -154,6 +154,7 @@ jobs:
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
FLUXER_SELF_HOSTED=true
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
APP_ASSETS_PLATFORM=linux/amd64
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
+1
View File
@@ -200,6 +200,7 @@ jobs:
tags: ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-arm64
build-args: |
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
PUBLIC_ASSET_BASE_URL=https://fluxerstatic.com
SOURCE_SHA=${{ github.sha }}
SOURCE_DATE=${{ steps.source.outputs.date }}
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy:${{ needs.meta.outputs.build_version }}-assets
@@ -0,0 +1,350 @@
name: build desktop modules
on:
workflow_dispatch:
inputs:
channel:
description: Release channel to ship renderer modules on. The live shell on that channel stays as it is.
required: true
type: choice
options:
- canary
- stable
default: canary
build_version:
description: Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation.
required: false
default: ""
type: string
allow_shell_drift:
description: Publish even though shell sources changed since the live shell was built. Only when the renderer does not depend on those changes.
required: false
default: false
type: boolean
permissions:
contents: write
id-token: write
actions: read
concurrency:
group: desktop-modules-${{ inputs.channel }}
cancel-in-progress: false
env:
CHANNEL: ${{ inputs.channel }}
BUILD_CHANNEL: ${{ inputs.channel == 'canary' && 'canary' || 'stable' }}
jobs:
meta:
name: Resolve build metadata
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 25
permissions:
contents: read
outputs:
version: ${{ steps.meta.outputs.version }}
pub_date: ${{ steps.meta.outputs.pub_date }}
channel: ${{ steps.meta.outputs.channel }}
build_channel: ${{ steps.meta.outputs.build_channel }}
source_sha: ${{ steps.meta.outputs.source_sha }}
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: main
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: read
- name: Set metadata
id: meta
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
FLUXER_BUILD_VERSION: ${{ inputs.build_version }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step set_metadata
--channel "${{ inputs.channel }}"
shared_assets:
name: Build shared renderer assets
needs:
- meta
runs-on: ubuntu-24.04
environment: desktop-releases
timeout-minutes: 60
permissions:
contents: read
env:
CHANNEL: ${{ needs.meta.outputs.channel }}
BUILD_CHANNEL: ${{ needs.meta.outputs.build_channel }}
RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
PUBLIC_RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
steps:
- name: Checkout CI helpers
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: _ci
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: source
- name: Set up Rust toolchain (renderer wasm)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
targets: wasm32-unknown-unknown
- name: Set workdir (Unix)
env:
SUBST_TARGET: ${{ github.workspace }}/source
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_workdir_unix
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 26
- name: Set up pnpm
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step setup_pnpm
- name: Resolve pnpm store path (Unix)
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step resolve_pnpm_store_unix
- name: Cache pnpm store
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-shared-renderer-pnpm-store-${{ hashFiles('source/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-shared-renderer-pnpm-store-
- name: Cache cargo registry
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-shared-renderer-cargo-registry-${{ hashFiles('source/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-shared-renderer-cargo-registry-
- name: Install dependencies
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step install_dependencies
- name: Update version
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step update_version
- name: Set build channel
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_build_channel
- name: Build shared renderer assets
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step build_shared_assets
- name: Prepare shared renderer artifact
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step prepare_shared_assets
- name: Upload shared renderer artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: source/desktop-shared-assets
if-no-files-found: error
retention-days: 1
compression-level: 0
- name: Split renderer into desktop modules
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step split_modules
- name: Pack desktop modules
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step pack_modules
- name: Upload desktop module packages
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: |
source/desktop-modules/classification.json
source/desktop-modules/*/module.json
source/desktop-modules/*/package.br
source/desktop-modules/*/package.br.sha256
if-no-files-found: error
retention-days: 1
compression-level: 0
assemble:
name: Assemble the modules-only release
if: ${{ !cancelled() && needs.shared_assets.result == 'success' }}
needs:
- meta
- shared_assets
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 60
permissions:
actions: read
contents: read
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
fetch-depth: 0
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
- name: Download desktop module packages
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: desktop-modules
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: read
- name: Check the live shell runs these modules
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
ALLOW_SHELL_DRIFT: ${{ inputs.allow_shell_drift }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step check_shell_drift
- name: Build the modules-only manifests
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_modules_only_manifest
- name: Prepare GitHub release assets
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step prepare_modules_release_assets
- name: Upload GitHub release assets
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: fluxer-desktop-modules-release-assets
path: release_assets
if-no-files-found: error
retention-days: 1
compression-level: 0
publish_release:
name: Publish GitHub modules-only release
if: ${{ !cancelled() && needs.assemble.result == 'success' }}
needs:
- meta
- assemble
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 60
permissions:
contents: write
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
- name: Set up Rust toolchain (CI helpers)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
- name: Download GitHub release assets
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: fluxer-desktop-modules-release-assets
path: release_assets
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.FLUXER_CI_APP_ID }}
private-key: ${{ secrets.FLUXER_CI_APP_KEY }}
owner: fluxerapp
repositories: fluxer
permission-contents: write
- name: Publish GitHub modules-only release
env:
GH_TOKEN: ${{ steps.create-token.outputs.token }}
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
run: |
set -euo pipefail
release_args=(
release publish
--component "fluxer-desktop-${CHANNEL}"
--build-version "${VERSION}"
--source-sha "${SOURCE_SHA}"
--previous-sha "${RELEASE_BASELINE_SHA}"
--asset-dir release_assets
)
if [[ "${CHANNEL}" == "canary" ]]; then
release_args+=(--prerelease)
fi
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
+232 -2
View File
@@ -103,11 +103,150 @@ jobs:
--step set_matrix
--skip-targets "${{ inputs.skip_targets }}"
shared_assets:
name: Build shared renderer assets
needs:
- meta
runs-on: ubuntu-24.04
environment: desktop-releases
timeout-minutes: 60
permissions:
contents: read
env:
CHANNEL: ${{ needs.meta.outputs.channel }}
BUILD_CHANNEL: ${{ needs.meta.outputs.build_channel }}
RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
PUBLIC_RELEASE_CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
BUILD_VERSION: ${{ needs.meta.outputs.version }}
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
steps:
- name: Checkout CI helpers
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: _ci
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
path: source
- name: Set up Rust toolchain (renderer wasm)
uses: dtolnay/rust-toolchain@02cb101ec7c40f2c49e1d9714d64511d8e1b74de
with:
toolchain: "1.98.1"
targets: wasm32-unknown-unknown
- name: Set workdir (Unix)
env:
SUBST_TARGET: ${{ github.workspace }}/source
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_workdir_unix
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 26
- name: Set up pnpm
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step setup_pnpm
- name: Resolve pnpm store path (Unix)
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step resolve_pnpm_store_unix
- name: Cache pnpm store
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: ${{ env.PNPM_STORE_PATH }}
key: ${{ runner.os }}-shared-renderer-pnpm-store-${{ hashFiles('source/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-shared-renderer-pnpm-store-
- name: Cache cargo registry
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
with:
path: |
~/.cargo/registry
~/.cargo/git
key: ${{ runner.os }}-shared-renderer-cargo-registry-${{ hashFiles('source/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-shared-renderer-cargo-registry-
- name: Install dependencies
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step install_dependencies
- name: Update version
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step update_version
- name: Set build channel
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_build_channel
- name: Build shared renderer assets
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step build_shared_assets
- name: Prepare shared renderer artifact
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step prepare_shared_assets
- name: Upload shared renderer artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: source/desktop-shared-assets
if-no-files-found: error
retention-days: 1
compression-level: 0
- name: Split renderer into desktop modules
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step split_modules
- name: Pack desktop modules
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step pack_modules
- name: Upload desktop module packages
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: |
source/desktop-modules/classification.json
source/desktop-modules/*/module.json
source/desktop-modules/*/package.br
source/desktop-modules/*/package.br.sha256
if-no-files-found: error
retention-days: 1
compression-level: 0
build:
name: Build ${{ matrix.platform }} (${{ matrix.arch }})
needs:
- meta
- matrix
- shared_assets
runs-on: ${{ matrix.os }}
environment: desktop-releases
timeout-minutes: 180
@@ -130,6 +269,7 @@ jobs:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
DESKTOP_PLATFORM: ${{ matrix.platform }}
DESKTOP_ARCH: ${{ matrix.arch }}
FLUXER_MODULES: "1"
PLATFORM: ${{ matrix.platform }}
ARCH: ${{ matrix.arch }}
ELECTRON_ARCH: ${{ matrix.electron_arch }}
@@ -276,6 +416,17 @@ jobs:
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step set_build_channel
- name: Download shared renderer artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: desktop-shared-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: source/desktop-shared-assets
- name: Restore shared renderer assets
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step restore_shared_assets
- name: Build Electron main process
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
env:
@@ -400,6 +551,16 @@ jobs:
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step verify_windows_unpacked_signatures
- name: Verify the packaged shell starts and boots its bundled renderer (Windows)
if: matrix.platform == 'windows' && matrix.arch == 'x64'
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
env:
BUILD_CHANNEL: ${{ env.BUILD_CHANNEL }}
BUILD_VERSION: ${{ env.VERSION }}
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step verify_bundled_renderer_windows
- name: Create portable ZIP (Windows)
if: matrix.platform == 'windows'
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
@@ -440,6 +601,16 @@ jobs:
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step build_app_linux
- name: Verify the packaged shell boots its bundled renderer (Linux)
if: matrix.platform == 'linux' && matrix.arch == 'x64'
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
env:
BUILD_CHANNEL: ${{ env.BUILD_CHANNEL }}
BUILD_VERSION: ${{ env.VERSION }}
run: >-
cargo run --locked --quiet --manifest-path ${{ github.workspace }}/_ci/tools/ci/Cargo.toml -- build-desktop
--step verify_bundled_renderer_linux
- name: Verify signed Windows artifacts
if: matrix.platform == 'windows'
working-directory: ${{ env.WORKDIR }}/fluxer_desktop
@@ -500,16 +671,64 @@ jobs:
retention-days: 1
compression-level: 0
upload:
name: Assemble desktop release assets
verify_windows_arm64:
name: Verify windows (arm64)
if: ${{ !cancelled() && needs.build.result == 'success' }}
needs:
- meta
- build
runs-on: windows-11-arm
timeout-minutes: 30
permissions:
actions: read
contents: read
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ needs.meta.outputs.source_sha }}
sparse-checkout: fluxer_desktop/scripts
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 24
- name: Download windows arm64 build artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
path: artifacts
pattern: fluxer-desktop-${{ needs.meta.outputs.build_channel }}-windows-arm64*
- name: Verify the packaged shell starts and boots its bundled renderer
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$zip = Get-ChildItem -Path artifacts -Recurse -Filter "*-$env:VERSION-portable-win-arm64.zip" | Select-Object -First 1
if ($null -eq $zip) { Write-Host 'This build has no windows arm64 portable zip'; exit 0 }
$app = Join-Path $env:RUNNER_TEMP 'app'
Expand-Archive -Path $zip.FullName -DestinationPath $app
$exe = Get-ChildItem -Path $app -Recurse -Filter 'Fluxer*.exe' | Where-Object { $_.Name -notlike '*_ExecutionStub.exe' } | Select-Object -First 1
if ($null -eq $exe) { throw "$($zip.Name) holds no Fluxer executable" }
node fluxer_desktop/scripts/release-check.mjs --app $exe.DirectoryName --channel $env:CHANNEL --expect-renderer $env:VERSION --expect-source bundled --package-origin http://127.0.0.1:9 --app-arg=--disable-gpu --workdir (Join-Path $env:RUNNER_TEMP 'runs') --timeout-seconds 240
if ($LASTEXITCODE -ne 0) { throw "The windows arm64 build fails its release check" }
upload:
name: Assemble desktop release assets
if: ${{ !cancelled() && needs.build.result == 'success' && needs.verify_windows_arm64.result == 'success' }}
needs:
- meta
- shared_assets
- build
- verify_windows_arm64
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 180
permissions:
actions: read
contents: read
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
@@ -547,6 +766,17 @@ jobs:
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_payload
- name: Download desktop module packages
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
path: desktop-modules
- name: Build desktop module manifest
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_module_manifest
- name: Prepare GitHub release assets
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
@@ -0,0 +1,152 @@
name: desktop windows release check
on:
workflow_dispatch:
inputs:
channel:
description: Release channel
type: choice
options:
- canary
- stable
default: canary
version:
description: Published version to check
required: true
type: string
expect_failure:
description: Pass only when the published build fails to start
type: boolean
default: false
update_from:
description: Comma-separated published versions to install with Setup and update to the version above from the live feed
required: false
type: string
default: ""
stale_apply_record:
description: Before each update, leave behind an hour-old record of an update that never installed (passes only when the installed versions retry it)
type: boolean
default: false
pull_request:
paths:
- .github/workflows/desktop-windows-release-check.yaml
- fluxer_desktop/scripts/release-check.mjs
permissions:
contents: read
concurrency:
group: desktop-windows-release-check-${{ github.ref }}-${{ inputs.version || 'pr' }}
cancel-in-progress: false
jobs:
check:
name: Check windows (${{ matrix.arch }})
runs-on: ${{ matrix.os }}
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- arch: x64
os: windows-2025
- arch: arm64
os: windows-11-arm
env:
CHANNEL: ${{ inputs.channel || 'canary' }}
VERSION: ${{ inputs.version || '2026.1009.20411' }}
EXPECT_FAILURE: ${{ github.event_name == 'pull_request' && 'true' || inputs.expect_failure }}
UPDATE_FROM: ${{ inputs.update_from || '' }}
STALE_APPLY_RECORD: ${{ inputs.stale_apply_record && 'true' || 'false' }}
ARCH: ${{ matrix.arch }}
steps:
- name: Checkout source
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
sparse-checkout: fluxer_desktop/scripts
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
with:
node-version: 24
- name: Check the portable build
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$work = Join-Path $env:RUNNER_TEMP 'portable'
New-Item -ItemType Directory -Force -Path $work | Out-Null
$zip = Join-Path $work 'portable.zip'
Invoke-WebRequest -Uri "https://pkgs.fluxer.com/desktop/$env:CHANNEL/win32/$env:ARCH/$env:VERSION/portable" -OutFile $zip
Expand-Archive -Path $zip -DestinationPath (Join-Path $work 'app')
$exe = Get-ChildItem -Path (Join-Path $work 'app') -Recurse -Filter 'Fluxer*.exe' | Where-Object { $_.Name -notlike '*_ExecutionStub.exe' } | Select-Object -First 1
if ($null -eq $exe) { throw "The portable build holds no Fluxer executable" }
$output = & node fluxer_desktop/scripts/release-check.mjs --app $exe.DirectoryName --channel $env:CHANNEL --expect-renderer $env:VERSION --expect-source bundled --app-arg=--disable-gpu --workdir (Join-Path $work 'runs') --timeout-seconds 240 2>&1
$code = $LASTEXITCODE
$output | ForEach-Object { Write-Host $_ }
if ($env:EXPECT_FAILURE -eq 'true') {
if ($code -eq 0) { throw "Expected $env:VERSION to fail its release check, it passed" }
Write-Host "$env:VERSION fails its release check on windows $env:ARCH as expected"
exit 0
}
if ($code -ne 0) { throw "The release check failed for $env:VERSION on windows $env:ARCH" }
- name: Update installed builds from the live feed
if: env.UPDATE_FROM != ''
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$channelDirs = @{ canary = 'fluxer_desktop_canary'; stable = 'fluxer_desktop' }
$dataDirs = @{ canary = 'fluxercanary'; stable = 'fluxer' }
$installRoot = Join-Path $env:LOCALAPPDATA $channelDirs[$env:CHANNEL]
$logPath = Join-Path (Join-Path $env:APPDATA $dataDirs[$env:CHANNEL]) 'logs\main.log'
$failures = @()
foreach ($from in ($env:UPDATE_FROM -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })) {
Write-Host "== install $from, then update to $env:VERSION"
Get-Process | Where-Object { $_.Path -like "$installRoot*" } | Stop-Process -Force -ErrorAction SilentlyContinue
if (Test-Path $installRoot) { Remove-Item -Recurse -Force $installRoot }
Remove-Item -Recurse -Force (Split-Path (Split-Path $logPath -Parent) -Parent) -ErrorAction SilentlyContinue
$setup = Join-Path $env:RUNNER_TEMP "setup-$from.exe"
Invoke-WebRequest -Uri "https://pkgs.fluxer.com/desktop/$env:CHANNEL/win32/$env:ARCH/$from/setup" -OutFile $setup
Start-Process -FilePath $setup -ArgumentList '--silent' -Wait
Start-Sleep -Seconds 5
Get-Process | Where-Object { $_.Path -like "$installRoot*" } | Stop-Process -Force -ErrorAction SilentlyContinue
$exe = Get-ChildItem -Path (Join-Path $installRoot 'current') -Filter 'Fluxer*.exe' | Where-Object { $_.Name -notlike '*_ExecutionStub.exe' } | Select-Object -First 1
if ($null -eq $exe) { $failures += "$from did not install"; continue }
if ($env:STALE_APPLY_RECORD -eq 'true') {
$dataRoot = Split-Path (Split-Path $logPath -Parent) -Parent
New-Item -ItemType Directory -Force -Path $dataRoot | Out-Null
$attemptedAt = [DateTimeOffset]::UtcNow.AddHours(-1).ToUnixTimeMilliseconds()
Set-Content -Path (Join-Path $dataRoot 'update-apply-state.json') -Value "{`"version`":`"$env:VERSION`",`"attemptedAt`":$attemptedAt}" -NoNewline
Write-Host "left a stale apply record for $env:VERSION"
}
Start-Process -FilePath $exe.FullName -ArgumentList '--disable-gpu'
$deadline = (Get-Date).AddMinutes(8)
$reported = $null
while ((Get-Date) -lt $deadline) {
Start-Sleep -Seconds 10
if (Test-Path $logPath) {
$line = Select-String -Path $logPath -Pattern "The renderer reported its build: .*Desktop $([regex]::Escape($env:VERSION)), Web $([regex]::Escape($env:VERSION))" | Select-Object -Last 1
if ($null -ne $line) { $reported = $line.Line; break }
}
}
$installed = Get-Content (Join-Path $installRoot 'current\sq.version') -Raw -ErrorAction SilentlyContinue
Write-Host "installed package after update: $installed"
$logs = Join-Path $env:RUNNER_TEMP "logs\$from"
New-Item -ItemType Directory -Force -Path $logs | Out-Null
if (Test-Path $logPath) { Copy-Item $logPath $logs }
Get-ChildItem -Path $installRoot -Filter '*.log' -ErrorAction SilentlyContinue | Copy-Item -Destination $logs
if (Test-Path $logPath) { Get-Content $logPath | Select-String -Pattern 'Bootstrap|ShellSelfUpdate|Velopack|velopack|NativeModulePreflight|reported its build' | Select-Object -Last 40 | ForEach-Object { Write-Host $_.Line } }
if ($null -eq $reported) { $failures += "$from did not reach Desktop and Web $env:VERSION" } else { Write-Host "$from updated: $reported" }
Get-Process | Where-Object { $_.Path -like "$installRoot*" } | Stop-Process -Force -ErrorAction SilentlyContinue
}
if ($failures.Count -gt 0) { throw ($failures -join "`n") }
- name: Upload update logs
if: always() && env.UPDATE_FROM != ''
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: update-logs-${{ matrix.arch }}
path: ${{ runner.temp }}/logs
if-no-files-found: ignore
retention-days: 7
+2
View File
@@ -61,6 +61,8 @@ jobs:
--step install_dependencies
- name: Refresh source catalogs
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
run: pnpm i18n:source-sync
- name: Format generated catalogs
+3 -1
View File
@@ -476,6 +476,9 @@ jobs:
- name: Lint JSX for browser-translation safety
run: pnpm exec eslint . --max-warnings 0
- name: Check data-flx attributes
run: pnpm --filter fluxer_app theming:data-flx:check
i18n:
runs-on: ubuntu-24.04
timeout-minutes: 25
@@ -502,7 +505,6 @@ jobs:
run: |
if ! git diff --exit-code -- \
packages/errors/src/i18n/locales \
packages/errors/src/i18n/ErrorI18nTypes.generated.ts \
fluxer_api/pkgs/email/src/email_i18n/locales \
fluxer_api/pkgs/email/src/email_i18n/EmailI18nTypes.generated.ts \
fluxer_api/src/api/content_i18n/locales; then
+4
View File
@@ -28,6 +28,8 @@
/fluxer_app/pkgs/libfluxcore/
/fluxer_app/pkgs/libfluxwebp/
/fluxer_app/src/features/i18n/locales/*/messages.mjs
**/auto-i18n-reviewed-unchanged.json.lock
**/weblate/locales/auto-i18n-reviewed-unchanged.json
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
/fluxer_app/src/features/theme/styles/generated/
/fluxer_app/src/features/ui/components/SVGMasks.tsx
@@ -44,6 +46,8 @@
/app-dist-output/
/artifacts/
/desktop-shared-assets/
/desktop-modules/
/s3_payload/
/upload_staging/
Generated
+37 -159
View File
@@ -863,24 +863,12 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.1"
@@ -1048,12 +1036,6 @@ version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -1100,16 +1082,6 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "cookie"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
dependencies = [
"time",
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -1685,15 +1657,6 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "ff"
version = "0.13.1"
@@ -1710,6 +1673,16 @@ version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.12"
@@ -1729,7 +1702,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"miniz_oxide 0.9.1",
"miniz_oxide",
"zlib-rs",
]
@@ -1741,7 +1714,7 @@ dependencies = [
"aws-config",
"aws-sdk-s3",
"base64 0.23.1",
"bytes",
"brotli",
"chrono",
"clap",
"hex",
@@ -1750,6 +1723,7 @@ dependencies = [
"serde",
"serde_json",
"sha2 0.11.0",
"tar",
"tempfile",
"tokio",
"walkdir",
@@ -1764,10 +1738,8 @@ dependencies = [
"axum",
"clap",
"fluxer_common",
"hmac 0.13.0",
"hyper",
"hyper-util",
"image",
"libc",
"regex",
"reqwest",
@@ -1789,12 +1761,10 @@ dependencies = [
"fluxer-svc",
"fluxer_common",
"futures",
"hmac 0.13.0",
"moka",
"reqwest",
"serde",
"serde_json",
"sha2 0.11.0",
"tokio",
"tracing",
"url",
@@ -1851,7 +1821,6 @@ dependencies = [
"tempfile",
"thiserror",
"tokio",
"tokio-util",
"tower",
"tower-http 0.7.1",
"tracing",
@@ -1872,15 +1841,12 @@ dependencies = [
"fluxer_common",
"fluxer_markdown_parser",
"futures",
"hmac 0.13.0",
"linkify",
"mime_guess",
"moka",
"rmp-serde",
"scylla",
"serde",
"serde_json",
"sha2 0.11.0",
"tokio",
"tracing",
"url",
@@ -1937,13 +1903,11 @@ dependencies = [
"deadpool-postgres",
"futures",
"libc",
"moka",
"rmp-serde",
"rustls",
"scylla",
"serde",
"serde_json",
"thiserror",
"tokio",
"tokio-postgres",
"tokio-postgres-rustls",
@@ -1962,7 +1926,6 @@ dependencies = [
"entities",
"fluxer-svc",
"fluxer_common",
"hmac 0.13.0",
"infer",
"moka",
"regex",
@@ -1970,7 +1933,6 @@ dependencies = [
"scraper",
"serde",
"serde_json",
"sha2 0.11.0",
"tokio",
"tracing",
"url",
@@ -1986,11 +1948,12 @@ dependencies = [
"fluxer-svc",
"fluxer_common",
"futures",
"hmac 0.13.0",
"moka",
"rmp-serde",
"scylla",
"serde",
"serde_json",
"sha2 0.11.0",
"tokio",
"tracing",
]
@@ -2003,7 +1966,6 @@ dependencies = [
"axum",
"base64 0.23.1",
"chrono",
"cookie",
"fluxer_common",
"hmac 0.13.0",
"maud",
@@ -2040,7 +2002,6 @@ dependencies = [
"hex",
"rand 0.10.2",
"reqwest",
"serde",
"serde_json",
"sha2 0.11.0",
"tokio",
@@ -2260,16 +2221,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "group"
version = "0.13.0"
@@ -2659,34 +2610,6 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error 2.0.1",
]
[[package]]
name = "indexmap"
version = "2.14.2"
@@ -3012,16 +2935,6 @@ dependencies = [
"unicase",
]
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
@@ -3063,16 +2976,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "multiversion"
version = "0.9.0"
@@ -3456,19 +3359,6 @@ dependencies = [
"plotters-backend",
]
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "portable-atomic"
version = "1.15.0"
@@ -3641,24 +3531,12 @@ dependencies = [
"unarray",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-error"
version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quinn"
version = "0.11.12"
@@ -4171,7 +4049,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
dependencies = [
"fnv",
"quick-error 1.2.3",
"quick-error",
"tempfile",
"wait-timeout",
]
@@ -4788,6 +4666,17 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -5592,12 +5481,6 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "whoami"
version = "2.1.3"
@@ -5816,6 +5699,16 @@ dependencies = [
"tls_codec",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "xmlparser"
version = "0.13.6"
@@ -5976,18 +5869,3 @@ dependencies = [
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+9 -7
View File
@@ -23,10 +23,13 @@
# Fluxer
> [!IMPORTANT]
> Bug reports and feature requests have moved to [feedback.fluxer.com](https://feedback.fluxer.com). Sign in with your Fluxer account to post, vote and follow updates. GitHub Issues and Discussions are closed. Report security vulnerabilities privately through [fluxer.app/security](https://fluxer.app/security).
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
<p align="center">
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer running side by side on a desktop monitor and a phone" width="640">
<img src="https://fluxer.app/static/img/screenshots-desktop-readme-1920w.70cb6ce340007e0a.png" alt="Fluxer running side by side on a desktop monitor and a phone" width="640">
</p>
## Download
@@ -143,14 +146,13 @@ Full setup notes, including canary, are in the [Linux repositories documentation
The source is licensed under the [AGPL-3.0-or-later](./LICENSE) license.
Fluxer branding, icons, default avatars, badge artwork, screenshots and marketing
imagery are copyright Fluxer, all rights reserved, as set out in
[fluxer_static/LICENSE](./fluxer_static/LICENSE). Third-party material keeps its own
terms, listed in
Fluxer artwork, such as the logo, icons, badges and default avatars, is
licensed under [CC BY-SA 4.0](./fluxer_static/LICENSE). Third-party material
keeps its own terms, listed in
[fluxer_static/THIRD_PARTY_LICENSES.md](./fluxer_static/THIRD_PARTY_LICENSES.md).
Public availability of this repository does not grant trademark, brand, or
endorsement rights.
Use of the Fluxer name and logo is covered by the
[name and marks policy](./.github/GOVERNANCE.md#name-and-marks).
[win-setup-x64]: https://pkgs.fluxer.com/desktop/stable/win32/x64/latest/setup
[win-setup-arm64]: https://pkgs.fluxer.com/desktop/stable/win32/arm64/latest/setup
+1 -1
View File
@@ -89,6 +89,7 @@ FLUXER_ADMIN_OAUTH_REDIRECT_URI=http://localhost:8088/admin/oauth2_callback
FLUXER_SUDO_MODE_SECRET=dev-sudo-secret
FLUXER_CONNECTION_INITIATION_SECRET=dev-connection-initiation-secret
FLUXER_PROFILE_PSEUDONYM_SECRET=fluxer-dev-profile-pseudonym-secret
FLUXER_VAPID_PUBLIC_KEY=BHIbdKs24FdPkOQS7hbeg3adceLS0IqlKsn71ywEe6kbeopeFFiG3lkvJac7BVqkuk7mxwEa555O2FXV3HLt56w
FLUXER_VAPID_PRIVATE_KEY=cs24JvXSxHiqJQgkJNocJFAdzJpPmpfU9xD-fDpn3tw
FLUXER_VAPID_EMAIL=dev@localhost
@@ -111,7 +112,6 @@ FLUXER_SEARCH_USERNAME=
FLUXER_SEARCH_PASSWORD=
FLUXER_SEARCH_TLS_REJECT_UNAUTHORIZED=false
FLUXER_STRIPE_ENABLED=false
FLUXER_NCMEC_ENABLED=false
FLUXER_CLAMAV_ENABLED=false
FLUXER_DISCOVERY_ENABLED=true
FLUXER_SELF_HOSTED=true
-1
View File
@@ -94,7 +94,6 @@ skip = [
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older HTTP body API" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
{ crate = "[email protected]", reason = "transitive dependency requires the older release line" },
+28 -13
View File
@@ -138,17 +138,10 @@ MEILI_MASTER_KEY=CHANGE_ME
#FLUXER_STRIPE_PRICES={}
#FLUXER_STRIPE_LEGACY_PRICES={}
#FLUXER_API_DONATION_PROXY_KEY=
#FLUXER_API_TRUSTED_CALLERS=[]
#FLUXER_VISIONARIES_GUILD_ID=
#FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID=
# NCMEC CyberTipline reporting, off by default. All four values are required
# once it is on. The values below are examples.
#FLUXER_NCMEC_ENABLED=true
#FLUXER_NCMEC_BASE_URL=https://report.cybertip.org/ispws
#FLUXER_NCMEC_USERNAME=
#FLUXER_NCMEC_PASSWORD=
#[email protected]
# Upload virus scanning, off by default. No ClamAV container ships, so point
# this at your own. The values below are examples.
#FLUXER_CLAMAV_ENABLED=true
@@ -183,6 +176,7 @@ FLUXER_S3_SECRET_KEY=CHANGE_ME
FLUXER_SUDO_MODE_SECRET=CHANGE_ME
FLUXER_CONNECTION_INITIATION_SECRET=CHANGE_ME
FLUXER_PROFILE_PSEUDONYM_SECRET=CHANGE_ME
FLUXER_GATEWAY_RPC_AUTH_TOKEN=CHANGE_ME
FLUXER_ERLANG_COOKIE=CHANGE_ME
FLUXER_MEDIA_PROXY_SECRET_KEY=CHANGE_ME
@@ -217,6 +211,9 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
#FLUXER_PUSH_SERVICE_FCM_BASE_URL=https://fcm.googleapis.com
#FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS=push.fluxer.com
#FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS=
# Push hosts on your own network, such as a ntfy server, that may resolve to
# private addresses. Comma separated.
#FLUXER_PUSH_SERVICE_PRIVATE_HOSTS=ntfy.example.com
#FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED=false
# Direct mobile push through your own APNs and FCM credentials, off by default.
@@ -258,7 +255,7 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
# only when a browser must reach an origin the defaults do not cover. Separate
# several with spaces or commas. The three values below are illustrations.
#FLUXER_CSP_EXTRA_DEFAULT_SRC=
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com:7881
#FLUXER_CSP_EXTRA_CONNECT_SRC=wss://livekit.example.com
#FLUXER_CSP_EXTRA_IMG_SRC=https://cdn.example.com
#FLUXER_CSP_EXTRA_MEDIA_SRC=
#FLUXER_CSP_EXTRA_FONT_SRC=
@@ -305,10 +302,11 @@ FLUXER_KLIPY_API_KEY=
# Hosts the api never unfurls, comma separated.
#FLUXER_API_UNFURL_IGNORED_HOSTS=
# Email delivery. Only an instance where members sign in with email needs it.
FLUXER_EMAIL_ENABLED=false
FLUXER_EMAIL_PROVIDER=none
FLUXER_EMAIL_FROM_EMAIL=[email protected]
FLUXER_EMAIL_FROM_NAME=Fluxer
FLUXER_EMAIL_FROM_NAME=
#[email protected]
FLUXER_EMAIL_APP_BASE_URL=
FLUXER_EMAIL_SMTP_HOST=
@@ -321,7 +319,10 @@ FLUXER_EMAIL_SMTP_SECURE=true
FLUXER_DISCOVERY_ENABLED=true
#FLUXER_DISCOVERY_MIN_MEMBER_COUNT=1
# Instance identity and account policy.
# Instance identity and account policy. The terms, privacy and community
# guidelines links have no variable here. Set them in the admin panel under
# Instance Config. Until a guidelines link is set, the clients, report forms and
# enforcement emails show none.
#FLUXER_APP_PRODUCT_NAME=Fluxer
#FLUXER_APP_ICON_URL=
#FLUXER_APP_SYMBOL_URL=
@@ -332,9 +333,24 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_APP_STATUS_PAGE_URL=
#FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL=
#FLUXER_INSTANCE_SETUP_CONFIGURED=false
# How members sign in on a new instance, username or email. Unset means username. Read only on the first start.
#FLUXER_ACCOUNT_IDENTITY=
# Username tags on a new email instance. none gives unique names with no tag, random gives name#4821. Unset means none. A username instance always uses none. Read only on the first start.
#FLUXER_TAG_STYLE=
#FLUXER_AUTO_JOIN_INVITE_CODE=
#FLUXER_DELETION_GRACE_PERIOD_HOURS=336
# Report retention. A daily job deletes each report this many days after it was
# filed, with its evidence copies and search entry, unless a legal hold is set.
#FLUXER_REPORT_RETENTION_DAYS=365
# Delete resolved reports this many days after they were resolved, when that
# comes first. Unset leaves them to FLUXER_REPORT_RETENTION_DAYS.
#FLUXER_RESOLVED_REPORT_RETENTION_DAYS=
# true only logs what the job would delete. Deleted evidence cannot be
# restored, so check the "Processed report retention" log line of the worker
# in a dry run first if you are unsure.
#FLUXER_REPORT_RETENTION_DRY_RUN=false
# Sign in with Bluesky, off unless turned on.
#FLUXER_AUTH_BLUESKY_ENABLED=false
#FLUXER_AUTH_BLUESKY_CLIENT_NAME=Fluxer
@@ -357,9 +373,8 @@ FLUXER_DISCOVERY_ENABLED=true
#FLUXER_GIFT_ENDPOINT=
#FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT=
#PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=
# These follow FLUXER_STATIC_CDN_ENDPOINT first, then the public origin.
# This follows FLUXER_STATIC_CDN_ENDPOINT first, then the public origin.
#FLUXER_GATEWAY_STATIC_CDN_ENDPOINT=
#FLUXER_UNFURL_STATIC_CDN_ENDPOINT=
# These follow FLUXER_MEDIA_ENDPOINT first, then the public origin.
#FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT=
#FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT=
+1 -1
View File
@@ -42,7 +42,7 @@
reverse_proxy admin:8080
}
@staticAssets path /web/* /emoji/* /libs/* /avatars/* /badges/* /desktop/* /embeds/*
@staticAssets path /web/* /emoji/* /libs/* /avatars/* /badges/* /desktop/*
handle @staticAssets {
reverse_proxy static-proxy:8080
}
+9 -7
View File
@@ -128,14 +128,10 @@ x-fluxer-env: &fluxer-env
FLUXER_STRIPE_PRICES: ${FLUXER_STRIPE_PRICES:-}
FLUXER_STRIPE_LEGACY_PRICES: ${FLUXER_STRIPE_LEGACY_PRICES:-}
FLUXER_API_DONATION_PROXY_KEY: ${FLUXER_API_DONATION_PROXY_KEY:-}
FLUXER_API_TRUSTED_CALLERS: ${FLUXER_API_TRUSTED_CALLERS:-}
FLUXER_VISIONARIES_GUILD_ID: ${FLUXER_VISIONARIES_GUILD_ID:-}
FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID: ${FLUXER_VISIONARIES_GUILD_VISIONARY_ROLE_ID:-}
FLUXER_NCMEC_ENABLED: ${FLUXER_NCMEC_ENABLED:-}
FLUXER_NCMEC_BASE_URL: ${FLUXER_NCMEC_BASE_URL:-}
FLUXER_NCMEC_USERNAME: ${FLUXER_NCMEC_USERNAME:-}
FLUXER_NCMEC_PASSWORD: ${FLUXER_NCMEC_PASSWORD:-}
FLUXER_NCMEC_REPORTER_EMAIL: ${FLUXER_NCMEC_REPORTER_EMAIL:-}
FLUXER_CLAMAV_ENABLED: ${FLUXER_CLAMAV_ENABLED:-}
FLUXER_CLAMAV_HOST: ${FLUXER_CLAMAV_HOST:-}
FLUXER_CLAMAV_PORT: ${FLUXER_CLAMAV_PORT:-}
@@ -151,10 +147,15 @@ x-fluxer-env: &fluxer-env
FLUXER_APP_STATUS_PAGE_URL: ${FLUXER_APP_STATUS_PAGE_URL:-}
FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL: ${FLUXER_APP_STATUS_PAGE_INCIDENT_HISTORY_URL:-}
FLUXER_INSTANCE_SETUP_CONFIGURED: ${FLUXER_INSTANCE_SETUP_CONFIGURED:-}
FLUXER_ACCOUNT_IDENTITY: ${FLUXER_ACCOUNT_IDENTITY:-}
FLUXER_TAG_STYLE: ${FLUXER_TAG_STYLE:-}
FLUXER_AUTO_JOIN_INVITE_CODE: ${FLUXER_AUTO_JOIN_INVITE_CODE:-}
FLUXER_DISCOVERY_ENABLED: ${FLUXER_DISCOVERY_ENABLED:-}
FLUXER_DISCOVERY_MIN_MEMBER_COUNT: ${FLUXER_DISCOVERY_MIN_MEMBER_COUNT:-}
FLUXER_DELETION_GRACE_PERIOD_HOURS: ${FLUXER_DELETION_GRACE_PERIOD_HOURS:-}
FLUXER_REPORT_RETENTION_DAYS: ${FLUXER_REPORT_RETENTION_DAYS:-}
FLUXER_RESOLVED_REPORT_RETENTION_DAYS: ${FLUXER_RESOLVED_REPORT_RETENTION_DAYS:-}
FLUXER_REPORT_RETENTION_DRY_RUN: ${FLUXER_REPORT_RETENTION_DRY_RUN:-}
FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES: ${FLUXER_API_WORKER_LANE_CONCURRENCY_OVERRIDES:-}
FLUXER_AUTH_BLUESKY_ENABLED: ${FLUXER_AUTH_BLUESKY_ENABLED:-}
@@ -174,6 +175,7 @@ x-fluxer-env: &fluxer-env
FLUXER_SUDO_MODE_SECRET: ${FLUXER_SUDO_MODE_SECRET:?set FLUXER_SUDO_MODE_SECRET in .env}
FLUXER_CONNECTION_INITIATION_SECRET: ${FLUXER_CONNECTION_INITIATION_SECRET:?set FLUXER_CONNECTION_INITIATION_SECRET in .env}
FLUXER_PROFILE_PSEUDONYM_SECRET: ${FLUXER_PROFILE_PSEUDONYM_SECRET:?set FLUXER_PROFILE_PSEUDONYM_SECRET in .env}
FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES: ${FLUXER_SSO_ALLOW_PRIVATE_ADDRESSES:-}
FLUXER_VAPID_PUBLIC_KEY: ${FLUXER_VAPID_PUBLIC_KEY:?set FLUXER_VAPID_PUBLIC_KEY in .env}
FLUXER_VAPID_PRIVATE_KEY: ${FLUXER_VAPID_PRIVATE_KEY:?set FLUXER_VAPID_PRIVATE_KEY in .env}
@@ -611,6 +613,7 @@ services:
FLUXER_PUSH_SERVICE_FCM_BASE_URL: ${FLUXER_PUSH_SERVICE_FCM_BASE_URL:-}
FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS: ${FLUXER_PUSH_SERVICE_MANAGED_RELAY_HOSTS:-}
FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS: ${FLUXER_PUSH_SERVICE_OWN_RELAY_HOSTS:-}
FLUXER_PUSH_SERVICE_PRIVATE_HOSTS: ${FLUXER_PUSH_SERVICE_PRIVATE_HOSTS:-}
FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED: ${FLUXER_PUSH_SERVICE_RELAY_CONSENT_ACCEPTED:-}
FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT: ${FLUXER_PUSH_APNS_DEFAULT_ENVIRONMENT:-}
FLUXER_PUSH_FCM_ENABLED: ${FLUXER_PUSH_FCM_ENABLED:-}
@@ -666,6 +669,7 @@ services:
FLUXER_S3_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-}
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-}
FLUXER_MEDIA_ENDPOINT: ${FLUXER_MEDIA_ENDPOINT:-}
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-}
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
@@ -845,8 +849,6 @@ services:
FLUXER_SVC_MODE: shard
FLUXER_SVC_SHARD_ID: "0"
FLUXER_MEDIA_PROXY_ENDPOINT: http://media-proxy:8080
FLUXER_UNFURL_STATIC_CDN_ENDPOINT: ${FLUXER_UNFURL_STATIC_CDN_ENDPOINT:-}
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}}
healthcheck: *fluxer-svc-healthcheck
depends_on:
nats: {condition: service_healthy}
-1
View File
@@ -11,7 +11,6 @@ anyhow = "1.0.104"
axum = { version = "0.8.9", features = ["macros"] }
base64 = "0.23.1"
chrono = { version = "0.4", default-features = false, features = ["serde"] }
cookie = "0.18.2"
fluxer_common = { path = "../fluxer_common" }
hmac = "0.13.0"
maud = { version = "0.27.0", features = ["axum"] }
+7 -1
View File
@@ -175,7 +175,13 @@ fn relax_guild_audit_log_schemas(spec: &mut openapiv3::OpenAPI) {
}
}
const OPEN_INTEGER_ENUMS: &[&str] = &["ChannelType", "MessageType", "WebhookType"];
const OPEN_INTEGER_ENUMS: &[&str] = &[
"ChannelType",
"MessageType",
"ReportStatus",
"ReportType",
"WebhookType",
];
fn relax_integer_enums(spec: &mut openapiv3::OpenAPI) {
let components = spec.components.as_mut().expect("missing API components");
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -43,7 +43,6 @@ pub const BULK_DELETE_USERS: &str = "bulk:delete:users";
pub const BULK_DELETE_USER_MESSAGES: &str = "bulk:delete:user_messages";
pub const BULK_UPDATE_GUILD_FEATURES: &str = "bulk:update:guild_features";
pub const BULK_UPDATE_USER_FLAGS: &str = "bulk:update:user_flags";
pub const CSAM_SUBMIT_NCMEC: &str = "csam:submit_ncmec";
pub const DISCOVERY_REMOVE: &str = "discovery:remove";
pub const DISCOVERY_REVIEW: &str = "discovery:review";
pub const GATEWAY_MEMORY_STATS: &str = "gateway:memory_stats";
@@ -71,11 +70,14 @@ pub const MESSAGE_DELETE_ALL: &str = "message:delete_all";
pub const MESSAGE_DELETE: &str = "message:delete";
pub const MESSAGE_LOOKUP: &str = "message:lookup";
pub const MESSAGE_SHRED: &str = "message:shred";
pub const REPORT_DELETE: &str = "report:delete";
pub const REPORT_RESOLVE: &str = "report:resolve";
pub const REPORT_VIEW: &str = "report:view";
pub const REPORT_VIEW_REPORTER_PII: &str = "report:view:reporter_pii";
pub const SYSTEM_DM_SEND: &str = "system_dm:send";
pub const USER_CANCEL_BULK_MESSAGE_DELETION: &str = "user:cancel:bulk_message_deletion";
pub const USER_CREATE_PASSWORD_RESET_LINK: &str = "user:create:password_reset_link";
pub const USER_DELETE_RECOVERY_KIT: &str = "user:delete:recovery_kit";
pub const USER_DELETE: &str = "user:delete";
pub const USER_LIST_DM_CHANNELS: &str = "user:list:dm_channels";
pub const USER_LIST_GUILDS: &str = "user:list:guilds";
@@ -88,6 +90,7 @@ pub const USER_VIEW_DOB: &str = "user:view:dob";
pub const USER_VIEW_EMAIL: &str = "user:view:email";
pub const USER_VIEW_IP: &str = "user:view:ip";
pub const USER_TEMP_BAN: &str = "user:temp_ban";
pub const USER_UPDATE_BOT_STATUS: &str = "user:update:bot_status";
pub const USER_UPDATE_DOB: &str = "user:update:dob";
pub const USER_UPDATE_EMAIL: &str = "user:update:email";
pub const USER_UPDATE_FLAGS: &str = "user:update:flags";
@@ -147,7 +150,6 @@ pub const ALL_ACLS: &[&str] = &[
BULK_DELETE_USER_MESSAGES,
BULK_UPDATE_GUILD_FEATURES,
BULK_UPDATE_USER_FLAGS,
CSAM_SUBMIT_NCMEC,
DISCOVERY_REMOVE,
DISCOVERY_REVIEW,
GATEWAY_MEMORY_STATS,
@@ -175,11 +177,14 @@ pub const ALL_ACLS: &[&str] = &[
MESSAGE_DELETE,
MESSAGE_LOOKUP,
MESSAGE_SHRED,
REPORT_DELETE,
REPORT_RESOLVE,
REPORT_VIEW,
REPORT_VIEW_REPORTER_PII,
SYSTEM_DM_SEND,
USER_CANCEL_BULK_MESSAGE_DELETION,
USER_CREATE_PASSWORD_RESET_LINK,
USER_DELETE_RECOVERY_KIT,
USER_DELETE,
USER_LIST_DM_CHANNELS,
USER_LIST_GUILDS,
@@ -192,6 +197,7 @@ pub const ALL_ACLS: &[&str] = &[
USER_VIEW_EMAIL,
USER_VIEW_IP,
USER_TEMP_BAN,
USER_UPDATE_BOT_STATUS,
USER_UPDATE_DOB,
USER_UPDATE_EMAIL,
USER_UPDATE_FLAGS,
+34
View File
@@ -17,6 +17,7 @@ pub mod user_flag_bits {
pub const FRIENDLY_BOT: u64 = 1 << 4;
pub const FRIENDLY_BOT_MANUAL_APPROVAL: u64 = 1 << 5;
pub const SPAMMER: u64 = 1 << 6;
pub const PROFILE_HIDDEN: u64 = 1 << 7;
pub const HIGH_GLOBAL_RATE_LIMIT: u64 = 1 << 33;
pub const DELETED: u64 = 1 << 34;
pub const SELF_DELETED: u64 = 1 << 36;
@@ -58,6 +59,10 @@ pub const USER_FLAGS: &[U64Flag] = &[
name: "SPAMMER",
value: user_flag_bits::SPAMMER,
},
U64Flag {
name: "PROFILE_HIDDEN",
value: user_flag_bits::PROFILE_HIDDEN,
},
U64Flag {
name: "HIGH_GLOBAL_RATE_LIMIT",
value: user_flag_bits::HIGH_GLOBAL_RATE_LIMIT,
@@ -154,3 +159,32 @@ pub const PREMIUM_FLAGS: &[I32Flag] = &[
value: 1 << 8,
},
];
#[cfg(test)]
mod tests {
use super::USER_FLAGS;
#[test]
fn user_flags_cover_every_flag_in_the_admin_spec() {
let spec: serde_json::Value =
serde_json::from_str(include_str!("../openapi-admin.json")).expect("admin spec");
let values = spec["components"]["schemas"]["UserFlags"]["x-bitflagValues"]
.as_array()
.expect("UserFlags bitflag values");
assert!(!values.is_empty());
for entry in values {
let name = entry["name"].as_str().expect("flag name");
let value: u64 = entry["value"]
.as_str()
.expect("flag value")
.parse()
.expect("numeric flag value");
assert!(
USER_FLAGS
.iter()
.any(|flag| flag.name == name && flag.value == value),
"{name} ({value}) is missing from USER_FLAGS"
);
}
}
}
+30 -3
View File
@@ -3,7 +3,7 @@
use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
use super::types::{BanAvatarResult, BanCheckResult, BlocklistEntryPage, BulkBanResult};
impl AdminApiClient {
pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
@@ -28,11 +28,23 @@ impl AdminApiClient {
self.check_blocklist_entry("email", email, None).await
}
pub async fn ban_ip(&self, ip: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
pub async fn ban_ip(
&self,
ip: &str,
duration_hours: u32,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.create_blocklist_entry(
"ip",
generated_types::AdminBlocklistEntryCreateRequest::from(
generated_types::BanIpRequest { ip: ip.to_owned() },
generated_types::BanIpRequest {
duration_hours: Some(
i32::try_from(duration_hours)
.map_err(|e| ApiError::Parse(e.to_string()))?
.into(),
),
ip: ip.to_owned(),
},
),
audit_log_reason,
)
@@ -136,6 +148,19 @@ impl AdminApiClient {
self.check_blocklist_entry("url-domain", domain, None).await
}
pub async fn list_url_domain_entries(
&self,
after: Option<&str>,
) -> ApiResult<BlocklistEntryPage> {
let list_type = blocklist_list_type("url-domain")?;
let response = self
.generated()
.list_admin_blocklist_entries(list_type, after, Some(BLOCKLIST_PAGE_SIZE), None)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn ban_file_sha(
&self,
sha256_hex: &str,
@@ -323,6 +348,8 @@ impl AdminApiClient {
const PROFILE_SUBSTRING_LIST: &str = "profile-substring";
const BLOCKLIST_PAGE_SIZE: &str = "200";
fn blocklist_list_type(list_type: &str) -> ApiResult<generated_types::AdminBlocklistListType> {
generated_types::AdminBlocklistListType::try_from(list_type)
.map_err(|e| ApiError::Parse(e.to_string()))
+25
View File
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::snowflake;
use super::client::{AdminApiClient, ApiResult};
use super::types::ListGuildThreadsResponse;
impl AdminApiClient {
pub async fn list_guild_threads(&self, guild_id: &str) -> ApiResult<ListGuildThreadsResponse> {
let response = self
.generated()
.list_admin_guild_threads(&snowflake(guild_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn delete_thread_channel(&self, channel_id: &str) -> ApiResult<()> {
self.generated()
.delete_admin_thread_channel(&snowflake(channel_id))
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
}
+3 -3
View File
@@ -337,9 +337,9 @@ fn guild_update_response(
.map_err(ApiError::Parse)?,
features: guild.features.into_iter().map(String::from).collect(),
nsfw_level: guild.nsfw_level.map(i32::from),
nsfw: guild.nsfw,
content_warning_level: guild.content_warning_level.map(i32::from),
content_warning_text: guild.content_warning_text.map(String::from),
nsfw: None,
content_warning_level: None,
content_warning_text: None,
description: None,
vanity_url_code: None,
},
+13 -3
View File
@@ -2,9 +2,9 @@
use super::client::{AdminApiClient, ApiResult};
use super::types::{
CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceConfigResponse,
InstanceConfigUpdateRequest, InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse,
InstancePremiumDiscovery,
AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse,
InstanceAccountIdentityDiscovery, InstanceConfigResponse, InstanceConfigUpdateRequest,
InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse, InstancePremiumDiscovery,
};
impl AdminApiClient {
@@ -16,6 +16,16 @@ impl AdminApiClient {
self.get("/.well-known/fluxer", None).await
}
pub async fn get_instance_account_identity(&self) -> ApiResult<AccountIdentitySettings> {
let discovery: InstanceAccountIdentityDiscovery =
self.get("/.well-known/fluxer", None).await?;
let mode = discovery.features.account_identity;
Ok(AccountIdentitySettings {
mode,
tag_style: discovery.features.tag_style,
})
}
pub async fn update_instance_config(
&self,
update: &InstanceConfigUpdateRequest,
+1 -10
View File
@@ -3,7 +3,7 @@
use crate::api::generated::snowflake;
use super::client::{AdminApiClient, ApiResult};
use super::types::{ActiveJobsResponse, CancelJobResponse, GetJobResponse, ListJobsResponse};
use super::types::{CancelJobResponse, GetJobResponse, ListJobsResponse};
pub struct ListJobsParams {
pub limit: u32,
@@ -59,15 +59,6 @@ impl AdminApiClient {
)
.await
}
pub async fn list_active_jobs(&self) -> ApiResult<ActiveJobsResponse> {
let response = self
.generated()
.list_admin_active_jobs()
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
}
fn cursor_field<'a>(cursor: Option<&'a serde_json::Value>, field: &str) -> &'a str {
+1 -33
View File
@@ -5,8 +5,7 @@ use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
BrowseChannelResponse, DeleteAllUserMessagesResponse, LookupMessageResponse,
MessageShredResponse, MessageShredStatusResponse, NcmecAttachmentSubmitResult,
SearchChannelMessagesResponse,
MessageShredResponse, MessageShredStatusResponse, SearchChannelMessagesResponse,
};
impl AdminApiClient {
@@ -30,37 +29,6 @@ impl AdminApiClient {
Ok(())
}
pub async fn report_attachment_to_ncmec(
&self,
channel_id: &str,
message_id: &str,
attachment_id: &str,
filename: &str,
reporter_full_name: &str,
source_report_id: Option<&str>,
) -> ApiResult<NcmecAttachmentSubmitResult> {
let body = generated_types::ReportAttachmentToNcmecRequest {
attachment_id: snowflake(attachment_id),
channel_id: snowflake(channel_id),
confirmed_viewed: true,
filename: generated_types::ReportAttachmentToNcmecRequestFilename::try_from(filename)
.map_err(|e| ApiError::Parse(e.to_string()))?,
message_id: snowflake(message_id),
reporter_full_name:
generated_types::ReportAttachmentToNcmecRequestReporterFullName::try_from(
reporter_full_name,
)
.map_err(|e| ApiError::Parse(e.to_string()))?,
source_report_id: source_report_id.map(snowflake),
};
let response = self
.generated()
.create_admin_ncmec_report(&body)
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn lookup_message(
&self,
channel_id: &str,
+1
View File
@@ -13,6 +13,7 @@ pub mod client;
pub mod codes;
pub mod discovery;
pub mod guild_assets;
pub mod guild_threads;
pub mod guilds;
pub mod instance_config;
pub mod jobs;
+50 -20
View File
@@ -1,10 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::api::generated::snowflake;
use crate::api::generated::{snowflake, types::UpdateReportRequestResolution};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
ListReportsResponse, ReportEntry, ResolveReportResponse, SearchReportsResponse,
ReportEntry, ReportLegalHoldResponse, ReportReasonListResponse, ResolveReportResponse,
SearchReportsResponse,
};
#[derive(Default)]
@@ -13,8 +14,10 @@ pub struct SearchReportsParams<'a> {
pub status: Option<i32>,
pub report_type: Option<i32>,
pub category: Option<&'a str>,
pub reason: Option<&'a str>,
pub reporter_id: Option<&'a str>,
pub reported_user_id: Option<&'a str>,
pub reported_webhook_id: Option<&'a str>,
pub reported_guild_id: Option<&'a str>,
pub reported_channel_id: Option<&'a str>,
pub guild_context_id: Option<&'a str>,
@@ -26,23 +29,6 @@ pub struct SearchReportsParams<'a> {
}
impl AdminApiClient {
pub async fn list_reports(
&self,
status: Option<i32>,
limit: u32,
offset: Option<u32>,
) -> ApiResult<ListReportsResponse> {
let status = status.map(report_status).transpose()?.unwrap_or_default();
let limit = limit.to_string();
let offset = offset.map(|value| value.to_string()).unwrap_or_default();
let query_params = [
("status", status),
("limit", limit.as_str()),
("offset", offset.as_str()),
];
self.get("/admin/reports", Some(&query_params)).await
}
pub async fn get_report(&self, report_id: &str) -> ApiResult<ReportEntry> {
let response = self
.generated()
@@ -55,11 +41,12 @@ impl AdminApiClient {
pub async fn resolve_report(
&self,
report_id: &str,
resolution: UpdateReportRequestResolution,
public_comment: Option<&str>,
notify_reporter: bool,
audit_log_reason: Option<&str>,
) -> ApiResult<ResolveReportResponse> {
let mut body = serde_json::json!({"status": "resolved"});
let mut body = serde_json::json!({"status": "resolved", "resolution": resolution});
if let Some(public_comment) = public_comment {
body["public_comment"] = serde_json::Value::from(public_comment);
}
@@ -72,6 +59,40 @@ impl AdminApiClient {
.await
}
pub async fn set_report_legal_hold(
&self,
report_id: &str,
legal_hold_until: Option<&str>,
legal_hold_reason: Option<&str>,
) -> ApiResult<ReportLegalHoldResponse> {
let body = serde_json::json!({
"legal_hold_until": legal_hold_until,
"legal_hold_reason": legal_hold_until.and(legal_hold_reason),
});
self.post_with_reason(
&format!(
"/admin/reports/{}/legal-hold",
urlencoding::encode(report_id)
),
Some(&body),
None,
)
.await
}
pub async fn delete_report(
&self,
report_id: &str,
audit_log_reason: Option<&str>,
) -> ApiResult<()> {
self.delete_void_with_reason(
&format!("/admin/reports/{}", urlencoding::encode(report_id)),
None,
audit_log_reason,
)
.await
}
pub async fn search_reports(
&self,
params: &SearchReportsParams<'_>,
@@ -98,11 +119,16 @@ impl AdminApiClient {
("status", status),
("report_type", report_type),
("category", params.category.unwrap_or_default()),
("reason", params.reason.unwrap_or_default()),
("reporter_id", params.reporter_id.unwrap_or_default()),
(
"reported_user_id",
params.reported_user_id.unwrap_or_default(),
),
(
"reported_webhook_id",
params.reported_webhook_id.unwrap_or_default(),
),
(
"reported_guild_id",
params.reported_guild_id.unwrap_or_default(),
@@ -127,6 +153,10 @@ impl AdminApiClient {
self.get("/admin/reports", Some(&query_params)).await
}
pub async fn list_report_reasons(&self) -> ApiResult<ReportReasonListResponse> {
self.get("/admin/report-reasons", None).await
}
pub async fn search_reports_by_reporter(
&self,
reporter_id: &str,
+21
View File
@@ -255,9 +255,30 @@ pub enum FlashLevel {
pub struct BanCheckResult {
pub banned: bool,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub entries: Vec<serde_json::Value>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BlocklistEntry {
pub value: String,
#[serde(default)]
pub match_subdomains: Option<bool>,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub created_at: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BlocklistEntryPage {
pub items: Vec<BlocklistEntry>,
pub has_more: bool,
#[serde(default)]
pub next_after: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BulkBanResult {
pub job_id: String,
@@ -0,0 +1,35 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct GuildThreadMetadata {
pub archived: bool,
pub locked: bool,
pub auto_archive_duration: i32,
pub archive_timestamp: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct GuildThreadItem {
pub id: String,
#[serde(rename = "type")]
pub channel_type: i32,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub parent_id: Option<String>,
#[serde(default)]
pub owner_id: Option<String>,
#[serde(default)]
pub member_count: Option<i32>,
#[serde(default)]
pub message_count: Option<i32>,
#[serde(default)]
pub thread_metadata: Option<GuildThreadMetadata>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ListGuildThreadsResponse {
pub threads: Vec<GuildThreadItem>,
}
+79 -85
View File
@@ -13,6 +13,8 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub self_hosted: bool,
#[serde(default)]
pub account_identity: AccountIdentityConfigResponse,
#[serde(default)]
pub app_public: AppPublicConfigResponse,
#[serde(default)]
pub policy: InstancePolicyResponse,
@@ -25,8 +27,6 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub domain_migration: DomainMigrationConfigResponse,
#[serde(default)]
pub plutonium_page: PlutoniumPageConfigResponse,
#[serde(default)]
pub captcha: CaptchaConfigResponse,
#[serde(default)]
pub experiment_delivery: ExperimentDeliveryConfigResponse,
@@ -34,6 +34,79 @@ pub struct InstanceConfigResponse {
pub billing: InstanceBillingResponse,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AccountIdentityMode {
#[default]
Email,
Username,
}
impl AccountIdentityMode {
pub fn is_username(self) -> bool {
matches!(self, Self::Username)
}
pub fn label(self) -> &'static str {
match self {
Self::Email => "Email",
Self::Username => "Username",
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum TagStyle {
None,
#[default]
#[serde(other)]
Random,
}
impl TagStyle {
pub fn is_none(self) -> bool {
matches!(self, Self::None)
}
pub fn label(self) -> &'static str {
match self {
Self::None => "No tags",
Self::Random => "Random tags",
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
pub struct AccountIdentityConfigResponse {
#[serde(default)]
pub mode: AccountIdentityMode,
#[serde(default)]
pub locked: Option<bool>,
#[serde(default)]
pub tag_style: TagStyle,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct AccountIdentitySettings {
pub mode: AccountIdentityMode,
pub tag_style: TagStyle,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstanceAccountIdentityDiscovery {
#[serde(default)]
pub features: InstanceAccountIdentityDiscoveryFeatures,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstanceAccountIdentityDiscoveryFeatures {
#[serde(default)]
pub account_identity: AccountIdentityMode,
#[serde(default)]
pub tag_style: TagStyle,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InstancePolicyResponse {
#[serde(default)]
@@ -344,6 +417,8 @@ pub struct AppSetupConfigResponse {
pub struct AppLegalConfigResponse {
pub terms_url: Option<String>,
pub privacy_url: Option<String>,
#[serde(default)]
pub guidelines_url: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -432,7 +507,6 @@ impl VoiceE2eeScope {
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
pub const PLUTONIUM_PAGE_DEFAULT_SALT: &str = "plutonium-page-v1";
pub const CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=20_000;
pub const CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=20_000;
@@ -504,52 +578,6 @@ pub struct DomainMigrationConfigUpdateRequest {
pub standalone_forwarding: Option<bool>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct PlutoniumPageConfigResponse {
pub enabled: bool,
pub config_version: u64,
pub rollout_basis_points: u32,
pub rollout_salt: String,
pub included_user_ids: Vec<String>,
pub included_guild_ids: Vec<String>,
pub include_premium_users: bool,
pub excluded_user_ids: Vec<String>,
}
impl Default for PlutoniumPageConfigResponse {
fn default() -> Self {
Self {
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: PLUTONIUM_PAGE_DEFAULT_SALT.to_owned(),
included_user_ids: Vec::new(),
included_guild_ids: Vec::new(),
include_premium_users: false,
excluded_user_ids: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct PlutoniumPageConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_basis_points: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_salt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_user_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub included_guild_ids: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub include_premium_users: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub excluded_user_ids: Option<Vec<String>>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(default)]
pub struct CaptchaConfigResponse {
@@ -696,8 +724,6 @@ pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub plutonium_page: Option<PlutoniumPageConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub captcha: Option<CaptchaConfigUpdateRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
@@ -903,6 +929,8 @@ pub struct AppLegalConfigUpdateRequest {
pub terms_url: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub privacy_url: Option<Option<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub guidelines_url: Option<Option<String>>,
}
#[derive(Clone, Debug, Default, Serialize)]
@@ -1000,24 +1028,17 @@ mod tests {
.expect("admin schema");
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
.expect("default domain migration config");
let plutonium_page = serde_json::from_value::<PlutoniumPageConfigResponse>(json!({}))
.expect("default plutonium page config");
let captcha = serde_json::from_value::<CaptchaConfigResponse>(json!({}))
.expect("default captcha config");
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
.expect("default delivery config");
let domain_migration =
serde_json::to_value(domain_migration).expect("serializable domain migration config");
let plutonium_page =
serde_json::to_value(plutonium_page).expect("serializable plutonium page config");
let captcha = serde_json::to_value(captcha).expect("serializable captcha config");
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
serde_json::from_value(domain_migration.clone())
.expect("generated domain migration config contract");
let generated_plutonium_page: generated_types::PlutoniumPageConfigResponse =
serde_json::from_value(plutonium_page.clone())
.expect("generated plutonium page config contract");
let generated_captcha: generated_types::CaptchaConfigResponse =
serde_json::from_value(captcha.clone()).expect("generated captcha config contract");
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
@@ -1027,11 +1048,6 @@ mod tests {
.expect("serializable generated domain migration config"),
domain_migration
);
assert_eq!(
serde_json::to_value(generated_plutonium_page)
.expect("serializable generated plutonium page config"),
plutonium_page
);
assert_eq!(
serde_json::to_value(generated_captcha).expect("serializable generated captcha config"),
captcha
@@ -1043,7 +1059,6 @@ mod tests {
);
for (name, value) in [
("DomainMigrationConfigResponse", domain_migration),
("PlutoniumPageConfigResponse", plutonium_page),
("CaptchaConfigResponse", captcha),
("ExperimentDeliveryConfigResponse", delivery),
] {
@@ -1078,25 +1093,4 @@ mod tests {
json!({})
);
}
#[test]
fn plutonium_page_update_preserves_empty_lists_and_omitted_fields() {
let update = PlutoniumPageConfigUpdateRequest {
included_user_ids: Some(Vec::new()),
excluded_user_ids: Some(Vec::new()),
..Default::default()
};
let value = serde_json::to_value(update).expect("serializable update");
serde_json::from_value::<generated_types::PlutoniumPageConfigUpdateRequest>(value.clone())
.expect("generated update contract");
assert_eq!(
value,
json!({"included_user_ids": [], "excluded_user_ids": []})
);
assert_eq!(
serde_json::to_value(PlutoniumPageConfigUpdateRequest::default())
.expect("serializable update"),
json!({})
);
}
}
-5
View File
@@ -21,8 +21,3 @@ pub struct GetJobResponse {
pub struct CancelJobResponse {
pub cancelled: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ActiveJobsResponse {
pub jobs: Vec<serde_json::Value>,
}
-6
View File
@@ -34,12 +34,6 @@ pub struct DeleteAllUserMessagesResponse {
pub extra: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct NcmecAttachmentSubmitResult {
#[serde(flatten)]
pub data: serde_json::Value,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct BrowseChannelResponse {
#[serde(flatten)]
+2
View File
@@ -9,6 +9,7 @@ mod codes;
mod common;
mod discovery;
mod guild_assets;
mod guild_threads;
mod instance_billing;
mod instance_config;
mod jobs;
@@ -29,6 +30,7 @@ pub use codes::*;
pub use common::*;
pub use discovery::*;
pub use guild_assets::*;
pub use guild_threads::*;
pub use instance_billing::*;
pub use instance_config::*;
pub use jobs::*;
+180 -5
View File
@@ -98,6 +98,38 @@ pub struct ReportEntry {
pub reported_user_global_name: Option<String>,
pub reported_user_discriminator: Option<String>,
pub reported_user_avatar_hash: Option<String>,
#[serde(default)]
pub reported_webhook_id: Option<String>,
#[serde(default)]
pub reported_webhook_name: Option<String>,
#[serde(default)]
pub reported_webhook_avatar_hash: Option<String>,
#[serde(default)]
pub reported_webhook_default_name: Option<String>,
#[serde(default)]
pub reported_webhook_default_avatar_hash: Option<String>,
#[serde(default)]
pub reported_webhook_type: Option<i32>,
#[serde(default)]
pub reported_webhook_application_id: Option<String>,
#[serde(default)]
pub reported_webhook_channel_id: Option<String>,
#[serde(default)]
pub reported_webhook_guild_id: Option<String>,
#[serde(default)]
pub reported_webhook_created_at: Option<String>,
#[serde(default)]
pub reported_webhook_creator_id: Option<String>,
#[serde(default)]
pub reported_webhook_creator_tag: Option<String>,
#[serde(default)]
pub reported_webhook_creator_username: Option<String>,
#[serde(default)]
pub reported_webhook_creator_global_name: Option<String>,
#[serde(default)]
pub reported_webhook_creator_discriminator: Option<String>,
#[serde(default)]
pub reported_webhook_creator_avatar_hash: Option<String>,
pub reported_guild_id: Option<String>,
pub reported_guild_name: Option<String>,
pub reported_guild_icon_hash: Option<String>,
@@ -121,6 +153,148 @@ pub struct ReportEntry {
pub public_comment: Option<String>,
pub mutual_dm_channel_id: Option<String>,
pub message_context: Option<Vec<serde_json::Value>>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub reason_label: Option<String>,
#[serde(default)]
pub reason_highest_priority: Option<bool>,
#[serde(default)]
pub flow: Option<ReportFlowAnswersEntry>,
#[serde(default)]
pub reporter_good_faith_confirmed: Option<bool>,
#[serde(default)]
pub reported_user_bot: Option<bool>,
#[serde(default)]
pub reported_profile_snapshot: Option<ReportProfileSnapshot>,
#[serde(default)]
pub legal_hold_until: Option<String>,
#[serde(default)]
pub legal_hold_reason: Option<String>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct ReportProfileSnapshot {
#[serde(default)]
pub captured_at: Option<String>,
#[serde(default)]
pub user: Option<ReportProfileSnapshotUser>,
#[serde(default)]
pub member: Option<ReportProfileSnapshotMember>,
#[serde(default)]
pub guild: Option<ReportProfileSnapshotGuild>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct ReportProfileSnapshotUser {
pub id: String,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub discriminator: Option<String>,
#[serde(default)]
pub global_name: Option<String>,
#[serde(default)]
pub bio: Option<String>,
#[serde(default)]
pub pronouns: Option<String>,
#[serde(default)]
pub avatar: Option<ReportProfileSnapshotAsset>,
#[serde(default)]
pub banner: Option<ReportProfileSnapshotAsset>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct ReportProfileSnapshotMember {
pub guild_id: String,
#[serde(default)]
pub nick: Option<String>,
#[serde(default)]
pub bio: Option<String>,
#[serde(default)]
pub pronouns: Option<String>,
#[serde(default)]
pub joined_at: Option<String>,
#[serde(default)]
pub avatar: Option<ReportProfileSnapshotAsset>,
#[serde(default)]
pub banner: Option<ReportProfileSnapshotAsset>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct ReportProfileSnapshotGuild {
pub id: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub vanity_url_code: Option<String>,
#[serde(default)]
pub icon: Option<ReportProfileSnapshotAsset>,
#[serde(default)]
pub banner: Option<ReportProfileSnapshotAsset>,
#[serde(default)]
pub splash: Option<ReportProfileSnapshotAsset>,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct ReportProfileSnapshotAsset {
pub hash: String,
#[serde(default)]
pub url: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ReportLegalHoldResponse {
pub report_id: String,
pub legal_hold_until: Option<String>,
pub legal_hold_reason: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ReportFlowAnswersEntry {
pub revision_hash: String,
pub surface: String,
#[serde(default)]
pub locale: Option<String>,
#[serde(default)]
pub steps: Vec<ReportFlowAnswerStepEntry>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ReportFlowAnswerStepEntry {
pub screen_id: String,
pub screen_title: String,
#[serde(default)]
pub option_id: Option<String>,
#[serde(default)]
pub option_label: Option<String>,
#[serde(default)]
pub items: Vec<ReportFlowAnswerItemEntry>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ReportFlowAnswerItemEntry {
pub id: String,
pub label: String,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ReportReasonEntry {
pub key: String,
pub label: String,
#[serde(default)]
pub highest_priority: bool,
#[serde(default)]
pub legacy_category_message: Option<String>,
#[serde(default)]
pub legacy_category_user: Option<String>,
#[serde(default)]
pub legacy_category_guild: Option<String>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ReportReasonListResponse {
pub reasons: Vec<ReportReasonEntry>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -131,11 +305,6 @@ pub struct SearchReportsResponse {
pub limit: u64,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ListReportsResponse {
pub reports: Vec<ReportEntry>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ResolveReportResponse {
pub report_id: String,
@@ -232,3 +401,9 @@ pub struct WebAuthnCredential {
}
pub type WebAuthnCredentialListResponse = Vec<WebAuthnCredential>;
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PasswordResetLinkResponse {
pub url: String,
pub expires_at: String,
}
+44 -1
View File
@@ -5,7 +5,8 @@ use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
AdminUser, AdminUserMeResponse, GuildInfo, ListUserGuildsResponse, LookupUserResponse,
SearchUsersResponse, TerminateSessionsResponse, UserMutationResponse,
PasswordResetLinkResponse, SearchUsersResponse, TerminateSessionsResponse,
UserMutationResponse,
};
impl AdminApiClient {
@@ -305,6 +306,28 @@ impl AdminApiClient {
Ok(resp.user)
}
pub async fn set_bot_status(&self, user_id: &str, is_bot: bool) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserBotStatusRequest { bot: is_bot };
let response = self
.generated()
.set_admin_user_bot_status(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
Ok(resp.user)
}
pub async fn set_system_status(&self, user_id: &str, is_system: bool) -> ApiResult<AdminUser> {
let body = generated_types::AdminUserSystemStatusRequest { system: is_system };
let response = self
.generated()
.set_admin_user_system_status(&snowflake(user_id), &body)
.await
.map_err(|e| self.generated_error(e))?;
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
Ok(resp.user)
}
pub async fn change_username(
&self,
user_id: &str,
@@ -473,6 +496,26 @@ impl AdminApiClient {
Ok(())
}
pub async fn create_password_reset_link(
&self,
user_id: &str,
) -> ApiResult<PasswordResetLinkResponse> {
let response = self
.generated()
.create_admin_user_password_reset_link(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn revoke_recovery_kit(&self, user_id: &str) -> ApiResult<()> {
self.generated()
.revoke_admin_user_recovery_kit(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn remove_relationship(
&self,
user_id: &str,
+311 -2
View File
@@ -1,11 +1,12 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use fluxer_common::config::{
normalize_base_path, normalize_public_endpoint_from_env, read_bool_env, read_env,
env_value, normalize_base_path, normalize_public_endpoint_from_env, read_bool_env, read_env,
read_first_env, trim_trailing_slash,
};
const DEFAULT_ADMIN_OAUTH_CLIENT_ID: &str = "1234567890123456789";
const DEFAULT_REPORTS_BUCKET_ORIGIN: &str = "https://fluxer-reports.ewr1.vultrobjects.com";
#[derive(Clone, Debug)]
pub struct AdminConfig {
@@ -17,6 +18,7 @@ pub struct AdminConfig {
pub api_endpoint: String,
pub media_endpoint: String,
pub static_cdn_endpoint: String,
pub reports_bucket_origin: String,
pub admin_endpoint: String,
pub web_app_endpoint: String,
pub oauth_client_id: String,
@@ -76,6 +78,7 @@ impl AdminConfig {
static_cdn_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(
&read_env("FLUXER_STATIC_CDN_ENDPOINT", ""),
)),
reports_bucket_origin: reports_bucket_origin_from_env(),
admin_endpoint,
web_app_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
@@ -120,6 +123,72 @@ impl AdminConfig {
}
}
fn reports_bucket_origin_from_env() -> String {
let public_endpoint = env_value("FLUXER_S3_PUBLIC_ENDPOINT")
.map(|value| normalize_public_endpoint_from_env(value.trim()));
let endpoint = env_value("FLUXER_S3_ENDPOINT");
presign_endpoint(
public_endpoint.as_deref(),
endpoint.as_deref(),
&read_env("FLUXER_S3_BUCKET_UPLOADS", "fluxer-uploads"),
)
.and_then(|endpoint| {
bucket_origin(
&endpoint,
read_bool_env("FLUXER_S3_FORCE_PATH_STYLE", false),
&read_env("FLUXER_S3_BUCKET_REPORTS", "fluxer-reports"),
)
})
.unwrap_or_else(|| DEFAULT_REPORTS_BUCKET_ORIGIN.to_owned())
}
fn presign_endpoint(
public_endpoint: Option<&str>,
endpoint: Option<&str>,
uploads_bucket: &str,
) -> Option<url::Url> {
let Some(public_endpoint) = public_endpoint else {
return url::Url::parse(endpoint?.trim()).ok();
};
let mut parsed = url::Url::parse(public_endpoint).ok()?;
let host = parsed.host_str()?.to_owned();
if let Some(shared_host) = host.strip_prefix(&format!("{uploads_bucket}.")) {
parsed.set_host(Some(shared_host)).ok()?;
}
Some(parsed)
}
fn bucket_origin(endpoint: &url::Url, force_path_style: bool, bucket: &str) -> Option<String> {
if !matches!(endpoint.scheme(), "http" | "https") {
return None;
}
let path_style = force_path_style
|| !matches!(endpoint.host(), Some(url::Host::Domain(_)))
|| !is_virtual_hostable_bucket(bucket, endpoint.scheme() == "http");
if path_style {
return Some(endpoint.origin().ascii_serialization());
}
let mut virtual_host = endpoint.clone();
virtual_host
.set_host(Some(&format!("{bucket}.{}", endpoint.host_str()?)))
.ok()?;
Some(virtual_host.origin().ascii_serialization())
}
fn is_virtual_hostable_bucket(bucket: &str, allow_dots: bool) -> bool {
if allow_dots && bucket.contains('.') {
return bucket
.split('.')
.all(|label| is_virtual_hostable_bucket(label, false));
}
(3..=63).contains(&bucket.len())
&& bucket
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
&& !bucket.starts_with('-')
&& !bucket.ends_with('-')
}
impl RuntimeEnv {
pub(crate) fn from_env_value(value: &str) -> Self {
match value {
@@ -138,7 +207,7 @@ mod tests {
static ENV_LOCK: Mutex<()> = Mutex::new(());
const MANAGED_ENV: [&str; 10] = [
const MANAGED_ENV: [&str; 15] = [
"FLUXER_ENV",
"FLUXER_ADMIN_HOST",
"FLUXER_ADMIN_PORT",
@@ -149,6 +218,11 @@ mod tests {
"FLUXER_MEDIA_ENDPOINT",
"FLUXER_STATIC_CDN_ENDPOINT",
"FLUXER_BASE_DOMAIN",
"FLUXER_S3_ENDPOINT",
"FLUXER_S3_PUBLIC_ENDPOINT",
"FLUXER_S3_FORCE_PATH_STYLE",
"FLUXER_S3_BUCKET_UPLOADS",
"FLUXER_S3_BUCKET_REPORTS",
];
fn config_from_env(vars: &[(&str, &str)]) -> AdminConfig {
@@ -238,6 +312,7 @@ mod tests {
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
@@ -266,6 +341,7 @@ mod tests {
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
@@ -368,4 +444,237 @@ mod tests {
format!("{api_admin_endpoint}/oauth2_callback")
);
}
fn origin_for(endpoint: &str, force_path_style: bool, bucket: &str) -> Option<String> {
bucket_origin(
&url::Url::parse(endpoint).expect("valid endpoint"),
force_path_style,
bucket,
)
}
#[test]
fn bucket_origin_matches_the_addressing_of_presigned_urls() {
let cases = [
(
"https://ewr1.vultrobjects.com",
false,
"fluxer-reports",
"https://fluxer-reports.ewr1.vultrobjects.com",
),
(
"https://ewr1.vultrobjects.com/",
true,
"fluxer-reports",
"https://ewr1.vultrobjects.com",
),
(
"http://seaweedfs:8333",
true,
"fluxer-reports",
"http://seaweedfs:8333",
),
(
"http://seaweedfs:8333",
false,
"fluxer-reports",
"http://fluxer-reports.seaweedfs:8333",
),
(
"http://127.0.0.1:8333",
false,
"fluxer-reports",
"http://127.0.0.1:8333",
),
(
"http://[::1]:8333",
false,
"fluxer-reports",
"http://[::1]:8333",
),
(
"https://s3.example.com:9000",
false,
"fluxer-reports",
"https://fluxer-reports.s3.example.com:9000",
),
(
"https://s3.example.com:443/base/path",
false,
"fluxer-reports",
"https://fluxer-reports.s3.example.com",
),
(
"https://S3.Example.com",
false,
"fluxer-reports",
"https://fluxer-reports.s3.example.com",
),
(
"https://s3.example.com",
false,
"reports.example",
"https://s3.example.com",
),
(
"http://s3.example.com",
false,
"reports.example",
"http://reports.example.s3.example.com",
),
(
"http://s3.example.com",
false,
"a.example",
"http://s3.example.com",
),
(
"https://s3.example.com",
false,
"Reports",
"https://s3.example.com",
),
(
"https://s3.example.com",
false,
"ab",
"https://s3.example.com",
),
(
"https://s3.example.com",
false,
"reports_bucket",
"https://s3.example.com",
),
(
"https://s3.example.com",
false,
"-reports",
"https://s3.example.com",
),
(
"https://s3.example.com",
false,
"192.168.1.1",
"https://s3.example.com",
),
];
for (endpoint, force_path_style, bucket, expected) in cases {
assert_eq!(
origin_for(endpoint, force_path_style, bucket).as_deref(),
Some(expected),
"{endpoint} {force_path_style} {bucket}"
);
}
assert_eq!(origin_for("ftp://s3.example.com", true, "reports"), None);
}
#[test]
fn presign_endpoint_prefers_the_public_endpoint_and_drops_the_uploads_bucket_host() {
let host = |public: Option<&str>, endpoint: Option<&str>| {
presign_endpoint(public, endpoint, "fluxer-uploads").map(|url| url.to_string())
};
assert_eq!(
host(
Some("https://fluxer-uploads.ewr1.vultrobjects.com"),
Some("https://internal.example")
)
.as_deref(),
Some("https://ewr1.vultrobjects.com/")
);
assert_eq!(
host(
Some("https://cdn.example.com"),
Some("http://seaweedfs:8333")
)
.as_deref(),
Some("https://cdn.example.com/")
);
assert_eq!(
host(None, Some("http://seaweedfs:8333")).as_deref(),
Some("http://seaweedfs:8333/")
);
assert_eq!(host(Some("not a url"), Some("http://seaweedfs:8333")), None);
assert_eq!(host(None, None), None);
}
#[test]
fn the_reports_bucket_origin_defaults_to_the_hosted_bucket() {
let config = config_from_env(&[]);
assert_eq!(config.reports_bucket_origin, DEFAULT_REPORTS_BUCKET_ORIGIN);
let config = config_from_env(&[("FLUXER_S3_ENDPOINT", "not a url")]);
assert_eq!(config.reports_bucket_origin, DEFAULT_REPORTS_BUCKET_ORIGIN);
}
#[test]
fn the_reports_bucket_origin_follows_the_object_store_settings() {
let hosted = config_from_env(&[
("FLUXER_S3_ENDPOINT", "https://ewr1.vultrobjects.com"),
(
"FLUXER_S3_PUBLIC_ENDPOINT",
"https://fluxer-uploads.ewr1.vultrobjects.com",
),
]);
assert_eq!(hosted.reports_bucket_origin, DEFAULT_REPORTS_BUCKET_ORIGIN);
let bundled = config_from_env(&[
("FLUXER_S3_ENDPOINT", "http://seaweedfs:8333"),
(
"FLUXER_S3_PUBLIC_ENDPOINT",
"https://objects.fluxer.example",
),
("FLUXER_S3_FORCE_PATH_STYLE", "true"),
]);
assert_eq!(
bundled.reports_bucket_origin,
"https://objects.fluxer.example"
);
let internal_only = config_from_env(&[
("FLUXER_S3_ENDPOINT", "http://seaweedfs:8333"),
("FLUXER_S3_FORCE_PATH_STYLE", "true"),
]);
assert_eq!(internal_only.reports_bucket_origin, "http://seaweedfs:8333");
let outside = config_from_env(&[
(
"FLUXER_S3_ENDPOINT",
"https://s3.eu-central-1.amazonaws.com",
),
("FLUXER_S3_FORCE_PATH_STYLE", "false"),
("FLUXER_S3_BUCKET_UPLOADS", "example-uploads"),
("FLUXER_S3_BUCKET_REPORTS", "example-reports"),
]);
assert_eq!(
outside.reports_bucket_origin,
"https://example-reports.s3.eu-central-1.amazonaws.com"
);
let renamed_uploads = config_from_env(&[
("FLUXER_S3_ENDPOINT", "https://s3.example.com"),
(
"FLUXER_S3_PUBLIC_ENDPOINT",
"https://example-uploads.s3.example.com",
),
("FLUXER_S3_BUCKET_UPLOADS", "example-uploads"),
("FLUXER_S3_BUCKET_REPORTS", "example-reports"),
]);
assert_eq!(
renamed_uploads.reports_bucket_origin,
"https://example-reports.s3.example.com"
);
}
#[test]
fn a_non_default_public_port_reaches_the_reports_bucket_origin() {
let config = config_from_env(&[
("FLUXER_BASE_DOMAIN", "fluxer.example"),
("FLUXER_PUBLIC_PORT", "19080"),
("FLUXER_S3_ENDPOINT", "http://seaweedfs:8333"),
("FLUXER_S3_PUBLIC_ENDPOINT", "http://fluxer.example"),
("FLUXER_S3_FORCE_PATH_STYLE", "true"),
]);
assert_eq!(config.reports_bucket_origin, "http://fluxer.example:19080");
}
}
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::client::AdminApiClient, middleware::auth::AuthContext, state::AppState,
utils::user_tag::with_unique_usernames,
};
use axum::{
extract::{Request, State},
middleware::Next,
response::Response,
};
pub async fn scope_account_identity(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
let Some(auth) = request.extensions().get::<AuthContext>() else {
return next.run(request).await;
};
let client = AdminApiClient::new(state.http_client(), state.config(), &auth.session);
let settings = state.account_identity_settings(&client).await;
let unique_usernames = settings.mode.is_username() || settings.tag_style.is_none();
with_unique_usernames(unique_usernames, next.run(request)).await
}
+1 -14
View File
@@ -2,13 +2,12 @@
use crate::{
api::types::AdminUser,
middleware::flash::{self, FlashData},
middleware::flash,
session::{self, Session},
state::AppState,
};
use axum::{
extract::{Request, State},
http::StatusCode,
middleware::Next,
response::{IntoResponse, Redirect, Response},
};
@@ -65,10 +64,6 @@ pub async fn require_auth(
response
}
pub fn get_flash(request: &Request) -> Option<FlashData> {
request.extensions().get::<FlashData>().cloned()
}
fn admin_cookie_path(config: &crate::config::AdminConfig) -> &str {
if config.base_path.is_empty() {
"/"
@@ -159,11 +154,3 @@ async fn fetch_admin_user(
Err(_) => AdminFetchResult::None,
}
}
pub fn get_auth_context(request: &Request) -> Option<&AuthContext> {
request.extensions().get::<AuthContext>()
}
pub fn require_auth_context(request: &Request) -> Result<&AuthContext, Box<Response>> {
get_auth_context(request).ok_or_else(|| Box::new(StatusCode::UNAUTHORIZED.into_response()))
}
+1
View File
@@ -213,6 +213,7 @@ mod tests {
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
reports_bucket_origin: String::new(),
admin_endpoint: admin_endpoint.to_owned(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
@@ -1,122 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
use maud::{DOCTYPE, html};
pub struct AppError {
pub status: StatusCode,
pub message: String,
pub detail: Option<String>,
pub home_url: String,
}
impl AppError {
pub fn not_found(message: &str, base_path: &str) -> Self {
Self {
status: StatusCode::NOT_FOUND,
message: message.to_owned(),
detail: None,
home_url: if base_path.is_empty() {
"/".to_owned()
} else {
base_path.to_owned()
},
}
}
pub fn internal(message: &str) -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: message.to_owned(),
detail: None,
home_url: "/login".to_owned(),
}
}
pub fn forbidden(message: &str) -> Self {
Self {
status: StatusCode::FORBIDDEN,
message: message.to_owned(),
detail: None,
home_url: "/login".to_owned(),
}
}
pub fn with_detail(mut self, detail: String) -> Self {
self.detail = Some(detail);
self
}
pub fn with_base_path(mut self, base_path: &str) -> Self {
self.home_url = if base_path.is_empty() {
"/".to_owned()
} else {
base_path.to_owned()
};
self
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status_code = self.status.as_u16();
let status_text = self.status.canonical_reason().unwrap_or("Error");
let home_url = &self.home_url;
let (border, bg, text_color) = if status_code >= 500 {
("border-red-300", "bg-red-50", "text-red-800")
} else if status_code == 403 {
("border-yellow-300", "bg-yellow-50", "text-yellow-800")
} else {
("border-neutral-300", "bg-neutral-50", "text-neutral-800")
};
let markup = html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="UTF-8";
meta name="viewport" content="width=device-width, initial-scale=1.0";
title { (status_code) " " (status_text) " ~ Fluxer Admin" }
}
body class="min-h-screen bg-neutral-50 flex items-center justify-center" {
div class="mx-auto max-w-lg px-4 py-16 text-center" {
h1 class="text-6xl font-bold text-neutral-300 mb-4" {
(status_code)
}
h2 class="text-xl font-semibold text-neutral-700 mb-2" {
(status_text)
}
div class={"rounded-lg border px-4 py-3 text-sm mb-6 " (border) " " (bg) " " (text_color)} {
div { (self.message) }
@if let Some(ref detail) = self.detail {
div class="mt-2 break-all rounded border border-current/20 \
bg-white/60 px-3 py-2 text-xs" {
(detail)
}
}
}
a href=(home_url)
class="text-blue-600 hover:text-blue-800 hover:underline text-sm" {
"Go to admin"
}
}
}
}
};
(self.status, Html(markup.into_string())).into_response()
}
}
impl From<anyhow::Error> for AppError {
fn from(err: anyhow::Error) -> Self {
tracing::error!(?err, "internal server error");
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: "An internal error occurred.".to_owned(),
detail: None,
home_url: "/login".to_owned(),
}
}
}
+7 -3
View File
@@ -93,16 +93,20 @@ pub fn clear_flash_cookie(response: &mut Response) {
}
pub fn redirect_with_flash(url: &str, flash: FlashData, secure: bool) -> Response {
let encoded = serialize_flash(&flash);
let mut response = Redirect::to(url).into_response();
set_flash_cookie(&mut response, &flash, secure);
response
}
pub fn set_flash_cookie(response: &mut Response, flash: &FlashData, secure: bool) {
let encoded = serialize_flash(flash);
let secure_flag = if secure { "; Secure" } else { "" };
let cookie_value = format!(
"{FLASH_COOKIE_NAME}={encoded}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60{secure_flag}"
);
let mut response = Redirect::to(url).into_response();
if let Ok(v) = HeaderValue::from_str(&cookie_value) {
response.headers_mut().append(header::SET_COOKIE, v);
}
response
}
#[cfg(test)]
+11 -7
View File
@@ -10,6 +10,7 @@ use axum::{
const HX_RESWAP: HeaderName = HeaderName::from_static("hx-reswap");
const ADMIN_TOAST: HeaderName = HeaderName::from_static("x-fluxer-admin-toast");
const HX_REDIRECT: HeaderName = HeaderName::from_static("hx-redirect");
pub fn is_htmx_request(headers: &HeaderMap) -> bool {
headers
@@ -18,13 +19,6 @@ pub fn is_htmx_request(headers: &HeaderMap) -> bool {
.is_some_and(|value| value == "true")
}
pub fn htmx_current_url(headers: &HeaderMap) -> Option<String> {
headers
.get("HX-Current-URL")
.and_then(|value| value.to_str().ok())
.map(|s| s.to_owned())
}
pub fn htmx_target(headers: &HeaderMap) -> Option<String> {
headers
.get("HX-Target")
@@ -45,6 +39,16 @@ pub fn toast_response(flash: &FlashData) -> Response {
response
}
pub fn navigate_with_flash(url: &str, flash: &FlashData, secure: bool) -> Response {
let mut response = StatusCode::NO_CONTENT.into_response();
if let Ok(value) = HeaderValue::from_str(url) {
response.headers_mut().insert(HX_REDIRECT, value);
}
add_toast_header(&mut response, flash);
flash::set_flash_cookie(&mut response, flash, secure);
response
}
pub fn add_toast_header(response: &mut Response, flash: &FlashData) {
let payload = serde_json::json!({
"level": flash.flash_type,
+1 -1
View File
@@ -1,8 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub mod account_identity;
pub mod auth;
pub mod csrf;
pub mod error_handler;
pub mod flash;
pub mod htmx;
pub mod self_hosted;
-6
View File
@@ -9,17 +9,11 @@ pub const ADMIN_OAUTH_SCOPE: &str = "identify email";
#[derive(Debug, Deserialize)]
pub struct TokenResponse {
pub access_token: String,
pub token_type: String,
}
#[derive(Debug, Deserialize)]
pub struct UserInfo {
pub id: String,
pub username: String,
pub discriminator: String,
pub avatar: Option<String>,
pub email: Option<String>,
pub global_name: Option<String>,
}
pub fn authorize_url(config: &AdminConfig, state: &str) -> String {
+107 -44
View File
@@ -1,7 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::client::AdminApiClient,
api::{
client::{AdminApiClient, ApiError},
types::FlashMessage,
},
middleware::{auth::AuthContext, csrf, htmx},
state::AppState,
templates,
@@ -13,10 +16,12 @@ use axum::{
response::{Html, IntoResponse, Response},
routing::get,
};
use serde::Deserialize;
use super::ActionQuery;
use super::bans_actions::{
BanFormData, custom_flash, execute_ban, extract_value, flash_response, render_inline_flash,
to_flash,
};
pub fn router() -> Router<AppState> {
@@ -43,17 +48,41 @@ pub fn router() -> Router<AppState> {
)
}
fn render_ban_page(state: &AppState, auth: &AuthContext, key: &str, req: &Request) -> Response {
async fn render_ban_page(
state: &AppState,
auth: &AuthContext,
key: &str,
csrf_token: String,
) -> Response {
let config = state.config();
let ban_cfg = match templates::pages::bans::get_ban_config(key) {
Some(c) => c,
None => return axum::http::StatusCode::NOT_FOUND.into_response(),
};
let csrf_token = csrf::get_csrf_token(req);
let markup = templates::pages::bans::bans_page(config, auth, ban_cfg, None, &csrf_token);
let username_sign_in = email_bans_on_username_instance(state, auth, key).await;
let markup = templates::pages::bans::bans_page(
config,
auth,
ban_cfg,
None,
&csrf_token,
username_sign_in,
);
Html(markup.into_string()).into_response()
}
async fn email_bans_on_username_instance(state: &AppState, auth: &AuthContext, key: &str) -> bool {
key == "email-bans"
&& state
.account_identity(&AdminApiClient::new(
state.http_client(),
state.config(),
&auth.session,
))
.await
.is_username()
}
macro_rules! ban_get {
($name:ident, $key:expr) => {
async fn $name(
@@ -61,7 +90,8 @@ macro_rules! ban_get {
auth: axum::Extension<AuthContext>,
request: Request,
) -> Response {
render_ban_page(&state, &auth.0, $key, &request)
let csrf_token = csrf::get_csrf_token(&request);
render_ban_page(&state, &auth.0, $key, csrf_token).await
}
};
}
@@ -90,17 +120,18 @@ async fn generic_ban_post(
};
let value = extract_value(form, ban_cfg.input_name);
let is_htmx = htmx::is_htmx_request(headers);
let (level, msg) = execute_ban(
&client,
ban_key,
action,
&value,
form.hashes.as_deref(),
form.sha256_list.as_deref(),
form.audit_log_reason.as_deref(),
let (level, msg) = execute_ban(&client, ban_key, action, &value, form).await;
let username_sign_in = !is_htmx && email_bans_on_username_instance(state, auth, ban_key).await;
flash_response(
config,
auth,
is_htmx,
level,
&msg,
ban_cfg,
csrf_token,
username_sign_in,
)
.await;
flash_response(config, auth, is_htmx, level, &msg, ban_cfg, csrf_token)
}
macro_rules! ban_post {
@@ -118,14 +149,18 @@ macro_rules! ban_post {
let form: BanFormData = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(_) => {
let is_htmx = htmx::is_htmx_request(&headers);
let username_sign_in =
!is_htmx && email_bans_on_username_instance(&state, &auth.0, $key).await;
return flash_response(
state.config(),
&auth.0,
htmx::is_htmx_request(&headers),
is_htmx,
"error",
"Invalid form data",
templates::pages::bans::get_ban_config($key).unwrap(),
&csrf_token,
username_sign_in,
);
}
};
@@ -141,16 +176,56 @@ ban_post!(url_bans_post, "url-bans");
ban_post!(file_sha_bans_post, "file-sha-bans");
ban_post!(avatar_hash_bans_post, "avatar-hash-bans");
#[derive(Deserialize)]
struct UrlDomainListQuery {
after: Option<String>,
}
async fn render_url_domain_page(
state: &AppState,
auth: &AuthContext,
flash: Option<&FlashMessage>,
csrf_token: &str,
after: Option<&str>,
) -> Response {
let config = state.config();
let client = AdminApiClient::new(state.http_client(), config, &auth.session);
let entries = match client.list_url_domain_entries(after).await {
Ok(page) => Some(page),
Err(error) => {
tracing::warn!(%error, "admin API request failed: list URL domain blocklist");
None
}
};
let markup = templates::pages::url_domain_bans::url_domain_bans_page(
config,
auth,
flash,
csrf_token,
entries.as_ref(),
);
Html(markup.into_string()).into_response()
}
fn ban_url_domain_error(domain: &str, error: &ApiError) -> String {
match error {
ApiError::Http { status: 400, .. } => {
format!("Failed to ban {domain}: not a valid domain, or the pattern is too broad")
}
_ => format!("Failed to ban {domain}"),
}
}
async fn url_domain_bans(
State(state): State<AppState>,
auth: axum::Extension<AuthContext>,
request: Request,
) -> Response {
let config = state.config();
let csrf_token = csrf::get_csrf_token(&request);
let markup =
templates::pages::url_domain_bans::url_domain_bans_page(config, &auth.0, None, &csrf_token);
Html(markup.into_string()).into_response()
let Query(query): Query<UrlDomainListQuery> =
Query::try_from_uri(request.uri()).unwrap_or(Query(UrlDomainListQuery { after: None }));
let after = query.after.as_deref().filter(|value| !value.is_empty());
render_url_domain_page(&state, &auth.0, None, &csrf_token, after).await
}
async fn url_domain_bans_post(
@@ -181,10 +256,10 @@ async fn url_domain_bans_post(
.ban_url_domain(&domain, m_sub, form.audit_log_reason.as_deref())
.await
{
Ok(()) => ("success", format!("Domain {domain} banned successfully")),
Ok(()) => ("success", format!("{domain} banned successfully")),
Err(error) => {
tracing::warn!(%error, domain, "admin API request failed: ban URL domain");
("error", format!("Failed to ban domain {domain}"))
("error", ban_url_domain_error(&domain, &error))
}
}
}
@@ -192,15 +267,15 @@ async fn url_domain_bans_post(
.unban_url_domain(&domain, form.audit_log_reason.as_deref())
.await
{
Ok(()) => ("success", format!("Domain {domain} unbanned")),
Ok(()) => ("success", format!("{domain} unbanned")),
Err(error) => {
tracing::warn!(%error, domain, "admin API request failed: unban URL domain");
("error", format!("Failed to unban domain {domain}"))
("error", format!("Failed to unban {domain}"))
}
},
"check" => match client.check_url_domain_ban(&domain).await {
Ok(r) if r.banned => ("info", format!("Domain {domain} is banned")),
Ok(_) => ("info", format!("Domain {domain} is NOT banned")),
Ok(r) if r.banned => ("info", format!("{domain} is blocked")),
Ok(_) => ("info", format!("{domain} is NOT blocked")),
Err(error) => {
tracing::warn!(%error, domain, "admin API request failed: check URL domain ban");
("error", "Error checking ban status".into())
@@ -208,15 +283,11 @@ async fn url_domain_bans_post(
},
_ => ("error", "Unknown action".into()),
};
custom_flash(
config,
&auth.0,
is_htmx,
level,
&msg,
&csrf_token,
"url-domain",
)
if is_htmx {
return render_inline_flash(level, &msg);
}
let flash = to_flash(level, &msg);
render_url_domain_page(&state, &auth.0, Some(&flash), &csrf_token, None).await
}
async fn profile_substring_bans(
@@ -288,13 +359,5 @@ async fn profile_substring_bans_post(
},
_ => ("error", "Unknown action".into()),
};
custom_flash(
config,
&auth.0,
is_htmx,
level,
&msg,
&csrf_token,
"profile-substring",
)
custom_flash(config, &auth.0, is_htmx, level, &msg, &csrf_token)
}
+65 -22
View File
@@ -4,6 +4,7 @@ use crate::api::client::{AdminApiClient, ApiResult};
use crate::api::types::{FlashLevel, FlashMessage};
use crate::middleware::auth::AuthContext;
use crate::templates;
use crate::utils::timestamps::format_admin_timestamp;
use axum::response::{Html, IntoResponse, Response};
use serde::Deserialize;
@@ -34,6 +35,8 @@ pub struct BanFormData {
#[serde(default)]
pub substring: Option<String>,
#[serde(default)]
pub duration_hours: Option<String>,
#[serde(default)]
pub audit_log_reason: Option<String>,
#[serde(default)]
pub _csrf: Option<String>,
@@ -58,10 +61,12 @@ pub async fn execute_ban(
ban_type: &str,
action: &str,
value: &str,
bulk_hashes: Option<&str>,
bulk_sha256_list: Option<&str>,
audit_log_reason: Option<&str>,
form: &BanFormData,
) -> (&'static str, String) {
let bulk_hashes = form.hashes.as_deref();
let bulk_sha256_list = form.sha256_list.as_deref();
let duration_hours = form.duration_hours.as_deref();
let audit_log_reason = form.audit_log_reason.as_deref();
if (action == "bulk-ban" || action == "bulk-ban-files") && ban_type == "file-sha-bans" {
let raw_hashes = if action == "bulk-ban-files" {
bulk_sha256_list
@@ -74,6 +79,9 @@ pub async fn execute_ban(
return ("error", "Value is required".into());
}
match action {
"ban" if ban_type == "ip-bans" => {
execute_ip_ban(client, value, duration_hours, audit_log_reason).await
}
"ban" => execute_single_ban(client, ban_type, value, audit_log_reason).await,
"unban" => execute_single_unban(client, ban_type, value, audit_log_reason).await,
"check" => execute_check(client, ban_type, value).await,
@@ -107,6 +115,34 @@ async fn execute_bulk_ban(
}
}
async fn execute_ip_ban(
client: &AdminApiClient,
value: &str,
duration_hours: Option<&str>,
audit_log_reason: Option<&str>,
) -> (&'static str, String) {
let duration_hours = match duration_hours.map(str::trim).filter(|v| !v.is_empty()) {
None => 0,
Some(raw) => match raw.parse::<u32>() {
Ok(hours) => hours,
Err(_) => return ("error", "Invalid ban duration".into()),
},
};
let success_message = if duration_hours == 0 {
format!("{value} banned permanently")
} else {
format!(
"{value} banned for {}",
crate::templates::pages::bans::ip_ban_duration_label(duration_hours)
)
};
ban_action_result(
client.ban_ip(value, duration_hours, audit_log_reason).await,
success_message,
format!("Failed to ban {value}"),
)
}
async fn execute_single_ban(
client: &AdminApiClient,
ban_type: &str,
@@ -114,7 +150,6 @@ async fn execute_single_ban(
audit_log_reason: Option<&str>,
) -> (&'static str, String) {
let result = match ban_type {
"ip-bans" => client.ban_ip(value, audit_log_reason).await,
"email-bans" => client.ban_email(value, audit_log_reason).await,
"phrase-bans" => client.ban_phrase(value, audit_log_reason).await,
"url-bans" => client.ban_url(value, audit_log_reason).await,
@@ -166,7 +201,16 @@ async fn execute_check(
_ => return ("error", "Unknown ban type".into()),
};
match result {
Ok(r) if r.banned => ("info", format!("{value} is banned")),
Ok(r) if r.banned => match r.expires_at {
Some(expires_at) => (
"info",
format!(
"{value} is banned until {}",
format_admin_timestamp(&expires_at)
),
),
None => ("info", format!("{value} is banned")),
},
Ok(_) => ("info", format!("{value} is NOT banned")),
Err(error) => {
tracing::warn!(%error, ban_type, value, "admin API request failed: check ban status");
@@ -189,6 +233,7 @@ fn ban_action_result(
}
}
#[allow(clippy::too_many_arguments)]
pub fn flash_response(
config: &crate::config::AdminConfig,
auth: &AuthContext,
@@ -197,13 +242,20 @@ pub fn flash_response(
message: &str,
ban_cfg: &templates::pages::bans::BanConfig,
csrf_token: &str,
username_sign_in: bool,
) -> Response {
if is_htmx {
render_inline_flash(level, message)
} else {
let flash = to_flash(level, message);
let markup =
templates::pages::bans::bans_page(config, auth, ban_cfg, Some(&flash), csrf_token);
let markup = templates::pages::bans::bans_page(
config,
auth,
ban_cfg,
Some(&flash),
csrf_token,
username_sign_in,
);
Html(markup.into_string()).into_response()
}
}
@@ -243,25 +295,16 @@ pub fn custom_flash(
level: &str,
message: &str,
csrf_token: &str,
page_type: &str,
) -> Response {
if is_htmx {
return render_inline_flash(level, message);
}
let flash = to_flash(level, message);
let markup = match page_type {
"url-domain" => templates::pages::url_domain_bans::url_domain_bans_page(
config,
auth,
Some(&flash),
csrf_token,
),
_ => templates::pages::profile_substring_bans::profile_substring_bans_page(
config,
auth,
Some(&flash),
csrf_token,
),
};
let markup = templates::pages::profile_substring_bans::profile_substring_bans_page(
config,
auth,
Some(&flash),
csrf_token,
);
Html(markup.into_string()).into_response()
}
+18
View File
@@ -96,6 +96,24 @@ pub async fn render(
config, &guild, &stickers, csrf_token,
))
}
"threads" => {
if !acl::has_permission(admin_acls, acl::GUILD_LOOKUP) {
return None;
}
let threads = client
.list_guild_threads(guild_id)
.await
.map(|response| response.threads)
.map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild threads"))
.unwrap_or_default();
Some(tabs::threads::threads_tab(
config,
&guild,
&threads,
acl::has_permission(admin_acls, acl::MESSAGE_DELETE_ALL),
csrf_token,
))
}
"audit_log" | "audit-log" => {
if !acl::has_permission(admin_acls, acl::GUILD_AUDIT_LOG_VIEW) {
return None;
+14
View File
@@ -134,6 +134,7 @@ async fn guild_detail(
.as_ref()
.map(|user| user.acls.as_slice())
.unwrap_or(&[]);
let username_sign_in = state.account_identity(&client).await.is_username();
let tab_body = if let Some(guild) = guild.as_ref() {
guild_tabs::render(
&client,
@@ -152,6 +153,7 @@ async fn guild_detail(
active_tab,
&csrf_token,
admin_acls,
username_sign_in,
))
})
} else {
@@ -166,6 +168,7 @@ async fn guild_detail(
active_tab,
tab_body,
is_detail_fragment,
username_sign_in,
);
Html(markup.into_string()).into_response()
}
@@ -430,6 +433,16 @@ async fn dispatch_guild_action(
"Failed to delete sticker",
)
}
"delete_thread" => {
let Some(thread_id) = get("thread_id") else {
return FlashData::error("Thread ID is required");
};
action_result(
client.delete_thread_channel(&thread_id).await,
"Thread deleted",
"Failed to delete thread",
)
}
"trigger_archive" => {
let inc = form.bool_value("include_attachments");
action_result(
@@ -508,6 +521,7 @@ async fn guild_tab(
normalize_guild_tab(&tab),
&csrf_token,
admin_acls,
state.account_identity(&client).await.is_username(),
),
None => maud::html! {
div class="p-4 text-red-600 text-sm" {
+1 -17
View File
@@ -12,7 +12,7 @@ use crate::{
templates,
};
use axum::{
Form, Json, Router,
Form, Router,
extract::{FromRequest, Path, Query, Request, State},
http::HeaderMap,
response::{Html, IntoResponse, Response},
@@ -46,7 +46,6 @@ struct JobActionForm {
pub fn router() -> Router<AppState> {
Router::new()
.route("/jobs", get(jobs_list))
.route("/jobs/active.json", get(jobs_active_json))
.route("/jobs/{job_id}", get(job_detail).post(job_detail_post))
}
@@ -128,21 +127,6 @@ async fn jobs_list(
Html(markup.into_string()).into_response()
}
async fn jobs_active_json(
State(state): State<AppState>,
auth: axum::Extension<AuthContext>,
) -> Response {
let config = state.config();
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
match client.list_active_jobs().await {
Ok(data) => Json(serde_json::json!(data)).into_response(),
Err(error) => {
tracing::warn!(%error, "admin API request failed: list active jobs");
Json(serde_json::json!({ "jobs": [] })).into_response()
}
}
}
async fn job_detail(
State(state): State<AppState>,
headers: HeaderMap,
@@ -105,45 +105,6 @@ pub(crate) async fn messages_post(
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")),
}
}
"report-to-ncmec" => {
let attachment_id = form.clean("attachment_id");
let filename = form.clean("filename");
let reporter_full_name = form.clean("reporter_full_name");
let source_report_id = form.clean("source_report_id");
let confirmed_viewed = form.bool_value("confirmed_viewed");
let (Some(cid), Some(mid), Some(aid), Some(name), Some(reporter)) = (
&channel_id,
&message_id,
&attachment_id,
&filename,
&reporter_full_name,
) else {
return json_error(
StatusCode::BAD_REQUEST,
"Missing required NCMEC report fields",
);
};
if !confirmed_viewed {
return json_error(
StatusCode::BAD_REQUEST,
"Missing required NCMEC report fields",
);
}
match client
.report_attachment_to_ncmec(
cid,
mid,
aid,
name,
reporter,
source_report_id.as_deref(),
)
.await
{
Ok(resp) => Json(resp.data).into_response(),
Err(e) => json_error(StatusCode::BAD_REQUEST, &format!("{e}")),
}
}
_ => Redirect::to(&format!("{base}/messages")).into_response(),
}
}
+8 -1
View File
@@ -201,6 +201,13 @@ async fn bulk_actions_page(
csrf: axum::Extension<CsrfToken>,
) -> Response {
let config = state.config();
let markup = templates::pages::bulk_actions::bulk_actions_page(config, &auth.0, &csrf.0.0);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let account_identity = state.account_identity(&client).await;
let markup = templates::pages::bulk_actions::bulk_actions_page(
config,
&auth.0,
&csrf.0.0,
account_identity.is_username(),
);
Html(markup.into_string()).into_response()
}
+8 -1
View File
@@ -73,6 +73,10 @@ pub fn build_router(config: AdminConfig) -> Router {
.merge(admin::router())
.route("/", get(dashboard))
.route("/dashboard", get(dashboard))
.layer(from_fn_with_state(
state.clone(),
middleware::account_identity::scope_account_identity,
))
.layer(from_fn(middleware::htmx::flash_redirect_to_toast))
.layer(from_fn_with_state(
state.clone(),
@@ -146,7 +150,10 @@ fn build_admin_csp(config: &AdminConfig) -> String {
"default-src 'self'".to_owned(),
"script-src 'self' 'unsafe-inline'".to_owned(),
"style-src 'self' 'unsafe-inline'".to_owned(),
format!("img-src 'self' data: blob: {static_cdn} {media} https://fluxer-reports.ewr1.vultrobjects.com"),
format!(
"img-src 'self' data: blob: {static_cdn} {media} {}",
config.reports_bucket_origin
),
"font-src 'self'".to_owned(),
"connect-src 'self'".to_owned(),
"object-src 'none'".to_owned(),
+347 -50
View File
@@ -1,9 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
acl,
api::{
client::{AdminApiClient, ApiResultExt},
client::{AdminApiClient, ApiError, ApiResultExt},
reports::SearchReportsParams,
types::ReportEntry,
},
config::AdminConfig,
middleware::{
@@ -14,13 +16,12 @@ use crate::{
},
state::AppState,
templates,
utils::forms::clean_string,
utils::{forms::clean_string, timestamps::format_admin_timestamp},
};
use axum::{
Form, Router,
extract::{FromRequest, Path, Query, Request, State},
http::HeaderMap,
http::StatusCode,
response::{Html, IntoResponse, Redirect, Response},
routing::get,
};
@@ -35,8 +36,10 @@ struct ReportsQuery {
#[serde(rename = "type")]
report_type: Option<String>,
category: Option<String>,
reason: Option<String>,
reporter_id: Option<String>,
reported_user_id: Option<String>,
reported_webhook_id: Option<String>,
reported_guild_id: Option<String>,
reported_channel_id: Option<String>,
guild_context_id: Option<String>,
@@ -46,6 +49,28 @@ struct ReportsQuery {
page: Option<u32>,
}
#[derive(Deserialize)]
struct LegalHoldForm {
#[serde(default)]
_csrf: Option<String>,
#[serde(default)]
legal_hold_until: Option<String>,
#[serde(default)]
legal_hold_reason: Option<String>,
#[serde(default)]
clear: Option<String>,
}
#[derive(Deserialize)]
struct DeleteForm {
#[serde(default)]
_csrf: Option<String>,
#[serde(default)]
confirm: Option<String>,
#[serde(default)]
audit_log_reason: Option<String>,
}
#[derive(Deserialize)]
struct ResolveForm {
#[serde(default)]
@@ -53,6 +78,8 @@ struct ResolveForm {
#[serde(default)]
resolution: Option<String>,
#[serde(default)]
public_comment: Option<String>,
#[serde(default)]
notify_reporter: Option<String>,
#[serde(default)]
notify_reporter_present: Option<String>,
@@ -67,6 +94,14 @@ pub fn router() -> Router<AppState> {
"/reports/{report_id}/resolve",
axum::routing::post(report_resolve),
)
.route(
"/reports/{report_id}/legal-hold",
axum::routing::post(report_legal_hold),
)
.route(
"/reports/{report_id}/delete",
axum::routing::post(report_delete),
)
}
async fn reports_list(
@@ -95,25 +130,30 @@ async fn reports_list(
.report_type
.as_deref()
.and_then(|s| s.parse::<i32>().ok());
let reports = client
.search_reports(&SearchReportsParams {
query: search_query.as_deref(),
status,
report_type,
category: query.category.as_deref(),
reporter_id: query.reporter_id.as_deref(),
reported_user_id: query.reported_user_id.as_deref(),
reported_guild_id: query.reported_guild_id.as_deref(),
reported_channel_id: query.reported_channel_id.as_deref(),
guild_context_id: query.guild_context_id.as_deref(),
resolved_by_admin_id: query.resolved_by_admin_id.as_deref(),
sort_by: Some(sort_by),
sort_order: Some(sort_order),
limit,
offset,
})
.await
.log_error("search reports");
let reason = query.reason.as_deref().and_then(clean_string);
let params = SearchReportsParams {
query: search_query.as_deref(),
status,
report_type,
category: query.category.as_deref(),
reason: reason.as_deref(),
reporter_id: query.reporter_id.as_deref(),
reported_user_id: query.reported_user_id.as_deref(),
reported_webhook_id: query.reported_webhook_id.as_deref(),
reported_guild_id: query.reported_guild_id.as_deref(),
reported_channel_id: query.reported_channel_id.as_deref(),
guild_context_id: query.guild_context_id.as_deref(),
resolved_by_admin_id: query.resolved_by_admin_id.as_deref(),
sort_by: Some(sort_by),
sort_order: Some(sort_order),
limit,
offset,
};
let (reports, reasons) = tokio::join!(
client.search_reports(&params),
state.report_reasons(&client)
);
let reports = reports.log_error("search reports");
let markup = templates::pages::reports_list::reports_list_page(
config,
@@ -124,14 +164,17 @@ async fn reports_list(
status: query.status.as_deref(),
report_type: query.report_type.as_deref(),
category: query.category.as_deref(),
reason: reason.as_deref(),
reporter_id: query.reporter_id.as_deref(),
reported_user_id: query.reported_user_id.as_deref(),
reported_webhook_id: query.reported_webhook_id.as_deref(),
reported_guild_id: query.reported_guild_id.as_deref(),
reported_channel_id: query.reported_channel_id.as_deref(),
guild_context_id: query.guild_context_id.as_deref(),
resolved_by_admin_id: query.resolved_by_admin_id.as_deref(),
sort: query.sort.as_deref().unwrap_or("reportedAt_desc"),
},
reasons.as_deref(),
page,
limit,
);
@@ -167,10 +210,12 @@ async fn report_detail(
.log_error("load report detail");
match report {
Some(report) => {
let live = load_live_profile(&client, &auth.0, &report).await;
let markup = templates::pages::report_detail::report_detail_page(
config,
&auth.0,
&report,
&live,
&csrf_token,
is_detail_fragment,
);
@@ -183,6 +228,49 @@ async fn report_detail(
}
}
async fn load_live_profile(
client: &AdminApiClient,
auth: &AuthContext,
report: &ReportEntry,
) -> templates::pages::report_detail::LiveProfile {
let can = |permission: &str| {
auth.admin_user
.as_ref()
.is_some_and(|admin| acl::has_permission(&admin.acls, permission))
};
let snapshot = report.reported_profile_snapshot.as_ref();
let user_id = snapshot
.and_then(|snapshot| snapshot.user.as_ref())
.map(|user| user.id.as_str())
.filter(|_| can(acl::USER_LOOKUP));
let guild_id = snapshot
.and_then(|snapshot| snapshot.guild.as_ref())
.map(|guild| guild.id.as_str())
.filter(|_| can(acl::GUILD_LOOKUP));
let (user, guild) = tokio::join!(
async {
match user_id {
Some(id) => client
.get_user_by_id(id)
.await
.log_error("load live reported user"),
None => None,
}
},
async {
match guild_id {
Some(id) => client
.lookup_guild(id)
.await
.log_error("load live reported guild")
.flatten(),
None => None,
}
}
);
templates::pages::report_detail::LiveProfile { user, guild }
}
async fn report_fragment(
State(state): State<AppState>,
auth: axum::Extension<AuthContext>,
@@ -214,50 +302,211 @@ async fn report_resolve(
) -> Response {
let config = state.config();
let base = &config.base_path;
let is_background = request
.uri()
.query()
.is_some_and(|query| query.split('&').any(|part| part == "background=1"));
let back = format!("{base}/reports/{report_id}");
let form: ResolveForm = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(error) => {
tracing::warn!(%error, report_id, "failed to parse report resolve form");
return flash::redirect_with_flash(
&format!("{base}/reports/{report_id}"),
&back,
FlashData::error("Invalid form data"),
config.secure_cookies(),
);
}
};
let Some((resolution, label)) =
templates::pages::report_detail::resolution_choice(form.resolution.as_deref())
else {
return flash::redirect_with_flash(
&back,
FlashData::error(RESOLUTION_REQUIRED),
config.secure_cookies(),
);
};
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let public_comment = clean_string(form.resolution.as_deref().unwrap_or(""));
let public_comment = clean_string(form.public_comment.as_deref().unwrap_or(""));
let notify_reporter =
form.notify_reporter_present.is_none() || form.notify_reporter.as_deref() == Some("true");
let result = client
.resolve_report(&report_id, public_comment.as_deref(), notify_reporter, None)
.await;
match result {
Ok(_) => {
if is_background {
return StatusCode::NO_CONTENT.into_response();
}
flash::redirect_with_flash(
&format!("{base}/reports/{report_id}"),
FlashData::success("Report resolved"),
config.secure_cookies(),
)
}
let flash = match client
.resolve_report(
&report_id,
resolution,
public_comment.as_deref(),
notify_reporter,
None,
)
.await
{
Ok(_) => FlashData::success(format!("Report resolved: {label}")),
Err(error) => {
tracing::warn!(%error, report_id, "admin API request failed: resolve report");
if is_background {
return StatusCode::BAD_GATEWAY.into_response();
}
flash::redirect_with_flash(
&format!("{base}/reports/{report_id}"),
FlashData::error("Failed to resolve report"),
config.secure_cookies(),
)
FlashData::error("Failed to resolve report")
}
};
flash::redirect_with_flash(&back, flash, config.secure_cookies())
}
const RESOLUTION_REQUIRED: &str = "Choose a resolution";
async fn report_legal_hold(
State(state): State<AppState>,
auth: axum::Extension<AuthContext>,
Path(report_id): Path<String>,
request: Request,
) -> Response {
let config = state.config();
let base = &config.base_path;
let back = format!("{base}/reports/{report_id}");
let form: LegalHoldForm = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(error) => {
tracing::warn!(%error, report_id, "failed to parse report legal hold form");
return flash::redirect_with_flash(
&back,
FlashData::error("Invalid form data"),
config.secure_cookies(),
);
}
};
let clearing = form.clear.is_some();
let until = if clearing {
None
} else {
match form.legal_hold_until.as_deref().and_then(legal_hold_date) {
Some(date) if date < time::OffsetDateTime::now_utc().date() => {
return flash::redirect_with_flash(
&back,
FlashData::error(LEGAL_HOLD_IN_PAST),
config.secure_cookies(),
);
}
Some(date) => Some(legal_hold_end_of_day(date)),
None => {
return flash::redirect_with_flash(
&back,
FlashData::error("Choose the date the hold ends"),
config.secure_cookies(),
);
}
}
};
let reason = clean_string(form.legal_hold_reason.as_deref().unwrap_or(""));
if until.is_some() && reason.is_none() {
return flash::redirect_with_flash(
&back,
FlashData::error(LEGAL_HOLD_NEEDS_REASON),
config.secure_cookies(),
);
}
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let result = client
.set_report_legal_hold(&report_id, until.as_deref(), reason.as_deref())
.await;
let flash = match result {
Ok(response) => match response.legal_hold_until {
Some(until) => FlashData::success(format!(
"Legal hold placed until {}",
format_admin_timestamp(&until)
)),
None => FlashData::success("Legal hold cleared"),
},
Err(error) => {
tracing::warn!(%error, report_id, "admin API request failed: set report legal hold");
match error {
ApiError::Http {
status: 400,
message,
} => FlashData::error(legal_hold_refusal(&message)),
_ => FlashData::error("Failed to update the legal hold"),
}
}
};
flash::redirect_with_flash(&back, flash, config.secure_cookies())
}
async fn report_delete(
State(state): State<AppState>,
auth: axum::Extension<AuthContext>,
Path(report_id): Path<String>,
request: Request,
) -> Response {
let config = state.config();
let base = &config.base_path;
let back = format!("{base}/reports/{report_id}");
let navigates = htmx::is_htmx_request(request.headers())
&& htmx::targets(request.headers(), "flash-container");
let form: DeleteForm = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(error) => {
tracing::warn!(%error, report_id, "failed to parse report delete form");
return flash::redirect_with_flash(
&back,
FlashData::error("Invalid form data"),
config.secure_cookies(),
);
}
};
if form.confirm.as_deref() != Some("true") {
return flash::redirect_with_flash(
&back,
FlashData::error(DELETE_NEEDS_CONFIRMATION),
config.secure_cookies(),
);
}
let audit_log_reason = clean_string(form.audit_log_reason.as_deref().unwrap_or(""));
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
match client
.delete_report(&report_id, audit_log_reason.as_deref())
.await
{
Ok(()) => {
let list = format!("{base}/reports");
let flash = FlashData::success("Report deleted");
if navigates {
htmx::navigate_with_flash(&list, &flash, config.secure_cookies())
} else {
flash::redirect_with_flash(&list, flash, config.secure_cookies())
}
}
Err(error) => {
tracing::warn!(%error, report_id, "admin API request failed: delete report");
let message = match error {
ApiError::Http { status: 409, .. } => DELETE_REFUSED_HELD,
ApiError::Http { status: 404, .. } => "The report no longer exists",
_ => "Failed to delete the report",
};
flash::redirect_with_flash(&back, FlashData::error(message), config.secure_cookies())
}
}
}
const DELETE_NEEDS_CONFIRMATION: &str = "Confirm that the report should be deleted";
const DELETE_REFUSED_HELD: &str =
"The report is under a legal hold. Clear the hold before deleting it.";
const LEGAL_HOLD_IN_PAST: &str = "The hold must end in the future";
const LEGAL_HOLD_NEEDS_REASON: &str = "Give a reason for the hold";
fn legal_hold_date(date: &str) -> Option<time::Date> {
let format = time::format_description::parse_borrowed::<2>("[year]-[month]-[day]").ok()?;
time::Date::parse(date.trim(), &format).ok()
}
fn legal_hold_end_of_day(date: time::Date) -> String {
format!("{date}T23:59:59.999Z")
}
fn legal_hold_refusal(body: &str) -> &'static str {
let body: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
let reason_refused = body["errors"]
.as_array()
.into_iter()
.flatten()
.any(|error| error["path"] == "legal_hold_reason");
if reason_refused {
LEGAL_HOLD_NEEDS_REASON
} else {
LEGAL_HOLD_IN_PAST
}
}
@@ -271,3 +520,51 @@ fn decode_sort(sort: Option<&str>) -> (&'static str, &'static str) {
_ => ("reportedAt", "desc"),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legal_hold_dates_end_at_the_end_of_the_day_in_utc() {
assert_eq!(
legal_hold_date("2027-01-31")
.map(legal_hold_end_of_day)
.as_deref(),
Some("2027-01-31T23:59:59.999Z")
);
assert_eq!(
legal_hold_date(" 2027-02-01 ")
.map(legal_hold_end_of_day)
.as_deref(),
Some("2027-02-01T23:59:59.999Z")
);
for invalid in ["", "2027-02-30", "31/01/2027", "2027-1-31", "tomorrow"] {
assert_eq!(legal_hold_date(invalid), None, "{invalid}");
}
}
#[test]
fn a_refused_hold_names_the_field_the_api_refused() {
let refusal = |path: &str| {
serde_json::json!({
"code": "INVALID_FORM_BODY",
"message": "Input Validation Error",
"errors": [{"path": path, "message": "refused"}]
})
.to_string()
};
assert_eq!(
legal_hold_refusal(&refusal("legal_hold_until")),
"The hold must end in the future"
);
assert_eq!(
legal_hold_refusal(&refusal("legal_hold_reason")),
"Give a reason for the hold"
);
assert_eq!(
legal_hold_refusal("not json"),
"The hold must end in the future"
);
}
}
-10
View File
@@ -64,7 +64,6 @@ pub fn router() -> Router<AppState> {
"/limit-config",
get(limit_config_page).post(system_actions::limit_config_post),
)
.route("/strange-place", get(strange_place_page))
}
async fn gateway_page(
@@ -263,15 +262,6 @@ async fn limit_config_page(
Html(markup.into_string()).into_response()
}
async fn strange_place_page(
State(state): State<AppState>,
auth: axum::Extension<AuthContext>,
) -> Response {
let config = state.config();
let markup = templates::pages::strange_place::strange_place_page(config, &auth.0);
Html(markup.into_string()).into_response()
}
fn nonempty(s: &str) -> Option<String> {
if s.is_empty() {
None
+78 -108
View File
@@ -18,8 +18,8 @@ use crate::{
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
LimitRuleFilters, PlutoniumPageConfigUpdateRequest, PremiumMode,
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope,
LimitRuleFilters, PremiumMode, PushRelayConfigUpdateRequest, RegistrationMode,
SsoConfigUpdateRequest, VoiceE2eeScope,
},
},
config::AdminConfig,
@@ -220,10 +220,6 @@ pub async fn instance_config_post(
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_plutonium_page" => match build_plutonium_page_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
},
"update_captcha" => match build_captcha_update(&form) {
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
Err(message) => FlashData::error(message),
@@ -613,41 +609,6 @@ fn build_domain_migration_update(
})
}
fn build_plutonium_page_update(
form: &MultiValueForm,
) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
plutonium_page: Some(PlutoniumPageConfigUpdateRequest {
enabled: Some(form.bool_value("plutonium_page_enabled")),
rollout_basis_points: parse_form_number(
form,
"plutonium_page_rollout_basis_points",
"Rollout basis points",
0,
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
)?,
rollout_salt: parse_experiment_rollout_salt(form, "plutonium_page_rollout_salt")?,
included_user_ids: Some(parse_experiment_user_ids(
form.first("plutonium_page_included_user_ids")
.unwrap_or_default(),
"Included user IDs",
)?),
included_guild_ids: Some(parse_experiment_user_ids(
form.first("plutonium_page_included_guild_ids")
.unwrap_or_default(),
"Included guild IDs",
)?),
include_premium_users: Some(form.bool_value("plutonium_page_include_premium_users")),
excluded_user_ids: Some(parse_experiment_user_ids(
form.first("plutonium_page_excluded_user_ids")
.unwrap_or_default(),
"Excluded user IDs",
)?),
}),
..Default::default()
})
}
fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateRequest, String> {
Ok(InstanceConfigUpdateRequest {
captcha: Some(CaptchaConfigUpdateRequest {
@@ -748,6 +709,7 @@ fn build_app_legal_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
legal: Some(AppLegalConfigUpdateRequest {
terms_url: optional("app_terms_url"),
privacy_url: optional("app_privacy_url"),
guidelines_url: optional("app_guidelines_url"),
}),
registration: None,
}),
@@ -838,7 +800,9 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
youtube: Some(InstanceYoutubeIntegrationUpdateRequest {
api_key: clean("integration_youtube_api_key"),
}),
email: Some(InstanceEmailIntegrationUpdateRequest {
email: (form.has_key_starting_with("integration_email_")
|| form.has_key_starting_with("integration_smtp_"))
.then(|| InstanceEmailIntegrationUpdateRequest {
enabled: Some(form.bool_value("integration_email_enabled")),
provider: Some("smtp".to_owned()),
from_email: clean("integration_email_from_email"),
@@ -1195,6 +1159,78 @@ pub async fn limit_config_post(
mod tests {
use super::*;
#[test]
fn build_app_legal_update_sends_the_guidelines_url_and_clears_blank_fields() {
let form = MultiValueForm::parse(
b"app_terms_url=+https%3A%2F%2Fexample.com%2Fterms+&app_privacy_url=&app_guidelines_url=https%3A%2F%2Fexample.com%2Frules",
);
let request = build_app_legal_update(&form);
let legal = request
.app_public
.expect("app public update")
.legal
.expect("legal update");
assert_eq!(
legal.terms_url,
Some(Some("https://example.com/terms".to_owned()))
);
assert_eq!(legal.privacy_url, Some(None));
assert_eq!(
legal.guidelines_url,
Some(Some("https://example.com/rules".to_owned()))
);
let body = serde_json::to_value(&legal).expect("serialize legal update");
assert_eq!(
body,
serde_json::json!({
"terms_url": "https://example.com/terms",
"privacy_url": null,
"guidelines_url": "https://example.com/rules"
})
);
let cleared = build_app_legal_update(&MultiValueForm::parse(
b"app_terms_url=&app_privacy_url=&app_guidelines_url=+",
));
let body = serde_json::to_value(cleared.app_public.expect("app public").legal)
.expect("serialize cleared legal update");
assert_eq!(
body,
serde_json::json!({"terms_url": null, "privacy_url": null, "guidelines_url": null})
);
}
#[test]
fn build_integrations_update_leaves_email_alone_when_its_fields_are_hidden() {
let hidden = build_integrations_update(&MultiValueForm::parse(
b"integration_klipy_api_key=&integration_youtube_api_key=",
));
let integrations = hidden.integrations.expect("integrations update");
assert!(integrations.email.is_none());
assert!(integrations.gif.is_some());
let shown = build_integrations_update(&MultiValueForm::parse(
b"integration_email_present=1&integration_smtp_host=smtp.example.com",
));
let email = shown
.integrations
.and_then(|integrations| integrations.email)
.expect("email update");
assert_eq!(email.enabled, Some(false));
let from_an_older_page = build_integrations_update(&MultiValueForm::parse(
b"integration_klipy_api_key=&integration_smtp_host=smtp.example.com",
));
let email = from_an_older_page
.integrations
.and_then(|integrations| integrations.email)
.expect("email update from a page without the presence marker");
assert_eq!(
email.smtp.and_then(|smtp| smtp.host).as_deref(),
Some("smtp.example.com")
);
}
#[test]
fn build_sso_update_keeps_repeated_allowed_domains() {
let form = MultiValueForm::parse(
@@ -1483,72 +1519,6 @@ mod tests {
);
}
#[test]
fn build_plutonium_page_update_reads_the_rollout_fields() {
let form = MultiValueForm::parse(
b"plutonium_page_enabled=true&plutonium_page_rollout_basis_points=%20500%20&plutonium_page_rollout_salt=%20plutonium-page-v2%20&plutonium_page_included_user_ids=1500000000000000001&plutonium_page_excluded_user_ids=1500000000000000002&plutonium_page_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&plutonium_page_include_premium_users=true",
);
let update = build_plutonium_page_update(&form)
.expect("valid form")
.plutonium_page
.expect("plutonium page update");
assert_eq!(update.enabled, Some(true));
assert_eq!(update.rollout_basis_points, Some(500));
assert_eq!(update.rollout_salt, Some("plutonium-page-v2".to_owned()));
assert_eq!(update.include_premium_users, Some(true));
assert_eq!(
update.included_guild_ids,
Some(vec![
"1500000000000000005".to_owned(),
"1500000000000000006".to_owned()
])
);
assert_eq!(
update.included_user_ids,
Some(vec!["1500000000000000001".to_owned()])
);
assert_eq!(
update.excluded_user_ids,
Some(vec!["1500000000000000002".to_owned()])
);
}
#[test]
fn build_plutonium_page_update_leaves_the_feature_inert_when_nothing_is_submitted() {
let form = MultiValueForm::parse(b"_csrf=token");
let request = build_plutonium_page_update(&form).expect("valid form");
assert_eq!(
serde_json::to_value(request).expect("serializable update"),
serde_json::json!({"plutonium_page": {
"enabled": false,
"included_user_ids": [],
"included_guild_ids": [],
"include_premium_users": false,
"excluded_user_ids": [],
}})
);
}
#[test]
fn build_plutonium_page_update_rejects_invalid_rollout_fields() {
for (form, message) in [
(
"plutonium_page_rollout_basis_points=10001",
"Rollout basis points must be a whole number between 0 and 10000",
),
(
"plutonium_page_included_guild_ids=1500000000000000005%0Anot-a-guild",
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
),
] {
let form = MultiValueForm::parse(form.as_bytes());
assert_eq!(
build_plutonium_page_update(&form).expect_err("invalid field"),
message
);
}
}
#[test]
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
let form = MultiValueForm::parse(b"_csrf=token");
+32 -6
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::plural::count_noun;
use crate::{
admin_flags,
api::client::{AdminApiClient, ApiError},
@@ -178,6 +179,22 @@ pub async fn dispatch(
"Failed to clear user fields",
)
}
"set_bot_status" => {
let val = form.bool_value("bot");
DispatchOutcome::from_result(
client.set_bot_status(user_id, val).await,
"Bot status updated successfully",
"Failed to update bot status",
)
}
"set_system_status" => {
let val = form.bool_value("system");
DispatchOutcome::from_result(
client.set_system_status(user_id, val).await,
"System status updated successfully",
"Failed to update system status",
)
}
"change_username" => {
let Some(username) = get("username") else {
return DispatchOutcome::error("Username is required");
@@ -243,8 +260,11 @@ pub async fn dispatch(
let Some(ip) = get("ip") else {
return DispatchOutcome::error("IP address is required");
};
let Ok(duration) = form.parse_value::<u32>("duration_hours") else {
return DispatchOutcome::error("Invalid ban duration");
};
DispatchOutcome::from_result(
client.ban_ip(&ip, None).await,
client.ban_ip(&ip, duration.unwrap_or(0), None).await,
"IP banned successfully",
"Failed to ban IP",
)
@@ -292,7 +312,7 @@ pub async fn dispatch(
};
if !form.bool_value("confirm") {
return DispatchOutcome::error(
"Confirm whose deletion you are cancelling before submitting",
"Confirm whose deletion you are canceling before submitting",
);
}
let Some(private_reason) = get("private_reason") else {
@@ -303,7 +323,7 @@ pub async fn dispatch(
.cancel_deletion(user_id, &expected, notify_user, Some(&private_reason))
.await
{
Ok(_) => DispatchOutcome::success("User deletion cancelled successfully"),
Ok(_) => DispatchOutcome::success("User deletion canceled successfully"),
Err(ApiError::Http { status: 409, .. }) => DispatchOutcome::error(
"The pending deletion changed since this page loaded. Reload and review.",
),
@@ -364,6 +384,11 @@ pub async fn dispatch(
"Password reset sent successfully",
"Failed to send password reset",
),
"revoke_recovery_kit" => DispatchOutcome::from_result(
client.revoke_recovery_kit(user_id).await,
"Recovery kit revoked",
"Failed to revoke recovery kit",
),
"remove_relationship" => {
let Some(target_id) = get("target_user_id").or_else(|| get("target_id")) else {
return DispatchOutcome::error("Target user ID is required");
@@ -411,8 +436,9 @@ pub async fn dispatch(
match client.delete_all_user_messages(user_id, dry_run).await {
Ok(response) if dry_run => DispatchOutcome {
flash: FlashData::success(format!(
"Dry run found {} messages across {} channels. Confirm to delete them permanently.",
response.message_count, response.channel_count
"Dry run found {} across {}. Confirm to delete them permanently.",
count_noun(response.message_count, "message", "messages"),
count_noun(response.channel_count, "channel", "channels")
)),
redirect_params: vec![
("delete_all_messages_dry_run".to_owned(), "true".to_owned()),
@@ -446,7 +472,7 @@ pub async fn dispatch(
}
"cancel_bulk_message_deletion" => DispatchOutcome::from_result(
client.cancel_bulk_message_deletion(user_id).await,
"Bulk message deletion cancelled successfully",
"Bulk message deletion canceled successfully",
"Failed to cancel bulk message deletion",
),
"message_shred" => {
+49 -20
View File
@@ -5,6 +5,7 @@ use crate::{
api::{
audit::SearchAuditLogsParams,
client::{AdminApiClient, ApiResultExt},
types::AccountIdentityMode,
},
config::AdminConfig,
templates::{
@@ -26,6 +27,7 @@ pub struct TabQuery {
pub delete_all_messages_message_count: Option<u64>,
}
#[allow(clippy::too_many_arguments)]
pub async fn render(
client: &AdminApiClient,
config: &AdminConfig,
@@ -34,6 +36,7 @@ pub async fn render(
tab: &str,
query: &TabQuery,
admin_acls: &[String],
account_identity: AccountIdentityMode,
) -> Option<maud::Markup> {
match tab {
"overview" => {
@@ -60,31 +63,22 @@ pub async fn render(
csrf_token,
change_log.as_ref(),
limit_config.as_ref(),
account_identity.is_username(),
))
}
"account" => {
let u = client
.get_user_by_id(user_id)
.await
.log_error("load user account")?;
let s = client
.list_user_sessions(user_id)
.await
.map(|r| r.sessions)
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list user sessions"))
.unwrap_or_default();
let webauthn_credentials = client
.list_webauthn_credentials(user_id)
.await
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials"))
.unwrap_or_default();
Some(tabs::account::account_tab(
render_account(
client,
config,
&u,
&s,
&webauthn_credentials,
csrf_token,
))
user_id,
&tabs::account::AccountTabOptions {
admin_acls,
account_identity,
password_reset_link: None,
},
)
.await
}
"moderation" => {
let u = client
@@ -145,6 +139,7 @@ pub async fn render(
let context = tabs::moderation::ModerationContext {
deletion_scheduler: deletion_scheduler.as_ref(),
current_ban: tabs::moderation::find_current_ban(&u, &ban_logs),
username_sign_in: account_identity.is_username(),
};
Some(tabs::moderation::moderation_tab(
config,
@@ -298,6 +293,40 @@ pub async fn render(
}
}
pub async fn render_account(
client: &AdminApiClient,
config: &AdminConfig,
csrf_token: &str,
user_id: &str,
options: &tabs::account::AccountTabOptions<'_>,
) -> Option<maud::Markup> {
let u = client
.get_user_by_id(user_id)
.await
.log_error("load user account")?;
let s = client
.list_user_sessions(user_id)
.await
.map(|r| r.sessions)
.map_err(
|error| tracing::warn!(%error, user_id, "admin API request failed: list user sessions"),
)
.unwrap_or_default();
let webauthn_credentials = client
.list_webauthn_credentials(user_id)
.await
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials"))
.unwrap_or_default();
Some(tabs::account::account_tab(
config,
&u,
&s,
&webauthn_credentials,
csrf_token,
options,
))
}
fn parse_bool_flag(value: &str) -> Option<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" => Some(true),
+104 -10
View File
@@ -9,7 +9,12 @@ use crate::{
middleware::{auth::AuthContext, csrf::CsrfToken, flash, htmx},
routes::user_tabs,
state::AppState,
templates,
templates::{
self,
pages::user_detail_tabs::account::{
PASSWORD_RESET_LINK_RESULT_ID, password_reset_link_result,
},
},
utils::forms::MultiValueForm,
};
use axum::{
@@ -86,8 +91,9 @@ async fn users_list(
.as_ref()
.map(|user| user.acls.as_slice())
.unwrap_or(&[]);
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let username_sign_in = state.account_identity(&client).await.is_username();
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL) && !username_sign_in;
let searching = params.has_id_lookup() || params.has_search();
let results = async {
if params.has_id_lookup() {
@@ -136,6 +142,7 @@ async fn users_list(
result_users,
has_more,
can_view_email,
username_sign_in,
premium_badge_name.as_deref(),
is_results_fragment,
);
@@ -204,8 +211,16 @@ async fn user_detail(
.map(|user| user.acls.as_slice())
.unwrap_or(&[]);
let tab_body = if user.is_some() {
let account_identity = state.account_identity(&client).await;
user_tabs::render(
&client, config, &csrf.0.0, &user_id, active_tab, &tq, admin_acls,
&client,
config,
&csrf.0.0,
&user_id,
active_tab,
&tq,
admin_acls,
account_identity,
)
.await
} else {
@@ -229,6 +244,7 @@ async fn user_detail_post(
State(state): State<AppState>,
headers: HeaderMap,
auth: axum::Extension<AuthContext>,
csrf: axum::Extension<CsrfToken>,
Path(user_id): Path<String>,
Query(aq): Query<ActionQuery>,
request: Request,
@@ -252,6 +268,10 @@ async fn user_detail_post(
};
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let action = aq.action.as_deref().unwrap_or("");
if action == "create_password_reset_link" {
return create_password_reset_link(&state, &headers, &auth.0, &csrf.0.0, &client, &user_id)
.await;
}
let outcome = super::user_actions::dispatch(&client, &user_id, action, &form).await;
let mut redirect = if tab.is_empty() {
format!("{base}/users/{user_id}")
@@ -268,6 +288,74 @@ async fn user_detail_post(
flash::redirect_with_flash(&redirect, outcome.flash, config.secure_cookies())
}
async fn create_password_reset_link(
state: &AppState,
headers: &HeaderMap,
auth: &AuthContext,
csrf_token: &str,
client: &AdminApiClient,
user_id: &str,
) -> Response {
let config = state.config();
let account_url = format!("{}/users/{user_id}?tab=account", config.base_path);
let link = match client.create_password_reset_link(user_id).await {
Ok(link) => link,
Err(error) => {
tracing::warn!(%error, user_id, "admin API request failed: create password reset link");
let flash = flash::FlashData::error("Failed to create password reset link");
if htmx::is_htmx_request(headers)
&& (htmx::targets(headers, "flash-container")
|| htmx::targets(headers, PASSWORD_RESET_LINK_RESULT_ID))
{
return htmx::toast_response(&flash);
}
return flash::redirect_with_flash(&account_url, flash, config.secure_cookies());
}
};
if htmx::is_htmx_request(headers) && htmx::targets(headers, PASSWORD_RESET_LINK_RESULT_ID) {
return Html(password_reset_link_result(Some(&link)).into_string()).into_response();
}
let admin_acls = auth
.admin_user
.as_ref()
.map(|user| user.acls.as_slice())
.unwrap_or(&[]);
let (user, badge_name, account_identity) = tokio::join!(
async {
client
.get_user_by_id(user_id)
.await
.log_error("load user after creating password reset link")
},
self_hosted_premium_badge_name(state, client),
state.account_identity(client)
);
let tab_body = user_tabs::render_account(
client,
config,
csrf_token,
user_id,
&templates::pages::user_detail_tabs::account::AccountTabOptions {
admin_acls,
account_identity,
password_reset_link: Some(&link),
},
)
.await;
let premium_badge_name = user.as_ref().and(badge_name);
let markup = templates::pages::user_detail::user_detail_with_tab(
config,
auth,
user.as_ref(),
user_id,
"account",
tab_body,
premium_badge_name.as_deref(),
htmx::targets(headers, "main-content"),
);
Html(markup.into_string()).into_response()
}
async fn user_tab(
State(state): State<AppState>,
auth: axum::Extension<AuthContext>,
@@ -299,14 +387,20 @@ async fn user_tab(
.as_ref()
.map(|user| user.acls.as_slice())
.unwrap_or(&[]);
let account_identity = state.account_identity(&client).await;
let markup = match user {
Some(ref u) => {
user_tabs::render(&client, config, &csrf.0.0, &user_id, &tab, &tq, admin_acls)
.await
.unwrap_or_else(|| {
templates::pages::user_detail::simple_tab_content(config, u, &tab)
})
}
Some(ref u) => user_tabs::render(
&client,
config,
&csrf.0.0,
&user_id,
&tab,
&tq,
admin_acls,
account_identity,
)
.await
.unwrap_or_else(|| templates::pages::user_detail::simple_tab_content(config, u, &tab)),
None => maud::html! {
div class="p-4 text-red-600 text-sm" { "Failed to load user data." }
},
+77 -1
View File
@@ -3,7 +3,7 @@
use crate::{
api::{
client::{AdminApiClient, ApiResultExt},
types::PremiumBranding,
types::{AccountIdentityMode, AccountIdentitySettings, PremiumBranding, ReportReasonEntry},
},
config::AdminConfig,
};
@@ -13,6 +13,8 @@ use std::{
};
const PREMIUM_BRANDING_TTL: Duration = Duration::from_secs(60);
const ACCOUNT_IDENTITY_TTL: Duration = Duration::from_secs(60);
const ACCOUNT_IDENTITY_RETRY_TTL: Duration = Duration::from_secs(10);
#[derive(Clone)]
pub struct AppState {
@@ -23,6 +25,8 @@ struct AppStateInner {
pub config: AdminConfig,
pub http_client: reqwest::Client,
premium_branding: Mutex<Option<(Instant, PremiumBranding)>>,
account_identity: Mutex<Option<(Instant, AccountIdentitySettings)>>,
report_reasons: Mutex<Option<Arc<[ReportReasonEntry]>>>,
}
impl AppState {
@@ -36,6 +40,8 @@ impl AppState {
config,
http_client,
premium_branding: Mutex::new(None),
account_identity: Mutex::new(None),
report_reasons: Mutex::new(None),
}),
}
}
@@ -81,6 +87,76 @@ impl AppState {
self.remember_premium_branding(branding.clone());
Some(branding)
}
pub async fn account_identity(&self, client: &AdminApiClient) -> AccountIdentityMode {
self.account_identity_settings(client).await.mode
}
pub async fn account_identity_settings(
&self,
client: &AdminApiClient,
) -> AccountIdentitySettings {
if !self.config().self_hosted {
return AccountIdentitySettings::default();
}
let previous = *self
.inner
.account_identity
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some((expires_at, settings)) = previous
&& Instant::now() < expires_at
{
return settings;
}
let (settings, ttl) = match client
.get_instance_account_identity()
.await
.log_error("load account identity mode")
{
Some(settings) => (settings, ACCOUNT_IDENTITY_TTL),
None => (
previous.map_or(AccountIdentitySettings::default(), |(_, settings)| settings),
ACCOUNT_IDENTITY_RETRY_TTL,
),
};
*self
.inner
.account_identity
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) =
Some((Instant::now() + ttl, settings));
settings
}
pub async fn report_reasons(
&self,
client: &AdminApiClient,
) -> Option<Arc<[ReportReasonEntry]>> {
if let Some(reasons) = self.cached_report_reasons() {
return Some(reasons);
}
let reasons: Arc<[ReportReasonEntry]> = client
.list_report_reasons()
.await
.log_error("load report reasons")?
.reasons
.into();
*self
.inner
.report_reasons
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(reasons.clone());
Some(reasons)
}
fn cached_report_reasons(&self) -> Option<Arc<[ReportReasonEntry]>> {
self.inner
.report_reasons
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone()
}
}
impl axum::extract::FromRef<AppState> for AdminConfig {
+6 -29
View File
@@ -35,6 +35,12 @@ h1, h2, h3, h4, h5, h6 {
input, select, textarea {
font-size: 16px;
}
select,
input:not([type="checkbox"], [type="radio"], [type="hidden"], [type="range"], [type="color"], [type="file"]) {
height: auto;
min-height: 2.5rem;
}
}
a:focus-visible,
@@ -71,33 +77,6 @@ main:focus {
outline-offset: 2px;
}
.flash-success {
border: 1px solid #86efac;
background-color: #f0fdf4;
color: #166534;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
font-size: 0.875rem;
}
.flash-error {
border: 1px solid #fca5a5;
background-color: #fef2f2;
color: #991b1b;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
font-size: 0.875rem;
}
.flash-info {
border: 1px solid #93c5fd;
background-color: #eff6ff;
color: #1e40af;
border-radius: 0.5rem;
padding: 0.75rem 1rem;
font-size: 0.875rem;
}
.drawer-panel {
inset: unset;
margin: 0;
@@ -109,8 +88,6 @@ main:focus {
}
.drawer-panel[data-drawer-side="right"] { right: 0; top: 0; }
.drawer-panel[data-drawer-side="left"] { left: 0; top: 0; }
.drawer-panel[data-drawer-side="bottom"] { bottom: 0; left: 0; right: 0; }
.drawer-panel[data-open],
.drawer-panel:popover-open {
@@ -5,16 +5,12 @@ use maud::{Markup, html};
#[derive(Clone, Copy)]
pub enum AlertVariant {
Info,
Warning,
Error,
Success,
}
pub fn alert(variant: AlertVariant, title: Option<&str>, content: Markup) -> Markup {
let variant_classes = match variant {
AlertVariant::Info => "bg-blue-50 border-blue-200 text-blue-700",
AlertVariant::Warning => "bg-neutral-50 border-neutral-200 text-neutral-700",
AlertVariant::Error => "bg-red-50 border-red-200 text-red-700",
AlertVariant::Success => "bg-green-50 border-green-200 text-green-700",
};
html! {
@@ -30,15 +26,3 @@ pub fn alert(variant: AlertVariant, title: Option<&str>, content: Markup) -> Mar
pub fn alert_info(content: Markup) -> Markup {
alert(AlertVariant::Info, None, content)
}
pub fn alert_error(title: &str, content: Markup) -> Markup {
alert(AlertVariant::Error, Some(title), content)
}
pub fn alert_success(title: &str, content: Markup) -> Markup {
alert(AlertVariant::Success, Some(title), content)
}
pub fn alert_warning(title: &str, content: Markup) -> Markup {
alert(AlertVariant::Warning, Some(title), content)
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::Archive,
templates::components::form::{checkbox, csrf_input, submit_button},
@@ -72,7 +73,7 @@ fn archives_table(base: &str, archives: &[Archive]) -> Markup {
@for archive in archives {
tr {
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
(archive.requested_at)
(format_admin_timestamp(&archive.requested_at))
}
td class="px-4 py-3 text-sm text-neutral-900" {
(status_text(archive))
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::AuditLogEntry,
templates::pages::audit_logs_table::{
@@ -27,7 +28,7 @@ fn log_row(base_path: &str, log: &AuditLogEntry, index: usize) -> Markup {
(table_row(html! {
(table_cell(true, html! {
span class="text-sm whitespace-nowrap" {
(log.created_at)
(format_admin_timestamp(&log.created_at))
}
}))
(table_cell(false, badge(&format_action(action), action_badge_variant(action))))
@@ -12,7 +12,3 @@ pub fn auto_refresh(enabled: bool, interval_ms: u32) -> Markup {
script defer { (PreEscaped(script)) }
}
}
pub fn auto_refresh_default(enabled: bool) -> Markup {
auto_refresh(enabled, 3000)
}
@@ -29,11 +29,3 @@ pub fn badge(label: &str, variant: BadgeVariant) -> Markup {
}
}
}
pub fn flag_badge(label: &str, active: bool) -> Markup {
if active {
badge(label, BadgeVariant::Success)
} else {
badge(label, BadgeVariant::Default)
}
}
@@ -44,10 +44,6 @@ pub fn data_field_mono(label: &str, value: &str) -> Markup {
data_field(label, data_field_value(value, DATA_FIELD_MONO_CLASS))
}
pub fn data_field_link(label: &str, href: &str, display: &str) -> Markup {
data_field(label, data_field_link_value(href, display, "break-words"))
}
pub fn data_field_link_mono(label: &str, href: &str, display: &str) -> Markup {
data_field(label, data_field_link_value(href, display, "break-all"))
}
@@ -66,20 +62,3 @@ pub fn data_grid(cols: u8, content: Markup) -> Markup {
div class=(col_class) { (content) }
}
}
pub fn metadata_row(label: &str, value: Markup) -> Markup {
html! {
div class="flex flex-wrap gap-x-2 gap-y-0.5" {
span class="flex-shrink-0 text-neutral-500 text-sm" {
(label) ":"
}
span class="min-w-0 break-words text-neutral-900 text-sm" {
(value)
}
}
}
}
pub fn metadata_row_text(label: &str, value: &str) -> Markup {
metadata_row(label, html! { (value) })
}
@@ -7,22 +7,16 @@ use super::icons::{close_icon, spinner_icon};
#[derive(Clone, Copy)]
pub enum DrawerSide {
Right,
Left,
Bottom,
}
#[derive(Clone, Copy)]
pub enum DrawerWidth {
Sm,
Md,
Lg,
Xl,
}
fn width_class(width: DrawerWidth) -> &'static str {
match width {
DrawerWidth::Sm => "sm:max-w-sm",
DrawerWidth::Md => "sm:max-w-md",
DrawerWidth::Lg => "sm:max-w-lg",
DrawerWidth::Xl => "sm:max-w-xl",
}
@@ -31,24 +25,18 @@ fn width_class(width: DrawerWidth) -> &'static str {
fn side_class(side: DrawerSide) -> &'static str {
match side {
DrawerSide::Right => "right-0 top-0 h-[100dvh] w-full sm:w-[92vw]",
DrawerSide::Left => "left-0 top-0 h-[100dvh] w-full sm:w-[92vw]",
DrawerSide::Bottom => "bottom-0 left-0 right-0 max-h-[92dvh] w-full",
}
}
fn side_off_class(side: DrawerSide) -> &'static str {
match side {
DrawerSide::Right => "translate-x-full",
DrawerSide::Left => "-translate-x-full",
DrawerSide::Bottom => "translate-y-full",
}
}
fn side_name(side: DrawerSide) -> &'static str {
match side {
DrawerSide::Right => "right",
DrawerSide::Left => "left",
DrawerSide::Bottom => "bottom",
}
}
@@ -68,10 +56,7 @@ pub fn drawer(
"drawer-panel pointer-events-auto fixed z-50 flex flex-col \
bg-white shadow-2xl {} {} {}",
side_class(side),
match side {
DrawerSide::Bottom => "",
_ => width_class(width),
},
width_class(width),
side_off_class(side),
);
html! {
@@ -150,16 +135,6 @@ pub fn drawer_loading_state() -> Markup {
}
}
pub fn drawer_error_state(message: &str) -> Markup {
html! {
div role="alert"
class="rounded-lg border border-red-200 bg-red-50 p-4 \
text-red-800 text-sm" {
(message)
}
}
}
pub fn drawer_controller_script() -> Markup {
html! {
script defer { (PreEscaped(DRAWER_CONTROLLER_SCRIPT)) }
@@ -2,25 +2,6 @@
use maud::{Markup, html};
#[derive(Clone, Copy)]
pub enum EmptyStateVariant {
Empty,
Loading,
Error,
}
pub fn empty_state(variant: EmptyStateVariant, content: Markup) -> Markup {
let classes = match variant {
EmptyStateVariant::Empty | EmptyStateVariant::Loading => {
"text-neutral-500 text-center py-8"
}
EmptyStateVariant::Error => "text-red-600 text-center py-8",
};
html! {
div class=(classes) { (content) }
}
}
pub fn empty_state_full(icon: Option<Markup>, title: &str, description: Option<&str>) -> Markup {
html! {
div class="flex flex-col items-center justify-center py-12 text-center" {
@@ -62,11 +43,3 @@ pub fn not_found_state(
}
}
}
pub fn empty_state_text(message: &str) -> Markup {
html! {
div class="text-neutral-500 text-center py-8" {
(message)
}
}
}
@@ -9,14 +9,3 @@ pub fn error_alert(error: &str) -> Markup {
}
}
}
pub fn error_card(title: &str, message: &str) -> Markup {
html! {
div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm sm:p-6" {
div class="flex flex-col gap-4" {
h3 class="text-base font-semibold text-gray-900" { (title) }
p class="text-sm text-neutral-500" { (message) }
}
}
}
}
@@ -151,10 +151,6 @@ pub fn danger_button(label: &str) -> Markup {
button_markup(label, "submit", DANGER_BUTTON_CLASS)
}
pub fn secondary_button(label: &str) -> Markup {
button_markup(label, "button", SECONDARY_BUTTON_CLASS)
}
pub fn form_field_group(
label: &str,
name: &str,
@@ -222,7 +218,7 @@ pub fn checkbox(name: &str, value: &str, label: &str, checked: bool, enabled: bo
div class="checkbox-custom" {
(PreEscaped(CHECKMARK_SVG))
}
span class="min-w-0 break-all text-sm leading-5 text-neutral-900" { (label) }
span class="min-w-0 [overflow-wrap:anywhere] text-sm leading-5 text-neutral-900" { (label) }
}
}
}
@@ -23,39 +23,6 @@ pub fn paperclip_icon(color: &str) -> Markup {
}
}
pub fn checkmark_icon(color: &str) -> Markup {
html! {
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"
class={"inline-block h-4 w-4 " (color)}
{
(PreEscaped(concat!(
r#"<rect width="256" height="256" fill="none"/>"#,
r#"<polyline points="40 144 96 200 224 72" fill="none""#,
r#" stroke="currentColor" stroke-linecap="round""#,
r#" stroke-linejoin="round" stroke-width="24"/>"#,
)))
}
}
}
pub fn x_icon(color: &str) -> Markup {
html! {
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"
class={"inline-block h-4 w-4 " (color)}
{
(PreEscaped(concat!(
r#"<rect width="256" height="256" fill="none"/>"#,
r#"<line x1="200" y1="56" x2="56" y2="200" fill="none""#,
r#" stroke="currentColor" stroke-linecap="round""#,
r#" stroke-linejoin="round" stroke-width="24"/>"#,
r#"<line x1="200" y1="200" x2="56" y2="56" fill="none""#,
r#" stroke="currentColor" stroke-linecap="round""#,
r#" stroke-linejoin="round" stroke-width="24"/>"#,
)))
}
}
}
pub fn close_icon() -> Markup {
html! {
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
@@ -82,63 +49,3 @@ pub fn spinner_icon() -> Markup {
}
}
}
pub fn search_icon() -> Markup {
html! {
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
class="h-4 w-4" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true"
{
(PreEscaped(concat!(
r#"<circle cx="11" cy="11" r="8"/>"#,
r#"<line x1="21" y1="21" x2="16.65" y2="16.65"/>"#,
)))
}
}
}
pub fn chevron_right_icon() -> Markup {
html! {
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
class="h-4 w-4" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true"
{
(PreEscaped(r#"<polyline points="9 18 15 12 9 6"/>"#))
}
}
}
pub fn external_link_icon() -> Markup {
html! {
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
class="h-3.5 w-3.5" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true"
{
(PreEscaped(concat!(
r#"<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8"#,
r#"a2 2 0 0 1 2-2h6"/>"#,
r#"<polyline points="15 3 21 3 21 9"/>"#,
r#"<line x1="10" y1="14" x2="21" y2="3"/>"#,
)))
}
}
}
pub fn copy_icon() -> Markup {
html! {
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
class="h-3.5 w-3.5" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
aria-hidden="true"
{
(PreEscaped(concat!(
r#"<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>"#,
r#"<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9"#,
r#"a2 2 0 0 1 2 2v1"/>"#,
)))
}
}
}
@@ -12,9 +12,14 @@ pub struct Attachment {
pub width: Option<u32>,
pub height: Option<u32>,
pub size: Option<u64>,
pub ncmec_status: String,
pub ncmec_report_id: Option<String>,
pub ncmec_failure_reason: Option<String>,
}
#[derive(Debug, PartialEq)]
pub struct MissingAttachment {
pub id: String,
pub filename: String,
pub content_type: Option<String>,
pub size: Option<u64>,
}
#[derive(Debug, PartialEq)]
@@ -27,12 +32,15 @@ pub struct Message {
pub author_global_name: Option<String>,
pub author_discriminator: String,
pub author_avatar: Option<String>,
pub webhook_id: Option<String>,
pub author_bot: Option<bool>,
pub channel_id: String,
pub channel_nsfw: Option<bool>,
pub channel_content_warning_level: Option<i32>,
pub channel_content_warning_text: Option<String>,
pub guild_nsfw: Option<bool>,
pub attachments: Vec<Attachment>,
pub missing_attachments: Vec<MissingAttachment>,
}
pub fn ordered_messages(values: &[Value]) -> Vec<Message> {
@@ -48,6 +56,12 @@ fn message_from_value(value: &Value) -> Message {
.flatten()
.map(attachment_from_value)
.collect();
let missing_attachments = value["missing_attachments"]
.as_array()
.into_iter()
.flatten()
.map(missing_attachment_from_value)
.collect();
Message {
id: value_id(&value["id"]).unwrap_or_default(),
content: value["content"].as_str().unwrap_or("").to_owned(),
@@ -61,6 +75,8 @@ fn message_from_value(value: &Value) -> Message {
author_discriminator: value_id(&value["author_discriminator"])
.unwrap_or_else(|| "0000".to_owned()),
author_avatar: value["author_avatar"].as_str().map(ToOwned::to_owned),
webhook_id: value_id(&value["webhook_id"]),
author_bot: value["author_bot"].as_bool(),
channel_id: value_id(&value["channel_id"]).unwrap_or_default(),
channel_nsfw: value["channel_nsfw"].as_bool(),
channel_content_warning_level: value["channel_content_warning_level"]
@@ -71,6 +87,16 @@ fn message_from_value(value: &Value) -> Message {
.map(ToOwned::to_owned),
guild_nsfw: value["guild_nsfw"].as_bool(),
attachments,
missing_attachments,
}
}
fn missing_attachment_from_value(value: &Value) -> MissingAttachment {
MissingAttachment {
id: value_id(&value["id"]).unwrap_or_default(),
filename: value["filename"].as_str().unwrap_or("").to_owned(),
content_type: value["content_type"].as_str().map(ToOwned::to_owned),
size: value["size"].as_u64(),
}
}
@@ -84,14 +110,6 @@ fn attachment_from_value(value: &Value) -> Attachment {
width: value["width"].as_u64().map(|n| n as u32),
height: value["height"].as_u64().map(|n| n as u32),
size: value["size"].as_u64(),
ncmec_status: value["ncmec_status"]
.as_str()
.unwrap_or("not_submitted")
.to_owned(),
ncmec_report_id: value["ncmec_report_id"].as_str().map(ToOwned::to_owned),
ncmec_failure_reason: value["ncmec_failure_reason"]
.as_str()
.map(ToOwned::to_owned),
}
}
@@ -139,10 +157,7 @@ mod tests {
"content_type": "image/png",
"width": 640,
"height": 480,
"size": 4096,
"ncmec_status": "submitted",
"ncmec_report_id": "report-id",
"ncmec_failure_reason": "previous failure"
"size": 4096
}]
});
@@ -157,6 +172,8 @@ mod tests {
author_global_name: Some("Alice".into()),
author_discriminator: "1234".into(),
author_avatar: Some("avatar-hash".into()),
webhook_id: None,
author_bot: None,
channel_id: "100".into(),
channel_nsfw: Some(false),
channel_content_warning_level: Some(2),
@@ -171,14 +188,70 @@ mod tests {
width: Some(640),
height: Some(480),
size: Some(4096),
ncmec_status: "submitted".into(),
ncmec_report_id: Some("report-id".into()),
ncmec_failure_reason: Some("previous failure".into()),
}],
missing_attachments: vec![],
}
);
}
#[test]
fn maps_author_bot_flags() {
let bot = message_from_value(&json!({"author_id": "42", "author_bot": true}));
assert_eq!(bot.author_bot, Some(true));
let human = message_from_value(&json!({"author_id": "43", "author_bot": false}));
assert_eq!(human.author_bot, Some(false));
let webhook = message_from_value(
&json!({"author_id": "500", "webhook_id": "500", "author_bot": null}),
);
assert_eq!(webhook.author_bot, None);
let old = message_from_value(&json!({"author_id": "44"}));
assert_eq!(old.author_bot, None);
}
#[test]
fn maps_missing_attachments() {
let message = message_from_value(&json!({
"id": "10",
"attachments": [],
"missing_attachments": [{
"id": 9007199254740993_u64,
"filename": "evidence.png",
"nsfw": null,
"content_type": "image/png",
"width": 640,
"height": 480,
"size": 4096
}]
}));
assert!(message.attachments.is_empty());
assert_eq!(
message.missing_attachments,
vec![MissingAttachment {
id: "9007199254740993".into(),
filename: "evidence.png".into(),
content_type: Some("image/png".into()),
size: Some(4096),
}]
);
let old = message_from_value(&json!({"id": "11", "attachments": []}));
assert!(old.missing_attachments.is_empty());
let null = message_from_value(&json!({"id": "12", "missing_attachments": null}));
assert!(null.missing_attachments.is_empty());
}
#[test]
fn maps_webhook_authors() {
let webhook = message_from_value(&json!({
"author_id": "500",
"author_username": "Harbor Bulletin",
"webhook_id": "500"
}));
assert_eq!(webhook.webhook_id.as_deref(), Some("500"));
assert_eq!(webhook.author_id, "500");
let user = message_from_value(&json!({"author_id": "42", "webhook_id": null}));
assert_eq!(user.webhook_id, None);
}
#[test]
fn retains_display_defaults_for_incomplete_snapshots() {
let message = message_from_value(&json!({"attachments": [{}]}));
@@ -187,6 +260,7 @@ mod tests {
assert_eq!(message.author_discriminator, "0000");
assert_eq!(message.content, "");
assert_eq!(message.author_global_name, None);
assert_eq!(message.webhook_id, None);
assert_eq!(message.channel_nsfw, None);
assert_eq!(
message.attachments,
@@ -199,9 +273,6 @@ mod tests {
width: None,
height: None,
size: None,
ncmec_status: "not_submitted".into(),
ncmec_report_id: None,
ncmec_failure_reason: None,
}]
);
}
@@ -2,12 +2,14 @@
use maud::{Markup, PreEscaped, html};
use super::badge::{BadgeVariant, badge};
use super::icons::paperclip_icon;
use super::media::user_avatar_url;
use super::nsfw_indicators::{attachment_nsfw_badge, channel_nsfw_state_badge};
use super::user_display::format_user_display;
use crate::config::AdminConfig;
use crate::routes::auth::json_string;
use crate::utils::timestamps::format_admin_timestamp;
use super::message_data::{Attachment, Message};
@@ -17,33 +19,6 @@ fn is_image(att: &Attachment) -> bool {
.is_some_and(|ct| ct.starts_with("image/"))
}
fn ncmec_badge(att: &Attachment) -> Markup {
match att.ncmec_status.as_str() {
"submitted" => {
let label = att
.ncmec_report_id
.as_deref()
.map(|id| format!("NCMEC {id}"))
.unwrap_or_else(|| "Reported to NCMEC".into());
html! {
span class="rounded bg-green-100 px-2 py-0.5 text-[11px] text-green-800"
title=(label) { (label) }
}
}
"failed" => {
let title = att
.ncmec_failure_reason
.as_deref()
.unwrap_or("NCMEC report failed");
html! {
span class="rounded bg-red-100 px-2 py-0.5 text-[11px] text-red-800"
title=(title) { "NCMEC Failed" }
}
}
_ => html! {},
}
}
fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
let mut images = msg.attachments.iter().filter(|a| is_image(a)).peekable();
if images.peek().is_none() {
@@ -70,7 +45,6 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
a href=(att.url) target="_blank" rel="noopener noreferrer"
class="font-medium text-blue-600 hover:underline" { (att.filename) }
(attachment_nsfw_badge(att.nsfw.unwrap_or(false)))
(ncmec_badge(att))
}
@if include_delete {
div class="flex flex-wrap gap-2" {
@@ -80,32 +54,6 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
transition-colors hover:bg-red-50 hover:text-red-700"
data-channel-id=(msg.channel_id)
data-message-id=(msg.id) { "Delete" }
button type="button"
class="ncmec-report-btn rounded bg-white px-2.5 py-1 text-xs \
shadow-sm ring-1 ring-neutral-200 transition-colors \
hover:bg-neutral-100 disabled:cursor-not-allowed \
disabled:opacity-70"
data-channel-id=(msg.channel_id)
data-message-id=(msg.id)
data-attachment-id=(att.id)
data-filename=(att.filename)
data-content-type=(att.content_type.as_deref().unwrap_or(""))
data-size=(att.size.map(|s| s.to_string()).unwrap_or_default())
data-author-id=(msg.author_id)
data-ncmec-status=(att.ncmec_status)
data-ncmec-report-id=(att.ncmec_report_id.as_deref().unwrap_or(""))
disabled[att.ncmec_status == "submitted"]
title=(if att.ncmec_status == "submitted" {
"Already reported to NCMEC"
} else {
"Report this image to NCMEC"
}) {
@if att.ncmec_status == "submitted" {
"Reported to NCMEC"
} @else {
"Report to NCMEC"
}
}
}
}
}
@@ -133,7 +81,29 @@ fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Marku
a href=(att.url) target="_blank" rel="noopener noreferrer"
class="text-blue-600 hover:underline" { (att.filename) }
(attachment_nsfw_badge(att.nsfw.unwrap_or(false)))
(ncmec_badge(att))
}
}
}
}
}
fn render_missing_attachments(msg: &Message) -> Markup {
if msg.missing_attachments.is_empty() {
return html! {};
}
html! {
div class="mt-1.5 space-y-1" {
@for att in &msg.missing_attachments {
div class="flex flex-wrap items-center gap-2 text-amber-800 text-xs"
data-missing-attachment=(att.id) {
(paperclip_icon("text-amber-500"))
span class="break-words" {
@if att.filename.is_empty() {
"An attachment was not preserved in the report snapshot"
} @else {
(att.filename) " was not preserved in the report snapshot"
}
}
}
}
}
@@ -179,6 +149,10 @@ fn message_row(
(render_image_attachments(msg, include_delete))
(render_other_attachments(msg, has_content_or_images))
}
(render_missing_attachments(msg))
div class="mt-1 text-neutral-400 text-xs" {
span class="" { (msg.id) }
}
@if include_delete && !has_images {
div class="absolute top-0 right-2 hidden group-hover:block" {
button type="button"
@@ -198,6 +172,7 @@ fn message_row(
msg.author_global_name.as_deref(),
Some(&msg.author_username),
None,
false,
);
let row_class = format!(
"group relative mt-4 py-0.5 pr-4 pl-4 transition-colors first:mt-0{hover}{highlight}"
@@ -207,20 +182,39 @@ fn message_row(
style="display:grid;grid-template-columns:16px 40px 16px minmax(0,1fr);"
data-message-id=(msg.id) data-message-row="" {
div style="grid-row:1;grid-column:1;" {}
a href={(base_path) "/users/" (msg.author_id)}
title=(msg.author_id) class="block flex-shrink-0"
style="grid-row:1;grid-column:2;align-self:start;" {
img src=(avatar_url) alt=(msg.author_username)
class="rounded-full" style="width:40px;height:40px;";
@if let Some(webhook_id) = &msg.webhook_id {
span title=(webhook_id) class="block flex-shrink-0"
style="grid-row:1;grid-column:2;align-self:start;" {
img src=(avatar_url) alt=(msg.author_username)
class="rounded-full" style="width:40px;height:40px;";
}
} @else {
a href={(base_path) "/users/" (msg.author_id)}
title=(msg.author_id) class="block flex-shrink-0"
style="grid-row:1;grid-column:2;align-self:start;" {
img src=(avatar_url) alt=(msg.author_username)
class="rounded-full" style="width:40px;height:40px;";
}
}
div style="grid-row:1;grid-column:3;" {}
div class="min-w-0" style="grid-column:4;" {
div class="flex items-baseline gap-2" {
a href={(base_path) "/users/" (msg.author_id)}
class="font-medium text-neutral-900 text-sm hover:underline"
title=(msg.author_id) { (tag) }
@if let Some(webhook_id) = &msg.webhook_id {
span class="font-medium text-neutral-900 text-sm"
title=(webhook_id) data-message-webhook=(webhook_id) { (msg.author_username) }
span class="self-center" { (badge("Webhook", BadgeVariant::Info)) }
} @else {
a href={(base_path) "/users/" (msg.author_id)}
class="font-medium text-neutral-900 text-sm hover:underline"
title=(msg.author_id) { (tag) }
@if msg.author_bot == Some(true) {
span class="self-center" data-message-author-bot=(msg.author_id) {
(badge("Bot", BadgeVariant::Info))
}
}
}
span class="text-neutral-400 text-xs" {
" \u{2014} " (msg.timestamp)
" \u{2014} " (format_admin_timestamp(&msg.timestamp))
}
(channel_nsfw_state_badge(
msg.channel_nsfw.unwrap_or(false),
@@ -238,6 +232,7 @@ fn message_row(
(render_image_attachments(msg, include_delete))
(render_other_attachments(msg, has_content_or_images))
}
(render_missing_attachments(msg))
div class="mt-1 text-neutral-400 text-xs" {
span class="" { (msg.id) }
}
@@ -266,7 +261,9 @@ pub fn message_list(
html! {
div class="divide-y-0" {
@for (i, msg) in messages.iter().enumerate() {
@let is_grouped = i > 0 && messages[i - 1].author_id == msg.author_id;
@let is_grouped = i > 0
&& messages[i - 1].author_id == msg.author_id
&& messages[i - 1].author_username == msg.author_username;
@let is_highlighted = highlight_message_id == Some(msg.id.as_str());
@let avatar_url = user_avatar_url(
config, &msg.author_id, msg.author_avatar.as_deref(), 160, true,
@@ -321,37 +318,6 @@ pub fn message_deletion_script(csrf_token: &str) -> Markup {
toast('error', 'Failed to delete message.');
});
}
function reportNcmec(b) {
var name = prompt('Type your full name to confirm you personally viewed this image and want to submit it to NCMEC.');
if (!name || !name.trim()) return;
var fields = new URLSearchParams();
fields.append('channel_id', b.dataset.channelId || '');
fields.append('message_id', b.dataset.messageId || '');
fields.append('attachment_id', b.dataset.attachmentId || '');
fields.append('filename', b.dataset.filename || '');
fields.append('reporter_full_name', name.trim());
fields.append('confirmed_viewed', 'true');
b.disabled = true;
b.textContent = 'Reporting...';
toast('info', 'Submitting NCMEC report...');
post('report-to-ncmec', fields).then(function(r) {
return r.json().catch(function() {
return null;
}).then(function(data) {
if (!r.ok || !data || data.success !== true) throw new Error(data && (data.error || data.message) || 'Failed to report attachment to NCMEC');
return data;
});
}).then(function(data) {
b.textContent = 'Reported to NCMEC';
b.dataset.ncmecStatus = 'submitted';
if (data.ncmec_report_id) b.dataset.ncmecReportId = data.ncmec_report_id;
toast('success', 'NCMEC report submitted.');
}).catch(function(err) {
b.disabled = false;
b.textContent = 'Report to NCMEC';
toast('error', err && err.message ? err.message : 'Failed to report attachment to NCMEC.');
});
}
document.addEventListener('click', function(e) {
var t = e.target;
if (!(t instanceof HTMLElement)) return;
@@ -359,12 +325,6 @@ pub fn message_deletion_script(csrf_token: &str) -> Markup {
if (d instanceof HTMLButtonElement) {
e.preventDefault();
deleteMessage(d);
return;
}
var n = t.closest('.ncmec-report-btn');
if (n instanceof HTMLButtonElement && !n.disabled) {
e.preventDefault();
reportNcmec(n);
}
});
})();"#
+2 -2
View File
@@ -17,12 +17,12 @@ pub mod message_data;
pub mod message_list;
pub mod nsfw_indicators;
pub mod page_container;
pub mod report_category;
pub mod report_webhook;
pub mod resource_link;
pub mod section_card;
pub mod stack;
pub mod table;
pub mod tooltip;
pub mod typography;
pub mod user_display;
pub mod user_profile_badges;
pub mod voice;
@@ -2,21 +2,6 @@
use maud::{Markup, PreEscaped, html};
pub fn page_container(spacing: &str, content: Markup) -> Markup {
let class = match spacing {
"sm" => "space-y-4",
"lg" => "space-y-8",
_ => "space-y-6",
};
html! {
div class=(class) { (content) }
}
}
pub fn page_container_md(content: Markup) -> Markup {
page_container("md", content)
}
pub fn page_header(title: &str, description: Option<&str>) -> Markup {
page_header_full(title, description, None, None, None, maud::html! {})
}
@@ -122,25 +107,3 @@ pub fn detail_row(label: &str, value: Markup) -> Markup {
}
}
}
pub fn tabs(tabs: &[(&str, &str, bool)], base_url: &str) -> Markup {
html! {
div class="border-b border-neutral-200 mb-6" {
nav class="-mb-px flex gap-6" aria-label="Tabs" {
@for (id, label, active) in tabs {
@let classes = if *active {
"border-brand-primary text-brand-primary whitespace-nowrap border-b-2 \
pb-3 pt-1 text-sm font-medium"
} else {
"border-transparent text-neutral-500 hover:border-neutral-300 \
hover:text-neutral-700 whitespace-nowrap border-b-2 pb-3 pt-1 \
text-sm font-medium"
};
a href={(base_url) "?tab=" (id)} class=(classes) {
(label)
}
}
}
}
}
}
@@ -0,0 +1,144 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use maud::{Markup, html};
const REPORT_CATEGORIES: &[(&str, &str)] = &[
("harassment", "Harassment or bullying"),
("hate_speech", "Hate speech"),
("spam", "Spam"),
("illegal_activity", "Illegal activity"),
("impersonation", "Impersonation"),
("child_safety", "Child safety concerns"),
("other", "Other"),
("violent_content", "Violent or graphic content"),
("nsfw_violation", "NSFW policy violation"),
("doxxing", "Sharing personal information"),
("self_harm", "Self-harm or suicide"),
("malicious_links", "Malicious links"),
("spam_account", "Spam account"),
("underage_user", "Underage user"),
("inappropriate_profile", "Inappropriate profile"),
("raid_coordination", "Raid coordination"),
("malware_distribution", "Malware distribution"),
("extremist_community", "Extremist community"),
];
pub fn report_category_label(category: &str) -> &str {
REPORT_CATEGORIES
.iter()
.find_map(|(key, label)| (*key == category).then_some(*label))
.unwrap_or(category)
}
pub fn reason_repeats_category(category: &str, reason_label: &str) -> bool {
report_category_label(category).trim().to_lowercase() == reason_label.trim().to_lowercase()
}
pub fn report_category_options(selected: &str) -> Vec<(&str, &str)> {
let mut options = std::iter::once(("", "All"))
.chain(REPORT_CATEGORIES.iter().copied())
.collect::<Vec<_>>();
if !options.iter().any(|(key, _)| *key == selected) {
options.push((selected, selected));
}
options
}
pub fn report_category(category: &str) -> Markup {
html! {
span data-report-category=(category) title=(category) {
(report_category_label(category))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn known_categories_get_their_label() {
assert_eq!(
report_category_label("harassment"),
"Harassment or bullying"
);
assert_eq!(report_category_label("spam"), "Spam");
assert_eq!(report_category_label("spam_account"), "Spam account");
assert_eq!(
report_category_label("extremist_community"),
"Extremist community"
);
}
#[test]
fn unknown_categories_fall_back_to_the_key() {
assert_eq!(report_category_label("future_value"), "future_value");
assert_eq!(report_category_label(""), "");
}
#[test]
fn reasons_that_repeat_the_category_label_are_detected() {
assert!(reason_repeats_category("spam", "Spam"));
assert!(reason_repeats_category("spam", "spam"));
assert!(reason_repeats_category(
"harassment",
"Harassment or bullying"
));
assert!(reason_repeats_category(
"harassment",
" harassment OR bullying "
));
assert!(!reason_repeats_category("harassment", "Hate speech"));
assert!(!reason_repeats_category(
"child_safety",
"Child sexual abuse material"
));
assert!(reason_repeats_category("future_value", "future_value"));
assert!(!reason_repeats_category("future_value", "Spam"));
}
#[test]
fn every_category_key_is_unique_and_labelled() {
let mut keys = REPORT_CATEGORIES
.iter()
.map(|(key, _)| *key)
.collect::<Vec<_>>();
keys.sort_unstable();
keys.dedup();
assert_eq!(keys.len(), REPORT_CATEGORIES.len());
assert_eq!(keys.len(), 18);
for (key, label) in REPORT_CATEGORIES {
assert_ne!(key, label);
}
}
#[test]
fn options_start_with_all_and_keep_an_unknown_selection() {
let options = report_category_options("");
assert_eq!(options.first(), Some(&("", "All")));
assert_eq!(options.len(), 19);
assert_eq!(report_category_options("spam").len(), 19);
let unknown = report_category_options("future_value");
assert_eq!(unknown.len(), 20);
assert_eq!(unknown.last(), Some(&("future_value", "future_value")));
}
#[test]
fn category_markup_shows_the_label_and_keeps_the_key() {
let markup = report_category("doxxing").into_string();
assert!(
markup.contains(r#"data-report-category="doxxing""#),
"{markup}"
);
assert!(
markup.contains(">Sharing personal information<"),
"{markup}"
);
let unknown = report_category("future_value").into_string();
assert!(
unknown.contains(r#"data-report-category="future_value""#),
"{unknown}"
);
assert!(unknown.contains(">future_value<"), "{unknown}");
}
}
@@ -0,0 +1,189 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use maud::{Markup, html};
use super::badge::{BadgeVariant, badge};
use super::media::user_avatar_url;
use crate::{
api::types::ReportEntry,
config::AdminConfig,
utils::{timestamps::format_admin_timestamp, user_tag::user_tag},
};
pub struct ReportedWebhook<'a> {
pub id: &'a str,
pub name: String,
pub configured_name: Option<&'a str>,
pub configured_avatar_url: Option<String>,
pub avatar_url: String,
pub reports_href: String,
pub kind: Option<String>,
pub created_at: Option<String>,
pub channel_id: Option<&'a str>,
pub guild_id: Option<&'a str>,
pub application: Option<WebhookApplication<'a>>,
pub record_deleted: bool,
pub creator: Option<WebhookCreator<'a>>,
}
pub struct WebhookApplication<'a> {
pub id: &'a str,
pub href: String,
}
pub struct WebhookCreator<'a> {
pub id: &'a str,
pub label: String,
pub avatar_url: String,
pub href: String,
pub account_deleted: bool,
pub bot: bool,
}
pub fn webhook_type_label(webhook_type: i32) -> String {
match webhook_type {
1 => "Incoming".to_owned(),
2 => "Channel Follower".to_owned(),
other => format!("Type {other}"),
}
}
fn non_blank(value: Option<&str>) -> Option<&str> {
value.filter(|value| !value.trim().is_empty())
}
pub fn reported_webhook<'a>(
config: &AdminConfig,
report: &'a ReportEntry,
) -> Option<ReportedWebhook<'a>> {
let id = report.reported_webhook_id.as_deref()?;
let name = non_blank(report.reported_webhook_name.as_deref())
.map_or_else(|| format!("Webhook {id}"), str::to_owned);
let configured_name = non_blank(report.reported_webhook_default_name.as_deref())
.filter(|configured| *configured != name);
let channel_id = non_blank(report.reported_webhook_channel_id.as_deref());
let guild_id = non_blank(report.reported_webhook_guild_id.as_deref());
let record_present = report.reported_webhook_type.is_some()
|| channel_id.is_some()
|| guild_id.is_some()
|| non_blank(report.reported_webhook_default_name.as_deref()).is_some();
let configured_avatar_hash = non_blank(report.reported_webhook_default_avatar_hash.as_deref());
let configured_avatar_url = (record_present
&& configured_avatar_hash != non_blank(report.reported_webhook_avatar_hash.as_deref()))
.then(|| user_avatar_url(config, id, configured_avatar_hash, 80, true));
let created_at = non_blank(report.reported_webhook_created_at.as_deref());
Some(ReportedWebhook {
id,
name,
configured_name,
configured_avatar_url,
avatar_url: user_avatar_url(
config,
id,
report.reported_webhook_avatar_hash.as_deref(),
80,
true,
),
reports_href: format!(
"{}/reports?reported_webhook_id={}",
config.base_path,
urlencoding::encode(id)
),
kind: report.reported_webhook_type.map(webhook_type_label),
created_at: created_at.map(format_admin_timestamp),
channel_id,
guild_id,
application: non_blank(report.reported_webhook_application_id.as_deref()).map(|id| {
WebhookApplication {
id,
href: format!(
"{}/applications/{}",
config.base_path,
urlencoding::encode(id)
),
}
}),
record_deleted: created_at.is_some() && !record_present,
creator: webhook_creator(config, report),
})
}
fn webhook_creator<'a>(
config: &AdminConfig,
report: &'a ReportEntry,
) -> Option<WebhookCreator<'a>> {
let id = report.reported_webhook_creator_id.as_deref()?;
let username = non_blank(report.reported_webhook_creator_username.as_deref());
let bot = report.reported_webhook_application_id.as_deref() == Some(id);
let tag = match username {
Some(username) => Some(user_tag(
username,
report
.reported_webhook_creator_discriminator
.as_deref()
.unwrap_or("0000"),
bot,
)),
None => non_blank(report.reported_webhook_creator_tag.as_deref()).map(str::to_owned),
};
let label = match (
non_blank(report.reported_webhook_creator_global_name.as_deref()),
tag,
) {
(Some(display), Some(tag)) => format!("{display} ({tag})"),
(_, Some(tag)) => tag,
(Some(display), None) => display.to_owned(),
(None, None) => format!("User {id}"),
};
Some(WebhookCreator {
id,
label,
avatar_url: user_avatar_url(
config,
id,
report.reported_webhook_creator_avatar_hash.as_deref(),
80,
true,
),
href: format!("{}/users/{}", config.base_path, urlencoding::encode(id)),
account_deleted: username.is_none()
&& non_blank(report.reported_webhook_creator_tag.as_deref()).is_none()
&& non_blank(report.reported_webhook_creator_global_name.as_deref()).is_none(),
bot,
})
}
pub fn webhook_identity(webhook: &ReportedWebhook<'_>) -> Markup {
html! {
span class="inline-flex min-w-0 items-center gap-2" data-report-webhook=(webhook.id) {
img src=(webhook.avatar_url) alt=(format!("{}'s avatar", webhook.name))
class="h-8 w-8 flex-shrink-0 rounded-full object-cover";
span class="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5" {
span class="font-medium text-neutral-900 text-sm break-words" { (webhook.name) }
(badge("Webhook", BadgeVariant::Info))
}
}
}
}
pub fn webhook_creator_link(creator: &WebhookCreator<'_>) -> Markup {
html! {
a href=(creator.href) title=(creator.id) data-report-webhook-creator=(creator.id)
class="inline-flex min-w-0 items-center gap-2 text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
img src=(creator.avatar_url) alt=(format!("{}'s avatar", creator.label))
class="h-8 w-8 flex-shrink-0 rounded-full object-cover";
span class="break-words" { (creator.label) }
}
}
}
pub fn webhook_creator_badges(creator: &WebhookCreator<'_>) -> Markup {
html! {
@if creator.bot {
span class="inline-flex" data-report-webhook-creator-bot { (badge("Bot", BadgeVariant::Info)) }
}
@if creator.account_deleted {
span class="inline-flex" data-report-webhook-creator-deleted { (badge("Account Deleted", BadgeVariant::Default)) }
}
}
}
@@ -10,8 +10,6 @@ pub enum ResourceType {
const RESOURCE_LINK_CLASS: &str = "text-neutral-900 underline decoration-neutral-300 \
hover:text-neutral-600 hover:decoration-neutral-500 text-sm";
const NAV_LINK_CLASS: &str = "label rounded-lg border border-neutral-300 bg-white \
px-3 py-2 text-neutral-700 transition-colors hover:bg-neutral-50";
impl ResourceType {
fn path_segment(self) -> &'static str {
@@ -20,20 +18,6 @@ impl ResourceType {
ResourceType::Guild => "guilds",
}
}
fn peek_drawer_id(self) -> &'static str {
match self {
ResourceType::User => "user-peek",
ResourceType::Guild => "guild-peek",
}
}
fn label(self) -> &'static str {
match self {
ResourceType::User => "User",
ResourceType::Guild => "Guild",
}
}
}
pub fn resource_link(
@@ -52,40 +36,3 @@ pub fn resource_link(
a href=(href) class=(RESOURCE_LINK_CLASS) { (display) }
}
}
pub fn resource_link_peek(
base_path: &str,
resource_type: ResourceType,
resource_id: &str,
display: Markup,
peek_title: Option<&str>,
) -> Markup {
let href = format!(
"{}/{}/{}",
base_path,
resource_type.path_segment(),
resource_id,
);
let fragment_href = format!("{}/fragment", href);
let title = peek_title
.map(|t| t.to_owned())
.unwrap_or_else(|| format!("{} {}", resource_type.label(), resource_id));
html! {
a href=(href)
class=(RESOURCE_LINK_CLASS)
data-drawer-open=(resource_type.peek_drawer_id())
data-drawer-href=(fragment_href)
hx-get=(fragment_href)
hx-target={"#" (resource_type.peek_drawer_id()) "-body"}
hx-swap="innerHTML"
data-drawer-title=(title) {
(display)
}
}
}
pub fn nav_link(href: &str, content: Markup) -> Markup {
html! {
a href=(href) class=(NAV_LINK_CLASS) { (content) }
}
}
@@ -1,53 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use maud::{Markup, html};
#[derive(Clone, Copy)]
pub enum StackGap {
Sm,
Md,
Lg,
}
#[derive(Clone, Copy)]
pub enum StackAlign {
Start,
Center,
End,
Stretch,
}
fn gap_class(gap: StackGap) -> &'static str {
match gap {
StackGap::Sm => "gap-2",
StackGap::Md => "gap-4",
StackGap::Lg => "gap-6",
}
}
fn align_class(align: StackAlign) -> &'static str {
match align {
StackAlign::Start => "items-start",
StackAlign::Center => "items-center",
StackAlign::End => "items-end",
StackAlign::Stretch => "items-stretch",
}
}
pub fn vstack(gap: StackGap, align: StackAlign, content: Markup) -> Markup {
let classes = format!("flex flex-col {} {}", gap_class(gap), align_class(align),);
html! {
div class=(classes) { (content) }
}
}
pub fn hstack(gap: StackGap, align: StackAlign, content: Markup) -> Markup {
let classes = format!("flex flex-row {} {}", gap_class(gap), align_class(align),);
html! {
div class=(classes) { (content) }
}
}
pub fn stack(content: Markup) -> Markup {
vstack(StackGap::Md, StackAlign::Stretch, content)
}
@@ -45,10 +45,6 @@ pub fn table_cell(muted: bool, content: Markup) -> Markup {
html! { td class=(table_cell_class(muted)) { (content) } }
}
pub fn table_cell_span(muted: bool, col_span: u32, content: Markup) -> Markup {
html! { td class=(table_cell_class(muted)) colspan=(col_span) { (content) } }
}
fn table_cell_class(muted: bool) -> &'static str {
if muted {
"px-3 py-3 text-sm sm:px-6 sm:py-4 text-neutral-600"
@@ -1,155 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use maud::{Markup, html};
#[derive(Clone, Copy)]
pub enum HeadingLevel {
H1,
H2,
H3,
H4,
H5,
H6,
}
#[derive(Clone, Copy)]
pub enum HeadingSize {
Xs,
Sm,
Base,
Lg,
Xl,
Xl2,
Xl3,
Xl4,
}
fn default_heading_classes(level: HeadingLevel) -> &'static str {
match level {
HeadingLevel::H1 => "text-3xl font-bold",
HeadingLevel::H2 => "text-2xl font-semibold",
HeadingLevel::H3 => "text-xl font-semibold",
HeadingLevel::H4 => "text-lg font-semibold",
HeadingLevel::H5 => "text-base font-semibold",
HeadingLevel::H6 => "text-sm font-semibold",
}
}
fn custom_size_class(size: HeadingSize) -> &'static str {
match size {
HeadingSize::Xs => "text-xs",
HeadingSize::Sm => "text-sm",
HeadingSize::Base => "text-base",
HeadingSize::Lg => "text-lg",
HeadingSize::Xl => "text-xl",
HeadingSize::Xl2 => "text-2xl",
HeadingSize::Xl3 => "text-3xl",
HeadingSize::Xl4 => "text-4xl",
}
}
pub fn heading(level: HeadingLevel, size: Option<HeadingSize>, content: Markup) -> Markup {
let size_classes = match size {
Some(s) => custom_size_class(s),
None => default_heading_classes(level),
};
let classes = format!("text-gray-900 tracking-tight {size_classes}");
match level {
HeadingLevel::H1 => html! { h1 class=(classes) { (content) } },
HeadingLevel::H2 => html! { h2 class=(classes) { (content) } },
HeadingLevel::H3 => html! { h3 class=(classes) { (content) } },
HeadingLevel::H4 => html! { h4 class=(classes) { (content) } },
HeadingLevel::H5 => html! { h5 class=(classes) { (content) } },
HeadingLevel::H6 => html! { h6 class=(classes) { (content) } },
}
}
#[derive(Clone, Copy)]
pub enum TextSize {
Xs,
Sm,
Base,
Lg,
}
#[derive(Clone, Copy)]
pub enum TextColor {
Default,
Muted,
Primary,
Danger,
Success,
}
pub fn text(size: TextSize, color: TextColor, content: Markup) -> Markup {
let size_class = match size {
TextSize::Xs => "text-xs",
TextSize::Sm => "text-sm",
TextSize::Base => "text-base",
TextSize::Lg => "text-lg",
};
let color_class = match color {
TextColor::Default => "text-gray-900",
TextColor::Muted => "text-neutral-500",
TextColor::Primary => "text-brand-primary",
TextColor::Danger => "text-red-600",
TextColor::Success => "text-green-600",
};
html! {
p class={(size_class) " font-normal " (color_class)} { (content) }
}
}
pub fn caption(content: Markup) -> Markup {
html! {
p class="text-xs text-gray-500" { (content) }
}
}
#[derive(Clone, Copy)]
pub enum CaptionVariant {
Default,
Error,
Success,
}
pub fn caption_variant(variant: CaptionVariant, content: Markup) -> Markup {
let color = match variant {
CaptionVariant::Default => "text-gray-500",
CaptionVariant::Error => "text-red-600",
CaptionVariant::Success => "text-green-600",
};
html! {
p class={"text-xs " (color)} { (content) }
}
}
pub fn label_text(for_id: Option<&str>, required: bool, content: Markup) -> Markup {
let classes = "block text-xs font-semibold uppercase tracking-wide text-neutral-500";
html! {
label for=[for_id] class=(classes) {
(content)
@if required {
span class="ml-1 text-red-600" { "*" }
}
}
}
}
pub fn section_heading(title: &str, actions: Option<Markup>) -> Markup {
match actions {
Some(action_content) => html! {
div class="mb-4 flex items-center justify-between" {
h2 class="font-semibold text-gray-900 text-xl" { (title) }
div class="flex items-center gap-2" { (action_content) }
}
},
None => html! {
h2 class="mb-4 font-semibold text-gray-900 text-xl" { (title) }
},
}
}
pub fn inline_text(content: &str) -> Markup {
html! { span { (content) } }
}
@@ -1,15 +1,46 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::user_tag::user_tag;
pub fn format_user_display(
global_name: Option<&str>,
username: Option<&str>,
discriminator: Option<&str>,
is_bot: bool,
) -> String {
match (global_name, username, discriminator) {
(Some(gn), Some(un), Some("0")) => format!("{gn} (@{un})"),
(Some(gn), _, _) => gn.to_owned(),
(None, Some(un), Some(d)) if d != "0" => format!("{un}#{d}"),
(None, Some(un), Some(d)) if d != "0" => user_tag(un, d, is_bot),
(None, Some(un), _) => format!("@{un}"),
_ => "Unknown".to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::user_tag::sync_with_unique_usernames;
#[test]
fn username_instances_show_bare_human_names_and_keep_bot_tags() {
sync_with_unique_usernames(true, || {
assert_eq!(
format_user_display(None, Some("alice"), Some("0000"), false),
"alice"
);
assert_eq!(
format_user_display(None, Some("helper"), Some("4363"), true),
"helper#4363"
);
});
}
#[test]
fn email_instances_keep_the_zero_tag() {
assert_eq!(
format_user_display(None, Some("alice"), Some("0000"), false),
"alice#0000"
);
}
}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::admin_flags::user_flag_bits;
use crate::utils::timestamps::format_admin_timestamp;
use maud::{Markup, html};
pub mod premium_types {
@@ -22,18 +23,21 @@ fn premium_tooltip(
if is_self_hosted {
let name = self_hosted_premium_name?;
return Some(match premium_since {
Some(since) => format!("{name} subscriber since {since}"),
Some(since) => format!("{name} subscriber since {}", format_admin_timestamp(since)),
None => name.to_owned(),
});
}
Some(if premium_type == premium_types::LIFETIME {
match premium_since {
Some(since) => format!("Fluxer Visionary since {since}"),
Some(since) => format!("Fluxer Visionary since {}", format_admin_timestamp(since)),
None => "Fluxer Visionary".into(),
}
} else {
match premium_since {
Some(since) => format!("Fluxer Plutonium subscriber since {since}"),
Some(since) => format!(
"Fluxer Plutonium subscriber since {}",
format_admin_timestamp(since)
),
None => "Fluxer Plutonium".into(),
}
})
-6
View File
@@ -126,9 +126,3 @@ fn render_flash(flash: &crate::api::types::FlashMessage) -> Markup {
}
}
}
pub fn flash_container() -> Markup {
html! {
div id="flash-container" class="mb-4 sm:mb-6 empty:hidden" {}
}
}
+5 -3
View File
@@ -1,8 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
config::AdminConfig, middleware::auth::AuthContext,
templates::components::media::user_avatar_url, utils::bigint::format_discriminator,
config::AdminConfig,
middleware::auth::AuthContext,
templates::components::media::user_avatar_url,
utils::{bigint::format_discriminator, user_tag::user_tag},
};
use maud::{Markup, html};
@@ -32,7 +34,7 @@ pub fn render_header(config: &AdminConfig, auth: &AuthContext, csrf_token: &str)
(display)
}
div class="truncate text-neutral-500 text-xs" {
(admin.username) "#" (format_discriminator(&admin.discriminator))
(user_tag(&admin.username, &format_discriminator(&admin.discriminator), admin.bot))
}
}
}
+19 -21
View File
@@ -489,10 +489,11 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
element = element.firstElementChild;
depth += 1;
}
var level = status >= 400 ? 'error' : 'success';
var level = 'success';
if (className.indexOf('red-') >= 0) level = 'error';
if (className.indexOf('green-') >= 0) level = 'success';
if (className.indexOf('blue-') >= 0) level = 'info';
if (status >= 400) level = 'error';
return {
level: level,
message: text || (level === 'error' ? 'Action failed.' : 'Done.')
@@ -514,6 +515,20 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
}
}
function refreshAfterSuccess(detail, level) {
if (level !== 'success') return;
var elt = (detail && detail.requestConfig && detail.requestConfig.elt) || mutationElement(detail);
var form = elt && elt.closest ? elt.closest('form') : null;
var selector = form ? form.getAttribute('data-admin-refresh-on-success') : '';
if (!selector || !document.querySelector(selector)) return;
if (!window.htmx || typeof window.htmx.ajax !== 'function') return;
window.htmx.ajax('GET', window.location.pathname + window.location.search, {
target: selector,
swap: 'outerHTML',
select: selector
});
}
document.body.addEventListener('showFlash', function (evt) {
var d = evt.detail || {};
showToast(d.level || 'info', d.message || '');
@@ -533,11 +548,12 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
var xhr = event.detail.xhr;
var parsed = isLoginResponse(xhr)
? {level: 'error', message: 'Session expired. Sign in again.'}
: parseFlashResponse(xhr ? xhr.responseText : '', xhr ? xhr.status : 200);
: parseAdminToastHeader(xhr) || parseFlashResponse(xhr ? xhr.responseText : '', xhr ? xhr.status : 200);
event.detail.shouldSwap = false;
event.detail.isError = false;
markToastHandled(event.detail);
showToast(parsed.level, parsed.message);
refreshAfterSuccess(event.detail, parsed.level);
}, true);
document.addEventListener('htmx:afterRequest', function (event) {
@@ -548,6 +564,7 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
if (serverToast) {
showToast(serverToast.level, serverToast.message);
markToastHandled(event.detail);
refreshAfterSuccess(event.detail, serverToast.level);
return;
}
var failed = !event.detail.successful || (xhr && xhr.status >= 400) || isLoginResponse(xhr);
@@ -568,25 +585,6 @@ window.__adminCopyToClipboard = function (text, btn, successLabel) {
};
"#;
pub const ARCHIVE_POLL_SCRIPT: &str = r#"
(function () {
var rows = document.querySelectorAll('tr[data-archive-id]');
var hasPending = false;
for (var i = 0; i < rows.length; i++) {
var status = rows[i].querySelector('.archive-status');
if (status && status.textContent.indexOf('Completed') === -1 && status.textContent.indexOf('Failed') === -1) {
hasPending = true;
break;
}
}
if (hasPending) {
setTimeout(function () {
window.location.reload();
}, 5000);
}
})();
"#;
pub const SH_LINK_REWRITE_SCRIPT: &str = r#"
(function () {
if (window.location.search.indexOf('sh=1') === -1) return;
+1 -1
View File
@@ -4,7 +4,7 @@ use crate::{acl, config::AdminConfig};
use maud::{Markup, PreEscaped, html};
use super::layout_scripts::SIDEBAR_ACTIVE_SCROLL_SCRIPT;
pub use super::layout_sidebar_nav::{NAV_SECTIONS, NavItem, NavSection};
use super::layout_sidebar_nav::NAV_SECTIONS;
pub fn render_sidebar(
config: &AdminConfig,
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::timestamps::format_admin_timestamp;
use crate::{
api::types::{CreateAdminApiKeyResponse, FlashMessage, ListAdminApiKeyEntry},
config::AdminConfig,
@@ -127,14 +128,14 @@ fn api_key_item(base: &str, csrf_token: &str, key: &ListAdminApiKeyEntry) -> Mar
"Key ID: " (key_id)
}
p class="text-sm text-neutral-500" {
"Created: " (created_at)
"Created: " (format_admin_timestamp(created_at))
}
p class="text-sm text-neutral-500" {
"Created by: " (key.created_by_user_id)
}
@if let Some(ref expires_at) = key.expires_at {
p class="text-sm text-neutral-500" {
"Expires: " (expires_at)
"Expires: " (format_admin_timestamp(expires_at))
}
}
@if !key.acls.is_empty() {
@@ -114,6 +114,7 @@ fn overview_card(config: &AdminConfig, app: &Application, can_list_by_owner: boo
app.owner_global_name.as_deref(),
app.owner_username.as_deref(),
app.owner_discriminator.as_deref(),
false,
);
section_card_simple(
"Overview",
@@ -157,6 +158,7 @@ fn bot_display_markup(config: &AdminConfig, app: &Application) -> Markup {
app.bot_global_name.as_deref(),
app.bot_username.as_deref(),
app.bot_discriminator.as_deref(),
true,
);
html! {
div class="space-y-1" {

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