mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-13 05:58:29 +09:00
Compare commits
232
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ffe8f3387 | ||
|
|
4194525933 | ||
|
|
44799dafa2 | ||
|
|
30b32c9a35 | ||
|
|
d1b4b82895 | ||
|
|
9bc43c0c08 | ||
|
|
13960a0bb3 | ||
|
|
64cb159d65 | ||
|
|
d2173fe879 | ||
|
|
49eb8cdd81 | ||
|
|
b4fc1241ae | ||
|
|
54f1b37666 | ||
|
|
8bda151596 | ||
|
|
ab19c0d1ac | ||
|
|
68cb2a4bbb | ||
|
|
17746fcdc4 | ||
|
|
c890956e4f | ||
|
|
8a5d4a8bdb | ||
|
|
0ba15ee46a | ||
|
|
85d9ef7386 | ||
|
|
f8d85f0e5e | ||
|
|
7f674474b5 | ||
|
|
c9fdf8fead | ||
|
|
c17091768c | ||
|
|
e0640d68ff | ||
|
|
3292dc5f89 | ||
|
|
86e29b61e3 | ||
|
|
f8d3c70a91 | ||
|
|
74c6fad260 | ||
|
|
bcb30eea88 | ||
|
|
d59ddc2214 | ||
|
|
e87b593f46 | ||
|
|
5a2d81ebbb | ||
|
|
50c28aa8fa | ||
|
|
af7724dbf6 | ||
|
|
be314788b3 | ||
|
|
5b3f69bf27 | ||
|
|
d7aa6ec2aa | ||
|
|
20e3474393 | ||
|
|
3694300d86 | ||
|
|
54ec74e4f9 | ||
|
|
aa3b058ce6 | ||
|
|
44bc29b101 | ||
|
|
b07dcc368d | ||
|
|
0967bf3136 | ||
|
|
edc913096a | ||
|
|
cca8227ca6 | ||
|
|
45b78a13c2 | ||
|
|
6acd7f3d22 | ||
|
|
7d2c51a57f | ||
|
|
575a5adbef | ||
|
|
5695510980 | ||
|
|
c16d414d93 | ||
|
|
e7b13fd19f | ||
|
|
f22261f218 | ||
|
|
901ed623f6 | ||
|
|
1198b61648 | ||
|
|
d564ca5691 | ||
|
|
b7e92cbfe0 | ||
|
|
1cd62eb661 | ||
|
|
7e177a1c90 | ||
|
|
78471d8a85 | ||
|
|
6b6ae13919 | ||
|
|
7197371123 | ||
|
|
70fd9e6331 | ||
|
|
c14b7d4cb3 | ||
|
|
0f0ff5f3df | ||
|
|
b4c614d21a | ||
|
|
bef5e33eec | ||
|
|
e5a568acca | ||
|
|
a208b56354 | ||
|
|
71be6c1de4 | ||
|
|
eae905b1da | ||
|
|
10cd4f96a4 | ||
|
|
9d74daf8ad | ||
|
|
6f344def93 | ||
|
|
72f9f9c396 | ||
|
|
c8755885d4 | ||
|
|
12d503d4c5 | ||
|
|
2f6201bcbd | ||
|
|
ec681e6061 | ||
|
|
0fc6fad11d | ||
|
|
eb329f2cd0 | ||
|
|
9a20821332 | ||
|
|
4749eb7f86 | ||
|
|
2d83fd2a93 | ||
|
|
d180af6ba2 | ||
|
|
4810eb3e24 | ||
|
|
48a19dedd8 | ||
|
|
cadf7ea532 | ||
|
|
fc60d05f17 | ||
|
|
5072488f1e | ||
|
|
7a5b9a0ded | ||
|
|
4ce4d4e09a | ||
|
|
82abd03a37 | ||
|
|
93d1f8af46 | ||
|
|
a19002652b | ||
|
|
6534ee4300 | ||
|
|
4db13b0375 | ||
|
|
a5c1362f4a | ||
|
|
454fefdb92 | ||
|
|
f390e610b9 | ||
|
|
ece622e800 | ||
|
|
bc17922b02 | ||
|
|
a32d8e4f52 | ||
|
|
12daeb758d | ||
|
|
fff67ed9af | ||
|
|
d0b4816a5f | ||
|
|
0febad324c | ||
|
|
dcc3273889 | ||
|
|
e9a4f33a7e | ||
|
|
ae820ba4ea | ||
|
|
94f1239050 | ||
|
|
acc1392a53 | ||
|
|
a6a9789091 | ||
|
|
fdb7410976 | ||
|
|
b146b8ee33 | ||
|
|
4b1afc953f | ||
|
|
4ecbe9603c | ||
|
|
847d449882 | ||
|
|
bef20cba85 | ||
|
|
ebf91ccc76 | ||
|
|
abfaff16c2 | ||
|
|
89fa895a0a | ||
|
|
334fef52e1 | ||
|
|
aa42bf13ce | ||
|
|
c486c2d3d0 | ||
|
|
0ec1cee99e | ||
|
|
ccbe4857a1 | ||
|
|
7f8b0afedb | ||
|
|
e94b7d7ad8 | ||
|
|
27baa38fd1 | ||
|
|
4af10dd3e3 | ||
|
|
269d33cab2 | ||
|
|
170e12595b | ||
|
|
b82bbaa2dd | ||
|
|
b8f3dbddbf | ||
|
|
663572fd10 | ||
|
|
5033cf7203 | ||
|
|
0fcab2f8f0 | ||
|
|
c3d790f664 | ||
|
|
0906a85d6f | ||
|
|
4da8dec4ac | ||
|
|
d6e3254a16 | ||
|
|
6dc698ff5e | ||
|
|
39e48458da | ||
|
|
c5453f83e8 | ||
|
|
510cc1b916 | ||
|
|
e7859fefb1 | ||
|
|
46a356cc40 | ||
|
|
bd56174870 | ||
|
|
965bb5f634 | ||
|
|
7937d5f802 | ||
|
|
f1373a63c3 | ||
|
|
e0d7625c39 | ||
|
|
f8505d19f7 | ||
|
|
1515a8487f | ||
|
|
7a4aa42d4b | ||
|
|
0ef1cd14c3 | ||
|
|
795e190bda | ||
|
|
d00389ab30 | ||
|
|
3071bc7525 | ||
|
|
6de6be2358 | ||
|
|
c51c222c47 | ||
|
|
e218ba21de | ||
|
|
de358c0def | ||
|
|
4b88d64b2d | ||
|
|
ee1c861eb2 | ||
|
|
6a24ac3f4e | ||
|
|
8b312c610e | ||
|
|
2e3e3a1536 | ||
|
|
5c63d81ffd | ||
|
|
c402c52a8a | ||
|
|
317fedeef9 | ||
|
|
002502a7fa | ||
|
|
1997850d55 | ||
|
|
d346eb0e88 | ||
|
|
7c5fa2180a | ||
|
|
c748c8af4e | ||
|
|
ba59a13149 | ||
|
|
3f4160b138 | ||
|
|
5f4295e399 | ||
|
|
4e730832c7 | ||
|
|
d7c00d4556 | ||
|
|
154b65afe5 | ||
|
|
fcc2a3f64b | ||
|
|
80456861ac | ||
|
|
0c4f016ba2 | ||
|
|
cc5545c333 | ||
|
|
6e28092cdc | ||
|
|
8b6910d505 | ||
|
|
d87e31efaf | ||
|
|
6618a6baf4 | ||
|
|
22b8f5454b | ||
|
|
801bd3f106 | ||
|
|
e26c8c870d | ||
|
|
f4e545e090 | ||
|
|
2006fc0d8d | ||
|
|
d456048e69 | ||
|
|
fd35b4da24 | ||
|
|
283d179b05 | ||
|
|
3093e7334b | ||
|
|
02c82f0038 | ||
|
|
e1eecc3b6c | ||
|
|
bf3d73a5f7 | ||
|
|
05257d6439 | ||
|
|
532e828fe6 | ||
|
|
12a407aca8 | ||
|
|
5ca458dada | ||
|
|
0aeff01c2d | ||
|
|
2ac164d5b8 | ||
|
|
14d475df9a | ||
|
|
f3c777b244 | ||
|
|
1544e58e76 | ||
|
|
5d0c9c7cbe | ||
|
|
8f58fcc4c4 | ||
|
|
5799ef705d | ||
|
|
0de7dde1ce | ||
|
|
7b39e5a79d | ||
|
|
583c791016 | ||
|
|
bc5dcdfe21 | ||
|
|
973aaced96 | ||
|
|
3e9ee908f8 | ||
|
|
e7347b582c | ||
|
|
71b7cffabc | ||
|
|
da9e9ff0be | ||
|
|
c6941d5905 | ||
|
|
a3cf960660 | ||
|
|
7eebfca20b | ||
|
|
e9167d96ec | ||
|
|
1664050ef7 | ||
|
|
7fa00c0e89 |
No files matched your search
@@ -28,7 +28,6 @@ services:
|
||||
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
FLUXER_ADMIN_OAUTH_REDIRECT_URI: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/admin/oauth2_callback"
|
||||
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: "http://localhost,http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
|
||||
volumes:
|
||||
- ..:/workspaces/fluxer:cached
|
||||
- type: volume
|
||||
@@ -86,11 +85,6 @@ services:
|
||||
target: /workspaces/fluxer/packages/hono/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: package-hono-types-node-modules
|
||||
target: /workspaces/fluxer/packages/hono_types/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: package-i18n-node-modules
|
||||
target: /workspaces/fluxer/packages/i18n/node_modules
|
||||
@@ -166,21 +160,11 @@ services:
|
||||
target: /workspaces/fluxer/fluxer_api/pkgs/http_client/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: fluxer-api-initialization-node-modules
|
||||
target: /workspaces/fluxer/fluxer_api/pkgs/initialization/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: fluxer-api-kv-client-node-modules
|
||||
target: /workspaces/fluxer/fluxer_api/pkgs/kv_client/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: fluxer-api-locale-node-modules
|
||||
target: /workspaces/fluxer/fluxer_api/pkgs/locale/node_modules
|
||||
volume:
|
||||
nocopy: true
|
||||
- type: volume
|
||||
source: fluxer-api-media-proxy-utils-node-modules
|
||||
target: /workspaces/fluxer/fluxer_api/pkgs/media_proxy_utils/node_modules
|
||||
@@ -355,7 +339,6 @@ volumes:
|
||||
package-errors-node-modules:
|
||||
package-geo-utils-node-modules:
|
||||
package-hono-node-modules:
|
||||
package-hono-types-node-modules:
|
||||
package-i18n-node-modules:
|
||||
package-instance-bootstrap-node-modules:
|
||||
package-ip-utils-node-modules:
|
||||
@@ -371,9 +354,7 @@ volumes:
|
||||
fluxer-api-email-node-modules:
|
||||
fluxer-api-geoip-node-modules:
|
||||
fluxer-api-http-client-node-modules:
|
||||
fluxer-api-initialization-node-modules:
|
||||
fluxer-api-kv-client-node-modules:
|
||||
fluxer-api-locale-node-modules:
|
||||
fluxer-api-media-proxy-utils-node-modules:
|
||||
fluxer-api-mime-utils-node-modules:
|
||||
fluxer-api-nats-node-modules:
|
||||
|
||||
+5
-3
@@ -38,6 +38,9 @@
|
||||
/fluxer_app/src/features/theme/styles/generated/
|
||||
/fluxer_app/src/features/ui/components/SVGMasks.tsx
|
||||
/fluxer_app/src/features/ui/constants/AvatarStatusGeometry.ts
|
||||
/fluxer_static/desktop/spellcheck/dictionaries/*/*
|
||||
!/fluxer_static/desktop/spellcheck/dictionaries/[email protected]/LICENSE
|
||||
!/fluxer_static/desktop/spellcheck/dictionaries/[email protected]/LICENSE
|
||||
/fluxer_gateway/priv/
|
||||
|
||||
/fluxer_media_proxy/fuzz/artifacts/
|
||||
@@ -45,11 +48,10 @@
|
||||
/packages/markdown_parser/rust/fuzz/artifacts/
|
||||
/packages/markdown_parser/rust/fuzz/corpus/
|
||||
|
||||
/fluxer_media_proxy/.benchmark-cache/
|
||||
/fluxer_media_proxy/bench-results/
|
||||
|
||||
/app-dist-output/
|
||||
/artifacts/
|
||||
/desktop-shared-assets/
|
||||
/desktop-modules/
|
||||
/s3_payload/
|
||||
/upload_staging/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
* text=auto
|
||||
fluxer_static/** -text -diff
|
||||
fluxer_static/**/*.md text diff
|
||||
fluxer_static/*.sh text eol=lf diff
|
||||
fluxer_static/*.sha256 text eol=lf diff
|
||||
packages/fonts/files/** -text -diff
|
||||
fluxer_app/src/features/voice/utils/noise_suppression/deepfilternet3/*.wasm -text -diff
|
||||
fluxer_app/src/features/voice/utils/noise_suppression/deepfilternet3/*.tar.gz -text -diff
|
||||
fluxer_app/src/features/i18n/locales/** linguist-generated
|
||||
**/weblate/locales/** linguist-generated
|
||||
fluxer_api/src/api/openapi/openapi.json linguist-generated
|
||||
fluxer_admin/openapi-admin.json linguist-generated
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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.
|
||||
@@ -0,0 +1,38 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: App Wasm cache
|
||||
description: Restore or save the shared application Wasm artifacts
|
||||
inputs:
|
||||
mode:
|
||||
description: Cache operation (restore or save)
|
||||
required: true
|
||||
outputs:
|
||||
cache-hit:
|
||||
description: Whether the restore found the exact artifact key
|
||||
value: ${{ steps.cache.outputs.cache-hit }}
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Resolve artifact key
|
||||
id: key
|
||||
if: always()
|
||||
shell: bash
|
||||
run: echo "key=app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs', 'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock', 'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**', 'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock', 'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**', 'fluxer_app/rust/libfluxwebp/simd/**', 'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml', 'packages/markdown_parser/rust/src/**') }}" >> "$GITHUB_OUTPUT"
|
||||
- name: Restore artifacts
|
||||
id: cache
|
||||
if: inputs.mode == 'restore'
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: ${{ steps.key.outputs.key }}
|
||||
- name: Save artifacts
|
||||
if: always() && inputs.mode == 'save'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: ${{ steps.key.outputs.key }}
|
||||
@@ -0,0 +1,29 @@
|
||||
# SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
name: CI helper
|
||||
description: Restore or build the shared Rust CI helper
|
||||
inputs:
|
||||
save:
|
||||
description: Save a missing helper cache on main
|
||||
required: false
|
||||
default: 'false'
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Restore ci helper
|
||||
id: cache
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**') }}
|
||||
- name: Build ci helper
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: cargo build --locked --package fluxer-ci
|
||||
- name: Save ci helper
|
||||
if: inputs.save == 'true' && github.ref == 'refs/heads/main' && steps.cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: target/debug/fluxer-ci
|
||||
key: ${{ steps.cache.outputs.cache-primary-key }}
|
||||
@@ -67,9 +67,6 @@ p:geo-utils:
|
||||
p:hono:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: packages/hono/**/*
|
||||
p:hono-types:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: packages/hono_types/**/*
|
||||
p:i18n:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file: packages/i18n/**/*
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -12,26 +12,11 @@ on:
|
||||
description: "Path to the Dockerfile to build"
|
||||
type: string
|
||||
required: true
|
||||
context:
|
||||
description: "Docker build context"
|
||||
type: string
|
||||
required: false
|
||||
default: "."
|
||||
build-version:
|
||||
description: "Explicit Fluxer CalVer build version (YYYY.MDD.MICRO, UTC HHMMSS without leading zeroes) to use instead of automatic UTC clock allocation"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
moving-tags:
|
||||
description: "Comma-separated moving tags to repoint at this build"
|
||||
type: string
|
||||
required: false
|
||||
default: "v1,latest"
|
||||
extra-build-args:
|
||||
description: "Additional Docker build args, one KEY=VALUE entry per line"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
@@ -115,7 +100,7 @@ jobs:
|
||||
password: ${{ github.token }}
|
||||
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc
|
||||
with:
|
||||
context: ${{ inputs.context }}
|
||||
context: .
|
||||
file: ${{ inputs.dockerfile }}
|
||||
push: true
|
||||
provenance: mode=min
|
||||
@@ -125,7 +110,6 @@ jobs:
|
||||
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
|
||||
SOURCE_SHA=${{ github.sha }}
|
||||
SOURCE_DATE=${{ steps.source.outputs.date }}
|
||||
${{ inputs.extra-build-args }}
|
||||
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:buildcache-${{ matrix.platform }}
|
||||
cache-to: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/${{ inputs.image }}:buildcache-${{ matrix.platform }},mode=max,image-manifest=true,oci-mediatypes=true,ignore-error=true
|
||||
env:
|
||||
@@ -190,7 +174,7 @@ jobs:
|
||||
|
||||
- name: Advance moving image tags
|
||||
env:
|
||||
MOVING_TAGS: ${{ inputs.moving-tags }}
|
||||
MOVING_TAGS: v1,latest
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
run: >-
|
||||
tools/ci/run.sh image-set
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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[@]}"
|
||||
@@ -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 }}
|
||||
@@ -521,7 +740,6 @@ jobs:
|
||||
PUBLIC_BUILD_VERSION: ${{ needs.meta.outputs.version }}
|
||||
PUB_DATE: ${{ needs.meta.outputs.pub_date }}
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
@@ -547,6 +765,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
|
||||
@@ -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
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
--step install_dependencies
|
||||
|
||||
- name: Compile translated catalogs
|
||||
run: pnpm i18n:weblate-compile
|
||||
run: pnpm i18n:compile
|
||||
|
||||
- name: Format generated catalogs
|
||||
run: pnpm biome format --write package.json packages/i18n/package.json packages/i18n/scripts/SyncStaticI18nCatalogs.ts packages/errors/src/i18n fluxer_api/pkgs/email/src/email_i18n fluxer_api/src/api/content_i18n
|
||||
|
||||
+28
-123
@@ -15,7 +15,6 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
GHCR_REGISTRY: ghcr.io/${{ github.repository_owner }}
|
||||
CARGO_PROFILE_DEV_DEBUG: none
|
||||
CARGO_PROFILE_TEST_DEBUG: none
|
||||
CARGO_INCREMENTAL: '0'
|
||||
@@ -36,27 +35,10 @@ jobs:
|
||||
toolchain: "1.98.1"
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Restore ci helper
|
||||
id: ci-helper
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
- name: Set up ci helper
|
||||
uses: ./.github/actions/ci-helper
|
||||
with:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
|
||||
- name: Build ci helper
|
||||
if: steps.ci-helper.outputs.cache-hit != 'true'
|
||||
run: cargo build --locked --package fluxer-ci
|
||||
|
||||
- name: Save ci helper
|
||||
if: github.ref == 'refs/heads/main' && steps.ci-helper.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
save: 'true'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||
@@ -91,18 +73,8 @@ jobs:
|
||||
toolchain: "1.98.1"
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Restore ci helper
|
||||
id: ci-helper
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
|
||||
- name: Build ci helper
|
||||
if: steps.ci-helper.outputs.cache-hit != 'true'
|
||||
run: cargo build --locked --package fluxer-ci
|
||||
- name: Set up ci helper
|
||||
uses: ./.github/actions/ci-helper
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||
@@ -119,22 +91,9 @@ jobs:
|
||||
|
||||
- name: Restore fluxer_app wasm artifacts
|
||||
id: app-wasm
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
uses: ./.github/actions/app-wasm-cache
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
mode: restore
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
@@ -142,22 +101,9 @@ jobs:
|
||||
|
||||
- name: Save fluxer_app wasm artifacts
|
||||
if: always() && github.ref == 'refs/heads/main' && steps.app-wasm.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
uses: ./.github/actions/app-wasm-cache
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
mode: save
|
||||
|
||||
rust:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -277,6 +223,9 @@ jobs:
|
||||
FLUXER_REQUIRE_MEDIA_FIXTURES: "1"
|
||||
run: cargo test --workspace --all-features --locked
|
||||
|
||||
- name: Run tests (libfluxcore)
|
||||
run: cargo test --locked --manifest-path fluxer_app/rust/libfluxcore/Cargo.toml
|
||||
|
||||
- name: Run desktop native workspace tests on Linux
|
||||
run: tools/ci/check-desktop-native-workspaces.sh test
|
||||
|
||||
@@ -302,18 +251,8 @@ jobs:
|
||||
fluxer_gateway/native/push_markdown_plaintext_nif -> target
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Restore ci helper
|
||||
id: ci-helper
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
|
||||
- name: Build ci helper
|
||||
if: steps.ci-helper.outputs.cache-hit != 'true'
|
||||
run: cargo build --locked --package fluxer-ci
|
||||
- name: Set up ci helper
|
||||
uses: ./.github/actions/ci-helper
|
||||
|
||||
- name: Set up Erlang
|
||||
uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124
|
||||
@@ -383,18 +322,8 @@ jobs:
|
||||
toolchain: "1.98.1"
|
||||
targets: wasm32-unknown-unknown
|
||||
|
||||
- name: Restore ci helper
|
||||
id: ci-helper
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
with:
|
||||
path: target/debug/fluxer-ci
|
||||
key: >-
|
||||
fluxer-ci-bin-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'Cargo.toml',
|
||||
'tools/ci/Cargo.toml', 'tools/ci/src/**', 'tools/ci/templates/**') }}
|
||||
|
||||
- name: Build ci helper
|
||||
if: steps.ci-helper.outputs.cache-hit != 'true'
|
||||
run: cargo build --locked --package fluxer-ci
|
||||
- name: Set up ci helper
|
||||
uses: ./.github/actions/ci-helper
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413
|
||||
@@ -411,22 +340,9 @@ jobs:
|
||||
|
||||
- name: Restore fluxer_app wasm artifacts
|
||||
id: app-wasm
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
uses: ./.github/actions/app-wasm-cache
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
mode: restore
|
||||
|
||||
- name: Run knip
|
||||
run: |
|
||||
@@ -434,22 +350,9 @@ jobs:
|
||||
|
||||
- name: Save fluxer_app wasm artifacts
|
||||
if: always() && github.ref == 'refs/heads/main' && steps.app-wasm.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9
|
||||
uses: ./.github/actions/app-wasm-cache
|
||||
with:
|
||||
path: |
|
||||
fluxer_app/pkgs/libfluxcore
|
||||
fluxer_app/pkgs/libfluxwebp
|
||||
fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
key: >-
|
||||
app-wasm-${{ runner.os }}-1.98.1-${{ hashFiles('Cargo.lock', 'tools/ci/src/app_wasm.rs',
|
||||
'tools/ci/templates/libfluxcore_wrapper.js', 'tools/ci/templates/libfluxcore_wrapper.d.ts',
|
||||
'fluxer_app/rust/libfluxcore/Cargo.toml', 'fluxer_app/rust/libfluxcore/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxcore/.cargo/config.toml', 'fluxer_app/rust/libfluxcore/src/**',
|
||||
'fluxer_app/rust/libfluxwebp/Cargo.toml', 'fluxer_app/rust/libfluxwebp/Cargo.lock',
|
||||
'fluxer_app/rust/libfluxwebp/src/**', 'fluxer_app/rust/libfluxwebp/shim/**',
|
||||
'fluxer_app/rust/libfluxwebp/simd/**',
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
mode: save
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -476,6 +379,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
|
||||
@@ -498,15 +404,14 @@ jobs:
|
||||
- name: Compile locale catalogs
|
||||
run: pnpm i18n:compile
|
||||
|
||||
- name: Check drift of compiled locale modules
|
||||
- name: Check drift of synced catalogs
|
||||
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 \
|
||||
packages/errors/src/i18n/weblate \
|
||||
fluxer_api/pkgs/email/src/email_i18n/weblate \
|
||||
fluxer_api/pkgs/email/src/email_i18n/EmailI18nTypes.generated.ts \
|
||||
fluxer_api/src/api/content_i18n/locales; then
|
||||
echo "::error::Compiled locale modules are outdated. Run 'pnpm i18n:compile' and commit the result. Translations belong in the weblate/ catalogs, not in the generated locales/ modules."
|
||||
fluxer_api/src/api/content_i18n/weblate; then
|
||||
echo "::error::Static catalogs are out of sync with their sources. Run 'pnpm i18n:compile' and commit the result."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
+7
-5
@@ -28,22 +28,24 @@
|
||||
/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
|
||||
/fluxer_app/src/features/ui/constants/AvatarStatusGeometry.ts
|
||||
/fluxer_gateway/priv/
|
||||
/fluxer_static/desktop/spellcheck/dictionaries/*/*
|
||||
!/fluxer_static/desktop/spellcheck/dictionaries/[email protected]/LICENSE
|
||||
!/fluxer_static/desktop/spellcheck/dictionaries/[email protected]/LICENSE
|
||||
|
||||
/fluxer_desktop/native/rust/fuzz/artifacts/
|
||||
/fluxer_desktop/native/rust/fuzz/corpus/
|
||||
/packages/markdown_parser/rust/fuzz/artifacts/
|
||||
/packages/markdown_parser/rust/fuzz/corpus/
|
||||
|
||||
/fluxer_media_proxy/.benchmark-cache/
|
||||
/fluxer_media_proxy/bench-results/
|
||||
|
||||
/app-dist-output/
|
||||
/artifacts/
|
||||
/desktop-shared-assets/
|
||||
/desktop-modules/
|
||||
/s3_payload/
|
||||
/upload_staging/
|
||||
|
||||
|
||||
Generated
+58
-251
@@ -49,9 +49,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "ammonia"
|
||||
version = "4.2.0"
|
||||
version = "4.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f7ed3ac4252be4d3e36fb589ed5659356842587b21a31596a9dc89d3b2f5e2a"
|
||||
checksum = "061e83b03c2681c18a6787d956e355c74e0b98ba7ba3d69b0822ade1e6f1d716"
|
||||
dependencies = [
|
||||
"cssparser 0.38.0",
|
||||
"html5ever 0.40.1",
|
||||
@@ -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"
|
||||
@@ -1185,8 +1157,6 @@ dependencies = [
|
||||
"num-traits",
|
||||
"oorandom",
|
||||
"page_size",
|
||||
"plotters",
|
||||
"rayon",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1213,16 +1183,6 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.21"
|
||||
@@ -1306,7 +1266,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1685,15 +1645,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 +1661,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 +1690,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 +1702,7 @@ dependencies = [
|
||||
"aws-config",
|
||||
"aws-sdk-s3",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"brotli",
|
||||
"chrono",
|
||||
"clap",
|
||||
"hex",
|
||||
@@ -1750,6 +1711,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"walkdir",
|
||||
@@ -1762,12 +1724,11 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"chrono",
|
||||
"clap",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"hex",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"image",
|
||||
"libc",
|
||||
"regex",
|
||||
"reqwest",
|
||||
@@ -1789,12 +1750,10 @@ dependencies = [
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
"moka",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -1811,7 +1770,6 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1824,7 +1782,6 @@ dependencies = [
|
||||
"bytes",
|
||||
"cc",
|
||||
"clap",
|
||||
"criterion",
|
||||
"flate2",
|
||||
"fluxer_common",
|
||||
"futures-util",
|
||||
@@ -1835,6 +1792,7 @@ dependencies = [
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"libc",
|
||||
"memmap2",
|
||||
"moka",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
@@ -1851,7 +1809,6 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http 0.7.1",
|
||||
"tracing",
|
||||
@@ -1867,20 +1824,16 @@ dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.23.1",
|
||||
"chrono",
|
||||
"criterion",
|
||||
"fluxer-svc",
|
||||
"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",
|
||||
@@ -1895,6 +1848,7 @@ dependencies = [
|
||||
"base64 0.23.1",
|
||||
"clap",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
"p256",
|
||||
@@ -1935,15 +1889,14 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"deadpool-postgres",
|
||||
"fluxer_common",
|
||||
"futures",
|
||||
"libc",
|
||||
"moka",
|
||||
"rmp-serde",
|
||||
"rustls",
|
||||
"scylla",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tokio-postgres-rustls",
|
||||
@@ -1962,7 +1915,6 @@ dependencies = [
|
||||
"entities",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"infer",
|
||||
"moka",
|
||||
"regex",
|
||||
@@ -1970,7 +1922,6 @@ dependencies = [
|
||||
"scraper",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -1986,11 +1937,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 +1955,6 @@ dependencies = [
|
||||
"axum",
|
||||
"base64 0.23.1",
|
||||
"chrono",
|
||||
"cookie",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"maud",
|
||||
@@ -2040,7 +1991,6 @@ dependencies = [
|
||||
"hex",
|
||||
"rand 0.10.2",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
@@ -2056,24 +2006,21 @@ name = "fluxer_common"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"aws-credential-types",
|
||||
"aws-sigv4",
|
||||
"axum",
|
||||
"base64 0.23.1",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"maxminddb",
|
||||
"moka",
|
||||
"http 1.5.0",
|
||||
"libc",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"url",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2260,16 +2207,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 +2596,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"
|
||||
@@ -2714,12 +2623,6 @@ version = "2.12.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||
|
||||
[[package]]
|
||||
name = "ipnetwork"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763"
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -2968,18 +2871,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maxminddb"
|
||||
version = "0.32.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b43d2458d977b610b04a3858e6504f06133663d303a1a0606601b530bfc9bc6"
|
||||
dependencies = [
|
||||
"ipnetwork",
|
||||
"memchr",
|
||||
"serde",
|
||||
"thiserror",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.11.0"
|
||||
@@ -2996,6 +2887,15 @@ version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
version = "0.9.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -3012,16 +2912,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 +2953,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"
|
||||
@@ -3428,47 +3308,6 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "plotters"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"plotters-backend",
|
||||
"plotters-svg",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "plotters-backend"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a"
|
||||
|
||||
[[package]]
|
||||
name = "plotters-svg"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670"
|
||||
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 +3480,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"
|
||||
@@ -3850,26 +3677,6 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -4171,7 +3978,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 +4595,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 +5410,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 +5628,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 +5798,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",
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -163,7 +163,6 @@
|
||||
"**",
|
||||
"!**/.git",
|
||||
"!**/app.css",
|
||||
"!fluxer_admin/public/static/app.css",
|
||||
"!**/build",
|
||||
"fluxer_app/scripts/build",
|
||||
"!**/dist",
|
||||
@@ -184,11 +183,8 @@
|
||||
"!packages/fonts",
|
||||
"!fluxer_admin/static/htmx.min.js",
|
||||
"!fluxer_api/src/api/openapi/openapi.json",
|
||||
"!fluxer_api/pkgs/email/src/email_i18n/locales",
|
||||
"!fluxer_api/pkgs/email/src/email_i18n/weblate",
|
||||
"!fluxer_api/src/api/content_i18n/locales",
|
||||
"!fluxer_api/src/api/content_i18n/weblate",
|
||||
"!packages/errors/src/i18n/locales",
|
||||
"!packages/errors/src/i18n/weblate",
|
||||
"!**/auto-i18n-reviewed-unchanged.json"
|
||||
],
|
||||
|
||||
Vendored
+2
-4
@@ -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
|
||||
@@ -103,7 +104,7 @@ FLUXER_EMAIL_SMTP_HOST=mailpit
|
||||
FLUXER_EMAIL_SMTP_PORT=1025
|
||||
FLUXER_EMAIL_SMTP_USERNAME=dev
|
||||
FLUXER_EMAIL_SMTP_PASSWORD=dev
|
||||
FLUXER_EMAIL_SMTP_SECURE=false
|
||||
FLUXER_EMAIL_SMTP_TLS_MODE=none
|
||||
FLUXER_SEARCH_ENGINE=meilisearch
|
||||
FLUXER_SEARCH_URL=http://meilisearch:7700
|
||||
FLUXER_SEARCH_API_KEY=fluxer-dev-meilisearch
|
||||
@@ -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
|
||||
@@ -120,8 +120,6 @@ FLUXER_TEST_MODE_ENABLED=false
|
||||
|
||||
PUBLIC_BUILD_VERSION=dev
|
||||
PUBLIC_RELEASE_CHANNEL=canary
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT=/api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT=http://localhost:8088/api
|
||||
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=Zmx1eGVyLWRldi11cGxvYWQtcmVsYXktc2VjcmV0LTAwMDA=
|
||||
FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64=Zmx1eGVyLWRldi1hdHRhY2htZW50LXVybC1zZWNyZXQ=
|
||||
AWS_EC2_METADATA_DISABLED=true
|
||||
@@ -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" },
|
||||
|
||||
@@ -56,7 +56,6 @@ workloads:
|
||||
name: fluxer-admin
|
||||
replicas: 1
|
||||
env:
|
||||
FLUXER_ENV: production
|
||||
FLUXER_API_ENDPOINT: https://api.example.com
|
||||
FLUXER_ADMIN_ENDPOINT: https://admin.example.com
|
||||
FLUXER_MEDIA_ENDPOINT: https://media.example.com
|
||||
@@ -71,10 +70,6 @@ workloads:
|
||||
image:
|
||||
name: fluxer-app-proxy-self-hosted
|
||||
replicas: 1
|
||||
env:
|
||||
RELEASE_CHANNEL: stable
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: https://web.example.com/api
|
||||
resources:
|
||||
requests:
|
||||
cpu: 50m
|
||||
|
||||
@@ -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
|
||||
@@ -164,8 +157,8 @@ MEILI_MASTER_KEY=CHANGE_ME
|
||||
#FLUXER_GEOIP_DB_PATH=
|
||||
|
||||
# The client address. The edge sets X-Forwarded-For on every hop, so keep the
|
||||
# trust on and the default header. Turning the trust off makes the api refuse
|
||||
# every request outside its exempt routes with a 403.
|
||||
# trust on and the default header. Turning the trust off makes the api ignore
|
||||
# the header and use the connection's peer address, which here is the edge.
|
||||
#FLUXER_CLIENT_IP_HEADER_NAME=x-forwarded-for
|
||||
#FLUXER_TRUST_CLIENT_IP_HEADER=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.
|
||||
@@ -239,8 +236,8 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
|
||||
# Optional media policies, both off by default. See the operator docs.
|
||||
#
|
||||
# CORS limits which web origins may read media. A request with no Origin is
|
||||
# always served. Add https://web.fluxer.app if people use the hosted client.
|
||||
# CORS limits which web origins may read media. A request with no Origin, or
|
||||
# from the desktop app, is always served.
|
||||
#
|
||||
# Signatures make an attachment read need a signed URL, so a copied link stops
|
||||
# working. Needs a secret from openssl rand -base64 32, first entry signs and
|
||||
@@ -250,7 +247,7 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
# media-proxy reads these at start, so apply with docker compose up -d
|
||||
# media-proxy. The values below are examples.
|
||||
#FLUXER_MEDIA_PROXY_CORS_MODE=enforce
|
||||
#FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS=https://chat.example.com,https://web.fluxer.app
|
||||
#FLUXER_MEDIA_PROXY_CORS_ALLOWED_ORIGINS=https://chat.example.com
|
||||
#FLUXER_MEDIA_PROXY_ATTACHMENT_URL_SECRETS_BASE64=
|
||||
#FLUXER_MEDIA_PROXY_ATTACHMENT_SIGNATURE_MODE=enforce
|
||||
|
||||
@@ -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=
|
||||
@@ -300,28 +297,43 @@ LIVEKIT_API_SECRET=CHANGE_ME
|
||||
#FLUXER_LIVEKIT_DEFAULT_REGION={"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}
|
||||
#FLUXER_LIVEKIT_LOG_LEVEL=info
|
||||
|
||||
# STUN servers for peer-to-peer calls, which need the voice_p2p experiment turned
|
||||
# on in the admin panel. STUN finds each peer's public address. The value below
|
||||
# is an example.
|
||||
#FLUXER_VOICE_P2P_STUN_URLS=stun:stun.example.com:3478
|
||||
|
||||
FLUXER_KLIPY_API_KEY=
|
||||
#FLUXER_YOUTUBE_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=
|
||||
FLUXER_EMAIL_SMTP_PORT=587
|
||||
FLUXER_EMAIL_SMTP_USERNAME=
|
||||
FLUXER_EMAIL_SMTP_PASSWORD=
|
||||
FLUXER_EMAIL_SMTP_SECURE=true
|
||||
# How the SMTP connection is encrypted: implicit, starttls, opportunistic or
|
||||
# none. Leave it empty for implicit TLS on port 465 and required STARTTLS on any
|
||||
# other port.
|
||||
FLUXER_EMAIL_SMTP_TLS_MODE=
|
||||
# The older switch, read only while the mode above is empty. true is implicit
|
||||
# and false is opportunistic.
|
||||
#FLUXER_EMAIL_SMTP_SECURE=false
|
||||
#FLUXER_EMAIL_WEBHOOK_SECRET=
|
||||
|
||||
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 +344,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
|
||||
@@ -356,10 +383,8 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_INVITE_ENDPOINT=
|
||||
#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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_LIVEKIT_INTERNAL_URL: ${FLUXER_LIVEKIT_INTERNAL_URL:-http://livekit:7880}
|
||||
FLUXER_LIVEKIT_DEFAULT_REGION: '${FLUXER_LIVEKIT_DEFAULT_REGION:-{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}}'
|
||||
FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT:-443}}/livekit}
|
||||
FLUXER_VOICE_P2P_STUN_URLS: ${FLUXER_VOICE_P2P_STUN_URLS:-}
|
||||
|
||||
FLUXER_KLIPY_API_KEY: ${FLUXER_KLIPY_API_KEY:-}
|
||||
FLUXER_YOUTUBE_API_KEY: ${FLUXER_YOUTUBE_API_KEY:-}
|
||||
@@ -120,6 +121,7 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_EMAIL_SMTP_PORT: ${FLUXER_EMAIL_SMTP_PORT:-}
|
||||
FLUXER_EMAIL_SMTP_USERNAME: ${FLUXER_EMAIL_SMTP_USERNAME:-}
|
||||
FLUXER_EMAIL_SMTP_PASSWORD: ${FLUXER_EMAIL_SMTP_PASSWORD:-}
|
||||
FLUXER_EMAIL_SMTP_TLS_MODE: ${FLUXER_EMAIL_SMTP_TLS_MODE:-}
|
||||
FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-}
|
||||
|
||||
FLUXER_STRIPE_ENABLED: ${FLUXER_STRIPE_ENABLED:-}
|
||||
@@ -128,14 +130,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 +149,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 +177,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 +615,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:-}
|
||||
@@ -652,24 +657,19 @@ services:
|
||||
environment:
|
||||
RUST_LOG: ${RUST_LOG:-}
|
||||
FLUXER_APP_PROXY_PORT: "8080"
|
||||
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
|
||||
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
|
||||
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
|
||||
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
|
||||
FLUXER_TRUST_CLIENT_IP_HEADER: "${FLUXER_TRUST_CLIENT_IP_HEADER:-true}"
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-}
|
||||
FLUXER_GEOIP_DB_PATH: ${FLUXER_GEOIP_DB_PATH:-}
|
||||
FLUXER_S3_ENDPOINT: ${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}
|
||||
FLUXER_S3_PUBLIC_ENDPOINT: ${FLUXER_S3_PUBLIC_ENDPOINT:-}
|
||||
FLUXER_S3_REGION: ${FLUXER_S3_REGION:-us-east-1}
|
||||
FLUXER_S3_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
||||
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:-}
|
||||
FLUXER_APP_PRODUCT_NAME: ${FLUXER_APP_PRODUCT_NAME:-}
|
||||
FLUXER_APP_ICON_URL: ${FLUXER_APP_ICON_URL:-}
|
||||
FLUXER_APP_SYMBOL_URL: ${FLUXER_APP_SYMBOL_URL:-}
|
||||
FLUXER_APP_LOGO_URL: ${FLUXER_APP_LOGO_URL:-}
|
||||
FLUXER_APP_WORDMARK_URL: ${FLUXER_APP_WORDMARK_URL:-}
|
||||
FLUXER_APP_FAVICON_URL: ${FLUXER_APP_FAVICON_URL:-}
|
||||
FLUXER_APP_THEME_COLOR: ${FLUXER_APP_THEME_COLOR:-}
|
||||
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
|
||||
DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-}
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api}
|
||||
FLUXER_APP_PROXY_INDEX_UPSTREAM_URL: ${FLUXER_APP_PROXY_INDEX_UPSTREAM_URL:-}
|
||||
FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS: ${FLUXER_APP_PROXY_SAME_ORIGIN_HOSTS:-}
|
||||
FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS: ${FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS:-}
|
||||
@@ -845,8 +845,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}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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");
|
||||
|
||||
Generated
+2175
-373
File diff suppressed because it is too large.
Load diff
@@ -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";
|
||||
@@ -148,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,
|
||||
@@ -176,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,
|
||||
|
||||
@@ -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,96 @@ pub const PREMIUM_FLAGS: &[I32Flag] = &[
|
||||
value: 1 << 8,
|
||||
},
|
||||
];
|
||||
|
||||
pub const DELETION_REASONS: &[(&str, &str)] = &[
|
||||
("1", "User requested"),
|
||||
("2", "Other"),
|
||||
("3", "Spam"),
|
||||
("4", "Cheating or exploitation"),
|
||||
("5", "Coordinated raiding or manipulation"),
|
||||
("6", "Automation or self-bot usage"),
|
||||
("7", "Nonconsensual sexual content"),
|
||||
("8", "Scam or social engineering"),
|
||||
("9", "Child sexual content"),
|
||||
("10", "Privacy violation or doxxing"),
|
||||
("11", "Harassment or bullying"),
|
||||
("12", "Payment fraud"),
|
||||
("13", "Child safety violation"),
|
||||
("14", "Billing dispute or abuse"),
|
||||
("15", "Unsolicited explicit content"),
|
||||
("16", "Graphic violence"),
|
||||
("17", "Ban evasion"),
|
||||
("18", "Token or credential scam"),
|
||||
("19", "Inactivity"),
|
||||
("20", "Hate speech or extremist content"),
|
||||
("21", "Malicious links or malware distribution"),
|
||||
("22", "Impersonation or fake identity"),
|
||||
];
|
||||
|
||||
pub const GUILD_FEATURES: &[&str] = &[
|
||||
"ANIMATED_ICON",
|
||||
"ANIMATED_BANNER",
|
||||
"AUDIO_BITRATE_128_KBPS",
|
||||
"AUDIO_BITRATE_256_KBPS",
|
||||
"AUDIO_BITRATE_384_KBPS",
|
||||
"BANNER",
|
||||
"CLONE_EMOJI_DISABLED",
|
||||
"CLONE_EMOJI_ENABLED",
|
||||
"CLONE_STICKER_DISABLED",
|
||||
"CLONE_STICKER_ENABLED",
|
||||
"DETACHED_BANNER",
|
||||
"INVITE_SPLASH",
|
||||
"INVITES_DISABLED",
|
||||
"RAID_DETECTED",
|
||||
"TEXT_CHANNEL_FLEXIBLE_NAMES",
|
||||
"HIDE_OWNER_CROWN",
|
||||
"MORE_EMOJI",
|
||||
"MORE_STICKERS",
|
||||
"UNLIMITED_EMOJI",
|
||||
"UNLIMITED_STICKERS",
|
||||
"EXPRESSION_PURGE_ALLOWED",
|
||||
"VANITY_URL",
|
||||
"DISCOVERABLE",
|
||||
"PARTNERED",
|
||||
"VERIFIED",
|
||||
"VIP_VOICE",
|
||||
"VOICE_E2EE",
|
||||
"UNAVAILABLE_FOR_EVERYONE",
|
||||
"UNAVAILABLE_FOR_EVERYONE_BUT_STAFF",
|
||||
"UNAVAILABLE_HIDDEN",
|
||||
"VISIONARY",
|
||||
"LARGE_GUILD_OVERRIDE",
|
||||
"VERY_LARGE_GUILD",
|
||||
"ANNOUNCEMENT_CHANNELS_DISABLED",
|
||||
];
|
||||
|
||||
pub const DEPRECATED_GUILD_FEATURES: &[&str] = &["CLONE_EMOJI_DISABLED", "CLONE_STICKER_DISABLED"];
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,11 @@ pub fn limit_key_hint(key: &str) -> Option<Hint<'static>> {
|
||||
"Community creation policy",
|
||||
)),
|
||||
}),
|
||||
"max_screen_share_bitrate" => Some(Hint {
|
||||
name: Some("Max Screen Share Bitrate"),
|
||||
body: "In bits per second, so 9000000 is 9 Mbps. Raising it also raises the automatic bitrate of every screen share quality. Accounts with Higher Video Quality can pick their own bitrate up to this value.",
|
||||
link: None,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use reqwest::Method;
|
||||
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{Application, ApplicationUpdateResponse, LookupApplicationResponse};
|
||||
use serde::Serialize;
|
||||
@@ -43,12 +45,13 @@ impl AdminApiClient {
|
||||
new_owner_id: &str,
|
||||
) -> ApiResult<ApplicationUpdateResponse> {
|
||||
let body = TransferApplicationOwnershipRequest { new_owner_id };
|
||||
self.patch_typed_with_reason(
|
||||
self.send(
|
||||
Method::PATCH,
|
||||
&format!(
|
||||
"/admin/applications/{}",
|
||||
urlencoding::encode(application_id)
|
||||
),
|
||||
&body,
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
use reqwest::Method;
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{BanAvatarResult, BanCheckResult, BulkBanResult};
|
||||
use super::types::{BanCheckResult, BlocklistEntryPage, BulkBanResult};
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn ban_email(&self, email: &str, audit_log_reason: Option<&str>) -> ApiResult<()> {
|
||||
@@ -28,11 +30,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 +150,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,
|
||||
@@ -180,9 +207,10 @@ impl AdminApiClient {
|
||||
let body = generated_types::BulkBanFileShasRequest {
|
||||
sha256_list: sha256_list.to_vec(),
|
||||
};
|
||||
self.put_typed_with_reason(
|
||||
self.send(
|
||||
Method::PUT,
|
||||
"/admin/blocklists/file-sha/entries",
|
||||
&body,
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
@@ -224,16 +252,6 @@ impl AdminApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn ban_user_avatar(&self, user_id: &str) -> ApiResult<BanAvatarResult> {
|
||||
let body = generated_types::BanUserAvatarRequest::default();
|
||||
let response = self
|
||||
.generated()
|
||||
.ban_admin_user_avatar(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn ban_profile_substring(
|
||||
&self,
|
||||
scope: &str,
|
||||
@@ -323,6 +341,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()))
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::{guild_features, user_flags};
|
||||
|
||||
use reqwest::Method;
|
||||
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
@@ -18,8 +22,13 @@ impl AdminApiClient {
|
||||
remove_flags: user_flags(remove_flags)?,
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/bulk-jobs",
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_update_guild_features(
|
||||
@@ -34,8 +43,13 @@ impl AdminApiClient {
|
||||
guild_ids: snowflakes(guild_ids),
|
||||
remove_features: guild_features(remove_features),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/bulk-jobs",
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_add_guild_members(
|
||||
@@ -48,8 +62,13 @@ impl AdminApiClient {
|
||||
guild_id: snowflake(guild_id),
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/bulk-jobs",
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_delete_user_messages(
|
||||
@@ -60,8 +79,13 @@ impl AdminApiClient {
|
||||
let body = generated_types::AdminBulkJobCreateRequest::DeleteUserMessages {
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/bulk-jobs",
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bulk_schedule_user_deletion(
|
||||
@@ -89,29 +113,16 @@ impl AdminApiClient {
|
||||
.map_err(ApiError::Parse)?,
|
||||
user_ids: snowflakes(user_ids),
|
||||
};
|
||||
self.post_typed_with_reason("/admin/bulk-jobs", &body, audit_log_reason)
|
||||
.await
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/bulk-jobs",
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn snowflakes(values: &[String]) -> Vec<generated_types::SnowflakeType> {
|
||||
values.iter().map(|value| snowflake(value)).collect()
|
||||
}
|
||||
|
||||
fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
|
||||
values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
generated_types::UserFlags::try_from(value.as_str())
|
||||
.map_err(|error| ApiError::Parse(error.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn guild_features(values: &[String]) -> Vec<generated_types::GuildFeatureSchema> {
|
||||
values
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(generated_types::GuildFeatureSchema::from)
|
||||
.collect()
|
||||
}
|
||||
+17
-146
@@ -49,6 +49,15 @@ impl AdminApiClient {
|
||||
Self { generated }
|
||||
}
|
||||
|
||||
pub fn anonymous(http_client: &reqwest::Client, config: &AdminConfig) -> Self {
|
||||
let generated = GeneratedClient::new_with_client(
|
||||
&config.api_endpoint,
|
||||
http_client.clone(),
|
||||
build_proxy_client_ip_headers(config),
|
||||
);
|
||||
Self { generated }
|
||||
}
|
||||
|
||||
fn build_url(&self, path: &str, query_params: Option<&[(&str, &str)]>) -> String {
|
||||
let mut url = format!("{}{}", self.generated.baseurl(), path);
|
||||
let query = query_params
|
||||
@@ -127,170 +136,32 @@ impl AdminApiClient {
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn post<T: DeserializeOwned>(
|
||||
pub async fn send<T, B>(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
) -> ApiResult<T> {
|
||||
self.post_with_reason(path, body, None).await
|
||||
}
|
||||
|
||||
pub async fn post_typed<T, B>(&self, path: &str, body: &B) -> ApiResult<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
B: Serialize + ?Sized,
|
||||
{
|
||||
self.post_typed_with_reason(path, body, None).await
|
||||
}
|
||||
|
||||
pub async fn post_typed_with_reason<T, B>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
body: Option<&B>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
B: Serialize + ?Sized,
|
||||
{
|
||||
let response = self
|
||||
.send_json(Method::POST, path, Some(body), audit_log_reason)
|
||||
.await?;
|
||||
let response = self.send_json(method, path, body, audit_log_reason).await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn post_with_reason<T: DeserializeOwned>(
|
||||
pub async fn send_void<B: Serialize + ?Sized>(
|
||||
&self,
|
||||
method: Method,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let response = self
|
||||
.send_json(Method::POST, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn post_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
|
||||
self.post_void_with_reason(path, body, None).await
|
||||
}
|
||||
|
||||
pub async fn post_void_with_reason(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
body: Option<&B>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let response = self
|
||||
.send_json(Method::POST, path, body, audit_log_reason)
|
||||
.await?;
|
||||
let response = self.send_json(method, path, body, audit_log_reason).await?;
|
||||
Self::parse_void_response(response).await
|
||||
}
|
||||
|
||||
pub async fn patch<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
) -> ApiResult<T> {
|
||||
self.patch_with_reason(path, body, None).await
|
||||
}
|
||||
|
||||
pub async fn patch_with_reason<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let response = self
|
||||
.send_json(Method::PATCH, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn patch_typed_with_reason<T, B>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
B: Serialize + ?Sized,
|
||||
{
|
||||
let response = self
|
||||
.send_json(Method::PATCH, path, Some(body), audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn put_with_reason<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let response = self
|
||||
.send_json(Method::PUT, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn put_typed_with_reason<T, B>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: &B,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T>
|
||||
where
|
||||
T: DeserializeOwned,
|
||||
B: Serialize + ?Sized,
|
||||
{
|
||||
let response = self
|
||||
.send_json(Method::PUT, path, Some(body), audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
pub async fn put_void_with_reason(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let response = self
|
||||
.send_json(Method::PUT, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_void_response(response).await
|
||||
}
|
||||
|
||||
pub async fn delete_void(&self, path: &str, body: Option<&serde_json::Value>) -> ApiResult<()> {
|
||||
self.delete_void_with_reason(path, body, None).await
|
||||
}
|
||||
|
||||
pub async fn delete_void_with_reason(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let response = self
|
||||
.send_json(Method::DELETE, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_void_response(response).await
|
||||
}
|
||||
|
||||
pub async fn delete_with_reason<T: DeserializeOwned>(
|
||||
&self,
|
||||
path: &str,
|
||||
body: Option<&serde_json::Value>,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<T> {
|
||||
let response = self
|
||||
.send_json(Method::DELETE, path, body, audit_log_reason)
|
||||
.await?;
|
||||
Self::parse_response(response).await
|
||||
}
|
||||
|
||||
async fn parse_void_response(response: reqwest::Response) -> ApiResult<()> {
|
||||
Self::check_response_status(response).await.map(drop)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::float_to_u64;
|
||||
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
@@ -144,10 +146,6 @@ fn listed_guild(
|
||||
})
|
||||
}
|
||||
|
||||
fn float_to_u64(value: f64, field: &str) -> ApiResult<u64> {
|
||||
crate::api::generated::number_to_u64(value, field).map_err(ApiError::Parse)
|
||||
}
|
||||
|
||||
fn float_to_i32(value: f64, field: &str) -> ApiResult<i32> {
|
||||
let parsed = crate::api::generated::number_to_u64(value, field).map_err(ApiError::Parse)?;
|
||||
i32::try_from(parsed).map_err(|_| ApiError::Parse(format!("{field} is out of range: {value}")))
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use self::types as generated_types;
|
||||
use crate::api::client::{ApiError, ApiResult};
|
||||
|
||||
use progenitor_client::{ClientHooks, ClientInfo, Error, OperationInfo};
|
||||
use reqwest::header::HeaderMap;
|
||||
|
||||
@@ -61,94 +64,35 @@ pub(crate) fn deletion_reason_code(
|
||||
.map_err(|_| format!("{field} is not a deletion reason code: {value}"))
|
||||
}
|
||||
|
||||
pub(crate) fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
|
||||
values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
generated_types::UserFlags::try_from(value.as_str())
|
||||
.map_err(|error| ApiError::Parse(error.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn guild_features(values: &[String]) -> Vec<generated_types::GuildFeatureSchema> {
|
||||
values
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(generated_types::GuildFeatureSchema::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn bool_param(value: bool) -> &'static str {
|
||||
if value { "true" } else { "false" }
|
||||
}
|
||||
|
||||
pub(crate) fn float_to_u64(value: f64, field: &str) -> ApiResult<u64> {
|
||||
crate::api::generated::number_to_u64(value, field).map_err(ApiError::Parse)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{number_to_u64, types::*};
|
||||
|
||||
#[test]
|
||||
fn deserialize_admin_users_me() {
|
||||
let json = serde_json::json!({
|
||||
"user": {
|
||||
"id": "123456789012345678",
|
||||
"username": "testadmin",
|
||||
"discriminator": 1,
|
||||
"global_name": "Test Admin",
|
||||
"bot": false,
|
||||
"system": false,
|
||||
"flags": "131141",
|
||||
"premium_flags": 0,
|
||||
"avatar": "abc123def456",
|
||||
"banner": null,
|
||||
"bio": "Hello world",
|
||||
"pronouns": null,
|
||||
"accent_color": null,
|
||||
"email": "[email protected]",
|
||||
"email_verified": true,
|
||||
"email_bounced": false,
|
||||
"date_of_birth": "2000-01-15",
|
||||
"locale": "en-US",
|
||||
"premium_type": 2,
|
||||
"premium_since": "2024-01-01T00:00:00.000Z",
|
||||
"premium_until": null,
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"acls": ["MANAGE_USERS", "VIEW_AUDIT_LOG"],
|
||||
"traits": ["STAFF"],
|
||||
"has_totp": true,
|
||||
"authenticator_types": [0],
|
||||
"last_active_at": "2026-05-26T12:00:00.000Z",
|
||||
"last_active_ip": "203.0.113.1",
|
||||
"last_active_ip_reverse": "1.113.0.203.in-addr.arpa",
|
||||
"last_active_location": "New York, US"
|
||||
}
|
||||
});
|
||||
|
||||
let response: AdminUsersMeResponse =
|
||||
serde_json::from_value(json).expect("failed to deserialize AdminUsersMeResponse");
|
||||
|
||||
assert_eq!(response.user.id.to_string(), "123456789012345678");
|
||||
assert_eq!(response.user.username, "testadmin");
|
||||
assert!(!response.user.bot);
|
||||
assert!(response.user.email_verified);
|
||||
assert_eq!(response.user.acls.len(), 2);
|
||||
assert_eq!(response.user.global_name, Some("Test Admin".to_string()));
|
||||
assert!(response.user.banner.is_none());
|
||||
assert!(response.user.has_totp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_search_guilds_response() {
|
||||
let json = serde_json::json!({
|
||||
"guilds": [
|
||||
{
|
||||
"id": "987654321098765432",
|
||||
"name": "Test Guild",
|
||||
"icon": "def456abc123",
|
||||
"banner": null,
|
||||
"owner_id": "123456789012345678",
|
||||
"owner_username": "guildowner",
|
||||
"owner_global_name": "Guild Owner",
|
||||
"owner_discriminator": "0001",
|
||||
"member_count": 42,
|
||||
"features": ["COMMUNITY", "NEWS"]
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
});
|
||||
|
||||
let response: SearchGuildsResponse =
|
||||
serde_json::from_value(json).expect("failed to deserialize SearchGuildsResponse");
|
||||
|
||||
assert_eq!(response.total, 1.0);
|
||||
assert_eq!(response.guilds.len(), 1);
|
||||
assert_eq!(response.guilds[0].name, "Test Guild");
|
||||
assert_eq!(*response.guilds[0].member_count, 42);
|
||||
}
|
||||
use super::number_to_u64;
|
||||
|
||||
#[test]
|
||||
fn reject_lossy_generated_number_count() {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::guild_features;
|
||||
|
||||
use reqwest::Method;
|
||||
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::reports::SearchReportsParams;
|
||||
use super::types::{
|
||||
GuildAuditLogResponse, GuildDetailInfo, GuildInfo, GuildUpdateResponse,
|
||||
ListGuildMembersResponse, LookupGuildResponse, SearchGuildsResponse, SearchReportsResponse,
|
||||
SuccessResponse,
|
||||
GuildAuditLogResponse, GuildDetailInfo, GuildInfo, ListGuildMembersResponse,
|
||||
LookupGuildResponse, SearchGuildsResponse, SearchReportsResponse, SuccessResponse,
|
||||
};
|
||||
|
||||
impl AdminApiClient {
|
||||
@@ -53,18 +56,17 @@ impl AdminApiClient {
|
||||
guild_id: &str,
|
||||
add_features: &[String],
|
||||
remove_features: &[String],
|
||||
) -> ApiResult<GuildUpdateResponse> {
|
||||
) -> ApiResult<()> {
|
||||
let body = generated_types::UpdateGuildRequest {
|
||||
add_features: guild_features(add_features),
|
||||
remove_features: guild_features(remove_features),
|
||||
..Default::default()
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
self.generated()
|
||||
.update_admin_guild(&snowflake(guild_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
|
||||
@@ -80,17 +82,16 @@ impl AdminApiClient {
|
||||
&self,
|
||||
guild_id: &str,
|
||||
new_owner_id: &str,
|
||||
) -> ApiResult<GuildUpdateResponse> {
|
||||
) -> ApiResult<()> {
|
||||
let body = generated_types::UpdateGuildRequest {
|
||||
new_owner_id: Some(snowflake(new_owner_id)),
|
||||
..Default::default()
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
self.generated()
|
||||
.update_admin_guild(&snowflake(guild_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_guild_members(
|
||||
@@ -177,44 +178,37 @@ impl AdminApiClient {
|
||||
&self,
|
||||
guild_id: &str,
|
||||
settings: &serde_json::Value,
|
||||
) -> ApiResult<GuildUpdateResponse> {
|
||||
) -> ApiResult<()> {
|
||||
let body = guild_settings_request(settings)?;
|
||||
let response = self
|
||||
.generated()
|
||||
self.generated()
|
||||
.update_admin_guild(&snowflake(guild_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
guild_update_response(response.into_inner())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_guild_name(
|
||||
&self,
|
||||
guild_id: &str,
|
||||
name: &str,
|
||||
) -> ApiResult<GuildUpdateResponse> {
|
||||
pub async fn update_guild_name(&self, guild_id: &str, name: &str) -> ApiResult<()> {
|
||||
let body = generated_types::UpdateGuildRequest {
|
||||
name: Some(name.to_owned()),
|
||||
..Default::default()
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
self.generated()
|
||||
.update_admin_guild(&snowflake(guild_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_guild_vanity(
|
||||
&self,
|
||||
guild_id: &str,
|
||||
vanity: Option<&str>,
|
||||
) -> ApiResult<GuildUpdateResponse> {
|
||||
pub async fn update_guild_vanity(&self, guild_id: &str, vanity: Option<&str>) -> ApiResult<()> {
|
||||
let body = serde_json::json!({"vanity_url_code": vanity});
|
||||
self.patch(
|
||||
self.send::<serde::de::IgnoredAny, _>(
|
||||
Method::PATCH,
|
||||
&format!("/admin/guilds/{}", urlencoding::encode(guild_id)),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.map(drop)
|
||||
}
|
||||
|
||||
pub async fn reload_guild(&self, guild_id: &str) -> ApiResult<SuccessResponse> {
|
||||
@@ -316,36 +310,6 @@ fn guild_admin_response(response: generated_types::GuildAdminResponse) -> ApiRes
|
||||
})
|
||||
}
|
||||
|
||||
fn guild_update_response(
|
||||
response: generated_types::GuildUpdateResponse,
|
||||
) -> ApiResult<GuildUpdateResponse> {
|
||||
let guild = response.guild;
|
||||
Ok(GuildUpdateResponse {
|
||||
guild: GuildInfo {
|
||||
id: String::from(guild.id),
|
||||
name: String::from(guild.name),
|
||||
icon: guild.icon.map(String::from),
|
||||
banner: guild.banner.map(String::from),
|
||||
owner_id: String::from(guild.owner_id),
|
||||
owner_username: None,
|
||||
owner_global_name: None,
|
||||
owner_discriminator: None,
|
||||
member_count: crate::api::generated::i64_to_u64(
|
||||
i64::from(i32::from(guild.member_count)),
|
||||
"member_count",
|
||||
)
|
||||
.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),
|
||||
description: None,
|
||||
vanity_url_code: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn guild_settings_request(
|
||||
settings: &serde_json::Value,
|
||||
) -> ApiResult<generated_types::UpdateGuildRequest> {
|
||||
@@ -365,14 +329,6 @@ fn guild_settings_request(
|
||||
})
|
||||
}
|
||||
|
||||
fn guild_features(values: &[String]) -> Vec<generated_types::GuildFeatureSchema> {
|
||||
values
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(generated_types::GuildFeatureSchema::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use reqwest::Method;
|
||||
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{
|
||||
CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceConfigResponse,
|
||||
InstanceConfigUpdateRequest, InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse,
|
||||
InstancePremiumDiscovery,
|
||||
AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse,
|
||||
InstanceAccountIdentityDiscovery, InstanceConfigResponse, InstanceConfigUpdateRequest,
|
||||
InstanceEmailSmtpTestResponse, InstancePremiumDiscovery,
|
||||
};
|
||||
|
||||
impl AdminApiClient {
|
||||
@@ -16,37 +18,57 @@ 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,
|
||||
) -> ApiResult<InstanceConfigResponse> {
|
||||
self.patch_typed_with_reason("/admin/instance/config", update, None)
|
||||
self.send(Method::PATCH, "/admin/instance/config", Some(update), None)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn test_instance_smtp_config(
|
||||
pub async fn test_saved_instance_smtp_config(
|
||||
&self,
|
||||
request: &InstanceEmailSmtpTestRequest,
|
||||
) -> ApiResult<InstanceEmailSmtpTestResponse> {
|
||||
self.post_typed("/admin/instance/config/smtp-tests", request)
|
||||
.await
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/instance/config/smtp-tests",
|
||||
Some(&serde_json::json!({})),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_registration_url(
|
||||
&self,
|
||||
request: &CreateRegistrationUrlRequest,
|
||||
) -> ApiResult<CreateRegistrationUrlResponse> {
|
||||
self.post_typed("/admin/instance/registration-urls", request)
|
||||
.await
|
||||
self.send(
|
||||
Method::POST,
|
||||
"/admin/instance/registration-urls",
|
||||
Some(request),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn revoke_registration_url(&self, id: &str) -> ApiResult<InstanceConfigResponse> {
|
||||
self.delete_with_reason(
|
||||
self.send(
|
||||
Method::DELETE,
|
||||
&format!(
|
||||
"/admin/instance/registration-urls/{}",
|
||||
urlencoding::encode(id)
|
||||
),
|
||||
None,
|
||||
None::<&serde_json::Value>,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -72,7 +94,8 @@ impl AdminApiClient {
|
||||
status: &str,
|
||||
) -> ApiResult<InstanceConfigResponse> {
|
||||
let body = serde_json::json!({"status": status});
|
||||
self.patch_with_reason(
|
||||
self.send(
|
||||
Method::PATCH,
|
||||
&format!(
|
||||
"/admin/instance/pending-registrations/{}",
|
||||
urlencoding::encode(user_id)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use reqwest::Method;
|
||||
|
||||
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,
|
||||
@@ -52,22 +54,14 @@ impl AdminApiClient {
|
||||
job_id: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<CancelJobResponse> {
|
||||
self.put_with_reason(
|
||||
self.send(
|
||||
Method::PUT,
|
||||
&format!("/admin/jobs/{}/cancellation", urlencoding::encode(job_id)),
|
||||
None,
|
||||
None::<&serde_json::Value>,
|
||||
audit_log_reason,
|
||||
)
|
||||
.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,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use reqwest::Method;
|
||||
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{LimitConfigResponse, LimitConfigUpdateRequest};
|
||||
|
||||
@@ -12,7 +14,7 @@ impl AdminApiClient {
|
||||
&self,
|
||||
request: &LimitConfigUpdateRequest,
|
||||
) -> ApiResult<LimitConfigResponse> {
|
||||
self.put_typed_with_reason("/admin/limit-config", request, None)
|
||||
self.send(Method::PUT, "/admin/limit-config", Some(request), None)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use reqwest::Method;
|
||||
|
||||
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 {
|
||||
@@ -17,50 +18,20 @@ impl AdminApiClient {
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
let _: serde_json::Value = self
|
||||
.delete_with_reason(
|
||||
.send(
|
||||
Method::DELETE,
|
||||
&format!(
|
||||
"/admin/channels/{}/messages/{}",
|
||||
urlencoding::encode(channel_id),
|
||||
urlencoding::encode(message_id)
|
||||
),
|
||||
None,
|
||||
None::<&serde_json::Value>,
|
||||
audit_log_reason,
|
||||
)
|
||||
.await?;
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::snowflake;
|
||||
use reqwest::Method;
|
||||
|
||||
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 +16,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 +31,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,16 +43,18 @@ 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);
|
||||
}
|
||||
body["notify_reporter"] = serde_json::Value::from(notify_reporter);
|
||||
self.patch_with_reason(
|
||||
self.send(
|
||||
Method::PATCH,
|
||||
&format!("/admin/reports/{}", urlencoding::encode(report_id)),
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
@@ -72,6 +62,42 @@ 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.send(
|
||||
Method::POST,
|
||||
&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.send_void(
|
||||
Method::DELETE,
|
||||
&format!("/admin/reports/{}", urlencoding::encode(report_id)),
|
||||
None::<&serde_json::Value>,
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn search_reports(
|
||||
&self,
|
||||
params: &SearchReportsParams<'_>,
|
||||
@@ -98,11 +124,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 +158,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,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::float_to_u64;
|
||||
|
||||
use crate::api::generated::{snowflake, types as generated_types};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
@@ -74,10 +76,6 @@ fn index_refresh_status(
|
||||
}
|
||||
}
|
||||
|
||||
fn float_to_u64(value: f64, field: &str) -> ApiResult<u64> {
|
||||
crate::api::generated::number_to_u64(value, field).map_err(ApiError::Parse)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -61,11 +61,6 @@ pub struct LookupGuildResponse {
|
||||
pub guild: Option<GuildDetailInfo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct GuildUpdateResponse {
|
||||
pub guild: GuildInfo,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct ListUserGuildsResponse {
|
||||
pub guilds: Vec<GuildInfo>,
|
||||
@@ -255,19 +250,35 @@ 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,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct BanAvatarResult {
|
||||
pub hash_short: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct SuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -119,6 +119,9 @@ pub struct InstancePremiumDiscoveryAppPublic {
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
pub struct InstancePremiumDiscoveryBranding {
|
||||
pub product_name: Option<String>,
|
||||
pub icon_url: Option<String>,
|
||||
pub favicon_url: Option<String>,
|
||||
pub premium_product_name: Option<String>,
|
||||
}
|
||||
|
||||
@@ -130,46 +133,67 @@ pub struct InstancePremiumDiscoveryFeatures {
|
||||
|
||||
impl InstancePremiumDiscovery {
|
||||
pub fn premium_product_name(&self) -> Option<&str> {
|
||||
self.app_public
|
||||
.branding
|
||||
.premium_product_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
non_blank(self.app_public.branding.premium_product_name.as_deref())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||
pub struct PremiumBranding {
|
||||
pub name: Option<String>,
|
||||
pub premium_enabled: bool,
|
||||
pub product_name: Option<String>,
|
||||
pub favicon_url: Option<String>,
|
||||
}
|
||||
|
||||
impl PremiumBranding {
|
||||
pub fn from_discovery(discovery: &InstancePremiumDiscovery) -> Self {
|
||||
let branding = &discovery.app_public.branding;
|
||||
Self {
|
||||
name: discovery.premium_product_name().map(str::to_owned),
|
||||
premium_enabled: discovery.features.premium_enabled,
|
||||
product_name: non_blank(branding.product_name.as_deref()).map(str::to_owned),
|
||||
favicon_url: favicon_url(
|
||||
branding.favicon_url.as_deref(),
|
||||
branding.icon_url.as_deref(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_instance_config(config: &InstanceConfigResponse) -> Self {
|
||||
Self::from_config_parts(
|
||||
config.self_hosted,
|
||||
&config.app_public.branding.premium_product_name,
|
||||
config.policy.premium_mode,
|
||||
)
|
||||
let branding = &config.app_public.branding;
|
||||
Self {
|
||||
product_name: non_blank(Some(&branding.product_name)).map(str::to_owned),
|
||||
favicon_url: favicon_url(
|
||||
branding.favicon_url.as_deref(),
|
||||
branding.icon_url.as_deref(),
|
||||
),
|
||||
..Self::from_config_parts(
|
||||
config.self_hosted,
|
||||
&branding.premium_product_name,
|
||||
config.policy.premium_mode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn from_config_parts(self_hosted: bool, name: &str, premium_mode: PremiumMode) -> Self {
|
||||
let name = name.trim();
|
||||
Self {
|
||||
name: (!name.is_empty()).then(|| name.to_owned()),
|
||||
name: non_blank(Some(name)).map(str::to_owned),
|
||||
premium_enabled: !self_hosted || matches!(premium_mode, PremiumMode::Mirror),
|
||||
..Self::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn non_blank(value: Option<&str>) -> Option<&str> {
|
||||
value.map(str::trim).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn favicon_url(favicon_url: Option<&str>, icon_url: Option<&str>) -> Option<String> {
|
||||
non_blank(favicon_url)
|
||||
.or_else(|| non_blank(icon_url))
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -304,18 +328,52 @@ mod tests {
|
||||
PremiumBranding::from_discovery(&discovery),
|
||||
PremiumBranding {
|
||||
name: Some("Gold".to_owned()),
|
||||
premium_enabled: true
|
||||
premium_enabled: true,
|
||||
product_name: Some("Example".to_owned()),
|
||||
favicon_url: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_branding_prefers_the_favicon_and_falls_back_to_the_icon() {
|
||||
let discovery = |branding| -> InstancePremiumDiscovery {
|
||||
serde_json::from_value(json!({"app_public": {"branding": branding}}))
|
||||
.expect("discovery")
|
||||
};
|
||||
let both = discovery(json!({
|
||||
"favicon_url": "https://media.example/favicon.png",
|
||||
"icon_url": "https://media.example/icon.png"
|
||||
}));
|
||||
assert_eq!(
|
||||
PremiumBranding::from_discovery(&both)
|
||||
.favicon_url
|
||||
.as_deref(),
|
||||
Some("https://media.example/favicon.png")
|
||||
);
|
||||
let icon_only =
|
||||
discovery(json!({"favicon_url": " ", "icon_url": "https://media.example/icon.png"}));
|
||||
assert_eq!(
|
||||
PremiumBranding::from_discovery(&icon_only)
|
||||
.favicon_url
|
||||
.as_deref(),
|
||||
Some("https://media.example/icon.png")
|
||||
);
|
||||
let neither = discovery(json!({"product_name": " "}));
|
||||
assert_eq!(
|
||||
PremiumBranding::from_discovery(&neither),
|
||||
PremiumBranding::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn premium_branding_from_instance_config_matches_discovery_rules() {
|
||||
assert_eq!(
|
||||
PremiumBranding::from_config_parts(true, " Gold ", PremiumMode::Everyone),
|
||||
PremiumBranding {
|
||||
name: Some("Gold".to_owned()),
|
||||
premium_enabled: false
|
||||
premium_enabled: false,
|
||||
..PremiumBranding::default()
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
@@ -325,7 +383,8 @@ mod tests {
|
||||
PremiumBranding::from_config_parts(false, " ", PremiumMode::Everyone),
|
||||
PremiumBranding {
|
||||
name: None,
|
||||
premium_enabled: true
|
||||
premium_enabled: true,
|
||||
..PremiumBranding::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,7 +27,7 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub domain_migration: DomainMigrationConfigResponse,
|
||||
#[serde(default)]
|
||||
pub plutonium_page: PlutoniumPageConfigResponse,
|
||||
pub voice_p2p: VoiceP2pConfigResponse,
|
||||
#[serde(default)]
|
||||
pub captcha: CaptchaConfigResponse,
|
||||
#[serde(default)]
|
||||
@@ -34,6 +36,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)]
|
||||
@@ -154,7 +229,8 @@ pub struct InstanceEmailSmtpIntegrationResponse {
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub password_set: bool,
|
||||
pub secure: Option<bool>,
|
||||
pub tls_mode: Option<String>,
|
||||
pub effective_tls_mode: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
@@ -344,6 +420,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)]
|
||||
@@ -431,8 +509,10 @@ impl VoiceE2eeScope {
|
||||
}
|
||||
|
||||
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
|
||||
pub const EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES: usize = 250;
|
||||
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
|
||||
pub const PLUTONIUM_PAGE_DEFAULT_SALT: &str = "plutonium-page-v1";
|
||||
pub const VOICE_P2P_DEFAULT_SALT: &str = "voice-p2p-v1";
|
||||
pub const VOICE_P2P_MAX_PARTICIPANTS_RANGE: std::ops::RangeInclusive<u32> = 2..=4;
|
||||
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;
|
||||
|
||||
@@ -456,6 +536,7 @@ pub struct DomainMigrationConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_country_codes: Vec<String>,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub included_guild_ids: Vec<String>,
|
||||
@@ -471,6 +552,7 @@ impl Default for DomainMigrationConfigResponse {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_country_codes: Vec::new(),
|
||||
rollout_salt: DOMAIN_MIGRATION_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
@@ -489,6 +571,8 @@ pub struct DomainMigrationConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_country_codes: Option<Vec<String>>,
|
||||
#[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>>,
|
||||
@@ -506,39 +590,45 @@ pub struct DomainMigrationConfigUpdateRequest {
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct PlutoniumPageConfigResponse {
|
||||
pub struct VoiceP2pConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_country_codes: Vec<String>,
|
||||
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>,
|
||||
pub max_participants: u32,
|
||||
}
|
||||
|
||||
impl Default for PlutoniumPageConfigResponse {
|
||||
impl Default for VoiceP2pConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: PLUTONIUM_PAGE_DEFAULT_SALT.to_owned(),
|
||||
rollout_country_codes: Vec::new(),
|
||||
rollout_salt: VOICE_P2P_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
excluded_user_ids: Vec::new(),
|
||||
max_participants: *VOICE_P2P_MAX_PARTICIPANTS_RANGE.start(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct PlutoniumPageConfigUpdateRequest {
|
||||
pub struct VoiceP2pConfigUpdateRequest {
|
||||
#[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_country_codes: Option<Vec<String>>,
|
||||
#[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>>,
|
||||
@@ -548,6 +638,8 @@ pub struct PlutoniumPageConfigUpdateRequest {
|
||||
pub include_premium_users: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_participants: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -696,7 +788,7 @@ 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>,
|
||||
pub voice_p2p: Option<VoiceP2pConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub captcha: Option<CaptchaConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -782,7 +874,7 @@ pub struct InstanceEmailSmtpIntegrationUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub password: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub secure: Option<bool>,
|
||||
pub tls_mode: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -837,15 +929,6 @@ pub struct InstanceAttachmentDecayUpdateRequest {
|
||||
pub renew_window_days: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct InstanceEmailSmtpTestRequest {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub secure: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
|
||||
pub struct InstanceEmailSmtpTestResponse {
|
||||
#[serde(default)]
|
||||
@@ -903,6 +986,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 +1085,22 @@ 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 voice_p2p = serde_json::from_value::<VoiceP2pConfigResponse>(json!({}))
|
||||
.expect("default voice p2p 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 voice_p2p = serde_json::to_value(voice_p2p).expect("serializable voice p2p 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_voice_p2p: generated_types::VoiceP2pConfigResponse =
|
||||
serde_json::from_value(voice_p2p.clone()).expect("generated voice p2p 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 =
|
||||
@@ -1028,9 +1111,9 @@ mod tests {
|
||||
domain_migration
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_plutonium_page)
|
||||
.expect("serializable generated plutonium page config"),
|
||||
plutonium_page
|
||||
serde_json::to_value(generated_voice_p2p)
|
||||
.expect("serializable generated voice p2p config"),
|
||||
voice_p2p
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_captcha).expect("serializable generated captcha config"),
|
||||
@@ -1043,7 +1126,7 @@ mod tests {
|
||||
);
|
||||
for (name, value) in [
|
||||
("DomainMigrationConfigResponse", domain_migration),
|
||||
("PlutoniumPageConfigResponse", plutonium_page),
|
||||
("VoiceP2pConfigResponse", voice_p2p),
|
||||
("CaptchaConfigResponse", captcha),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
] {
|
||||
@@ -1080,21 +1163,21 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plutonium_page_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = PlutoniumPageConfigUpdateRequest {
|
||||
fn voice_p2p_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = VoiceP2pConfigUpdateRequest {
|
||||
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())
|
||||
serde_json::from_value::<generated_types::VoiceP2pConfigUpdateRequest>(value.clone())
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(PlutoniumPageConfigUpdateRequest::default())
|
||||
serde_json::to_value(VoiceP2pConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -78,8 +78,3 @@ pub struct CreateVoiceServerResponse {
|
||||
pub struct UpdateVoiceServerResponse {
|
||||
pub server: VoiceServer,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct GetVoiceServerResponse {
|
||||
pub server: Option<VoiceServer>,
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::{bool_param, user_flags};
|
||||
|
||||
use reqwest::Method;
|
||||
|
||||
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 {
|
||||
@@ -40,16 +45,6 @@ impl AdminApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn lookup_user(&self, query: &str) -> ApiResult<Option<AdminUser>> {
|
||||
let response = self
|
||||
.generated()
|
||||
.list_admin_users(None, None, None, None, None, Some(query), None)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: LookupUserResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.users.into_iter().next())
|
||||
}
|
||||
|
||||
pub async fn lookup_users_by_ids(&self, user_ids: &[String]) -> ApiResult<Vec<AdminUser>> {
|
||||
if user_ids.is_empty() {
|
||||
return Ok(vec![]);
|
||||
@@ -376,9 +371,10 @@ impl AdminApiClient {
|
||||
reason: reason.map(std::borrow::ToOwned::to_owned),
|
||||
};
|
||||
let resp: UserMutationResponse = self
|
||||
.put_typed_with_reason(
|
||||
.send(
|
||||
Method::PUT,
|
||||
&format!("/admin/users/{}/ban", urlencoding::encode(user_id)),
|
||||
&body,
|
||||
Some(&body),
|
||||
private_reason,
|
||||
)
|
||||
.await?;
|
||||
@@ -448,7 +444,8 @@ impl AdminApiClient {
|
||||
"notify_user": notify_user,
|
||||
});
|
||||
let resp: UserMutationResponse = self
|
||||
.delete_with_reason(
|
||||
.send(
|
||||
Method::DELETE,
|
||||
&format!("/admin/users/{}/deletion", urlencoding::encode(user_id)),
|
||||
Some(&body),
|
||||
audit_log_reason,
|
||||
@@ -467,9 +464,11 @@ impl AdminApiClient {
|
||||
"ban_audit_log_id": ban_audit_log_id,
|
||||
"note": note,
|
||||
});
|
||||
self.post_void(
|
||||
self.send_void(
|
||||
Method::POST,
|
||||
&format!("/admin/users/{}/ban/notes", urlencoding::encode(user_id)),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -495,6 +494,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,
|
||||
@@ -578,20 +597,6 @@ fn nonempty(value: Option<&str>) -> Option<&str> {
|
||||
value.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn bool_param(value: bool) -> &'static str {
|
||||
if value { "true" } else { "false" }
|
||||
}
|
||||
|
||||
fn user_flags(values: &[String]) -> ApiResult<Vec<generated_types::UserFlags>> {
|
||||
values
|
||||
.iter()
|
||||
.map(|value| {
|
||||
generated_types::UserFlags::try_from(value.as_str())
|
||||
.map_err(|error| ApiError::Parse(error.to_string()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn premium_flags(values: &[i32]) -> Vec<generated_types::PremiumFlags> {
|
||||
values
|
||||
.iter()
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::bool_param;
|
||||
|
||||
use reqwest::Method;
|
||||
|
||||
use crate::api::generated::types as generated_types;
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
CreateVoiceRegionResponse, CreateVoiceServerResponse, DeleteVoiceResponse,
|
||||
GetVoiceRegionResponse, GetVoiceServerResponse, ListVoiceRegionsResponse,
|
||||
ListVoiceServersResponse, UpdateVoiceRegionResponse, UpdateVoiceServerResponse,
|
||||
GetVoiceRegionResponse, ListVoiceRegionsResponse, ListVoiceServersResponse,
|
||||
UpdateVoiceRegionResponse, UpdateVoiceServerResponse,
|
||||
};
|
||||
|
||||
impl AdminApiClient {
|
||||
@@ -57,7 +61,8 @@ impl AdminApiClient {
|
||||
let region_id = required_field(params, "id")?;
|
||||
let body = voice_request_body(params, &["id"])?;
|
||||
validate_against::<generated_types::UpdateVoiceRegionRequestBody>(&body)?;
|
||||
self.patch_with_reason(
|
||||
self.send(
|
||||
Method::PATCH,
|
||||
&format!("/admin/voice/regions/{}", urlencoding::encode(®ion_id)),
|
||||
Some(&body),
|
||||
None,
|
||||
@@ -83,19 +88,6 @@ impl AdminApiClient {
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn get_voice_server(
|
||||
&self,
|
||||
region_id: &str,
|
||||
server_id: &str,
|
||||
) -> ApiResult<GetVoiceServerResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.get_admin_voice_server(region_id, server_id)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn create_voice_server(
|
||||
&self,
|
||||
params: &serde_json::Value,
|
||||
@@ -123,7 +115,8 @@ impl AdminApiClient {
|
||||
paired_coordinates(params)?;
|
||||
let body = voice_request_body(params, &["region_id", "server_id"])?;
|
||||
validate_against::<generated_types::UpdateVoiceServerRequestBody>(&body)?;
|
||||
self.patch_with_reason(
|
||||
self.send(
|
||||
Method::PATCH,
|
||||
&format!(
|
||||
"/admin/voice/regions/{}/servers/{}",
|
||||
urlencoding::encode(®ion_id),
|
||||
@@ -149,10 +142,6 @@ impl AdminApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
fn bool_param(value: bool) -> &'static str {
|
||||
if value { "true" } else { "false" }
|
||||
}
|
||||
|
||||
fn validate_against<T: serde::de::DeserializeOwned>(params: &serde_json::Value) -> ApiResult<()> {
|
||||
serde_json::from_value::<T>(params.clone())
|
||||
.map(drop)
|
||||
|
||||
+306
-154
@@ -1,15 +1,15 @@
|
||||
// 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 {
|
||||
pub env: RuntimeEnv,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub secret_key_base: String,
|
||||
@@ -17,6 +17,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,
|
||||
@@ -33,13 +34,6 @@ pub struct ProxyConfig {
|
||||
pub client_ip_header_name: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum RuntimeEnv {
|
||||
Development,
|
||||
Production,
|
||||
Test,
|
||||
}
|
||||
|
||||
impl AdminConfig {
|
||||
pub fn from_env() -> anyhow::Result<Self> {
|
||||
let base_path = normalize_base_path(&read_env("FLUXER_ADMIN_BASE_PATH", ""));
|
||||
@@ -58,7 +52,6 @@ impl AdminConfig {
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
env: RuntimeEnv::from_env_value(&read_env("FLUXER_ENV", "development")),
|
||||
host: read_env("FLUXER_ADMIN_HOST", "0.0.0.0"),
|
||||
port: read_env("FLUXER_ADMIN_PORT", "3020")
|
||||
.parse()
|
||||
@@ -76,6 +69,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(
|
||||
@@ -102,14 +96,6 @@ impl AdminConfig {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_dev(&self) -> bool {
|
||||
self.env == RuntimeEnv::Development
|
||||
}
|
||||
|
||||
pub fn is_production(&self) -> bool {
|
||||
self.env == RuntimeEnv::Production
|
||||
}
|
||||
|
||||
pub fn secure_cookies(&self) -> bool {
|
||||
self.admin_endpoint.starts_with("https://")
|
||||
}
|
||||
@@ -120,14 +106,70 @@ impl AdminConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeEnv {
|
||||
pub(crate) fn from_env_value(value: &str) -> Self {
|
||||
match value {
|
||||
"production" => Self::Production,
|
||||
"test" => Self::Test,
|
||||
_ => Self::Development,
|
||||
}
|
||||
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('-')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -138,8 +180,7 @@ mod tests {
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
const MANAGED_ENV: [&str; 10] = [
|
||||
"FLUXER_ENV",
|
||||
const MANAGED_ENV: [&str; 14] = [
|
||||
"FLUXER_ADMIN_HOST",
|
||||
"FLUXER_ADMIN_PORT",
|
||||
"FLUXER_ADMIN_ENDPOINT",
|
||||
@@ -149,6 +190,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 {
|
||||
@@ -169,133 +215,6 @@ mod tests {
|
||||
config
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_base_path_strips_trailing_slashes() {
|
||||
assert_eq!(normalize_base_path("admin/"), "/admin");
|
||||
assert_eq!(normalize_base_path("admin///"), "/admin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_base_path_adds_leading_slash() {
|
||||
assert_eq!(normalize_base_path("admin"), "/admin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_base_path_empty_stays_empty() {
|
||||
assert_eq!(normalize_base_path(""), "");
|
||||
assert_eq!(normalize_base_path(" "), "");
|
||||
assert_eq!(normalize_base_path("/"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_base_path_preserves_inner() {
|
||||
assert_eq!(normalize_base_path("/foo/bar/"), "/foo/bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_trailing_slash_removes_trailing() {
|
||||
assert_eq!(
|
||||
trim_trailing_slash("https://example.com/"),
|
||||
"https://example.com"
|
||||
);
|
||||
assert_eq!(
|
||||
trim_trailing_slash("https://example.com"),
|
||||
"https://example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_trailing_slash_empty_string() {
|
||||
assert_eq!(trim_trailing_slash(""), "");
|
||||
assert_eq!(trim_trailing_slash("/"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_env_from_env_value() {
|
||||
assert_eq!(
|
||||
RuntimeEnv::from_env_value("production"),
|
||||
RuntimeEnv::Production
|
||||
);
|
||||
assert_eq!(RuntimeEnv::from_env_value("test"), RuntimeEnv::Test);
|
||||
assert_eq!(
|
||||
RuntimeEnv::from_env_value("development"),
|
||||
RuntimeEnv::Development
|
||||
);
|
||||
assert_eq!(
|
||||
RuntimeEnv::from_env_value("anything"),
|
||||
RuntimeEnv::Development
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_production_returns_true_for_production() {
|
||||
let config = AdminConfig {
|
||||
env: RuntimeEnv::Production,
|
||||
host: String::new(),
|
||||
port: 3020,
|
||||
secret_key_base: String::new(),
|
||||
base_path: String::new(),
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: String::new(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: String::new(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
};
|
||||
assert!(config.is_production());
|
||||
assert!(!config.is_dev());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_dev_returns_true_for_development() {
|
||||
let config = AdminConfig {
|
||||
env: RuntimeEnv::Development,
|
||||
host: String::new(),
|
||||
port: 3020,
|
||||
secret_key_base: String::new(),
|
||||
base_path: String::new(),
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: String::new(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
oauth_client_secret: String::new(),
|
||||
oauth_redirect_uri: String::new(),
|
||||
build_version: String::new(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: String::new(),
|
||||
},
|
||||
};
|
||||
assert!(config.is_dev());
|
||||
assert!(!config.is_production());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_uses_defaults() {
|
||||
let config = config_from_env(&[]);
|
||||
assert_eq!(config.env, RuntimeEnv::Development);
|
||||
assert_eq!(config.host, "0.0.0.0");
|
||||
assert_eq!(config.port, 3020);
|
||||
assert_eq!(config.oauth_client_id, DEFAULT_ADMIN_OAUTH_CLIENT_ID);
|
||||
assert_eq!(
|
||||
config.oauth_redirect_uri,
|
||||
"https://admin.fluxer.app/oauth2_callback"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_default_public_port_reaches_the_public_endpoints() {
|
||||
let config = config_from_env(&[
|
||||
@@ -368,4 +287,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");
|
||||
}
|
||||
}
|
||||
@@ -13,43 +13,6 @@ pub fn asset(file_name: &str) -> Option<(&'static str, &'static [u8])> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stylesheet_is_served_and_content_hashed() {
|
||||
let (content_type, bytes) =
|
||||
asset(STYLESHEET_FILE_NAME).expect("the generated stylesheet must be servable");
|
||||
assert_eq!(content_type, "text/css; charset=utf-8");
|
||||
let css = std::str::from_utf8(bytes).expect("stylesheet must be UTF-8");
|
||||
assert!(css.contains("font-family: 'Fluxer Sans'"));
|
||||
assert!(css.contains("font-family: 'Fluxer Mono'"));
|
||||
assert!(
|
||||
!css.contains("?v="),
|
||||
"content hashing replaces cache-bust tokens"
|
||||
);
|
||||
assert!(
|
||||
!css.contains("fluxerstatic"),
|
||||
"fonts must not be fetched from the static CDN"
|
||||
);
|
||||
assert!(
|
||||
STYLESHEET_FILE_NAME.starts_with("fonts.") && STYLESHEET_FILE_NAME.ends_with(".css"),
|
||||
"unexpected stylesheet name {STYLESHEET_FILE_NAME}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_face_the_stylesheet_references_is_served() {
|
||||
let (_, bytes) = asset(STYLESHEET_FILE_NAME).expect("stylesheet");
|
||||
let css = std::str::from_utf8(bytes).expect("stylesheet must be UTF-8");
|
||||
let mut referenced = 0;
|
||||
for fragment in css.split("url('").skip(1) {
|
||||
let file_name = fragment.split('\'').next().expect("unterminated url()");
|
||||
let (content_type, _) = asset(file_name)
|
||||
.unwrap_or_else(|| panic!("stylesheet references unserved font {file_name}"));
|
||||
assert_eq!(content_type, "font/woff2");
|
||||
referenced += 1;
|
||||
}
|
||||
assert_eq!(referenced, 16, "expected the 16 bundled Latin-core faces");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ofl_attribution_ships_with_the_binaries() {
|
||||
let notice = ASSETS
|
||||
@@ -63,10 +26,4 @@ mod tests {
|
||||
.any(|(name, _, _)| name.starts_with("LICENSE-IBM-PLEX."))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_files_are_not_served() {
|
||||
assert!(asset("fonts.css").is_none());
|
||||
assert!(asset("../../../etc/passwd").is_none());
|
||||
}
|
||||
}
|
||||
@@ -22,33 +22,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing::info!(%addr, "starting Fluxer admin service");
|
||||
|
||||
axum::serve(listener, router)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.with_graceful_shutdown(fluxer_common::shutdown::wait_for_shutdown())
|
||||
.await
|
||||
.context("admin server exited unexpectedly")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install SIGTERM handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
}
|
||||
@@ -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 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::AdminUser,
|
||||
middleware::flash::{self, FlashData},
|
||||
api::types::{AdminUser, PremiumBranding},
|
||||
middleware::flash,
|
||||
session::{self, Session},
|
||||
state::AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
@@ -17,6 +16,7 @@ use axum::{
|
||||
pub struct AuthContext {
|
||||
pub session: Session,
|
||||
pub admin_user: Option<AdminUser>,
|
||||
pub branding: Option<PremiumBranding>,
|
||||
}
|
||||
|
||||
pub async fn require_auth(
|
||||
@@ -29,27 +29,19 @@ pub async fn require_auth(
|
||||
let Some(session) = session else {
|
||||
return Redirect::to(&format!("{}/auth/start", config.base_path)).into_response();
|
||||
};
|
||||
let admin_result = fetch_admin_user(state.http_client(), config, &session).await;
|
||||
match admin_result {
|
||||
let admin_user = match fetch_admin_user(state.http_client(), config, &session).await {
|
||||
AdminFetchResult::Unauthorized => {
|
||||
let login_url = format!("{}/login", config.base_path);
|
||||
return clear_session_and_redirect(&login_url, config);
|
||||
}
|
||||
AdminFetchResult::Ok(admin_user) => {
|
||||
let auth_context = AuthContext {
|
||||
session,
|
||||
admin_user: Some(*admin_user),
|
||||
};
|
||||
request.extensions_mut().insert(auth_context);
|
||||
}
|
||||
AdminFetchResult::None => {
|
||||
let auth_context = AuthContext {
|
||||
session,
|
||||
admin_user: None,
|
||||
};
|
||||
request.extensions_mut().insert(auth_context);
|
||||
}
|
||||
}
|
||||
AdminFetchResult::Ok(admin_user) => Some(*admin_user),
|
||||
AdminFetchResult::None => None,
|
||||
};
|
||||
request.extensions_mut().insert(AuthContext {
|
||||
session,
|
||||
admin_user,
|
||||
branding: state.self_hosted_branding().await,
|
||||
});
|
||||
|
||||
let had_flash = flash::extract_flash(&request);
|
||||
if let Some(ref fd) = had_flash {
|
||||
@@ -65,46 +57,12 @@ 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() {
|
||||
"/"
|
||||
} else {
|
||||
config.base_path.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_session_and_redirect(url: &str, config: &crate::config::AdminConfig) -> Response {
|
||||
let mut response = Redirect::to(url).into_response();
|
||||
let admin_path = admin_cookie_path(config);
|
||||
for path in [admin_path, "/"] {
|
||||
if path != admin_path && admin_path == "/" {
|
||||
continue;
|
||||
}
|
||||
let clear = format!(
|
||||
"{}=; Path={}; HttpOnly; SameSite=Lax; Max-Age=0",
|
||||
crate::session::SESSION_COOKIE_NAME,
|
||||
path
|
||||
);
|
||||
if let Ok(v) = axum::http::HeaderValue::from_str(&clear) {
|
||||
response
|
||||
.headers_mut()
|
||||
.append(axum::http::header::SET_COOKIE, v);
|
||||
}
|
||||
let legacy_clear = format!(
|
||||
"{}=; Path={}; HttpOnly; SameSite=Lax; Max-Age=0",
|
||||
crate::session::LEGACY_SESSION_COOKIE_NAME,
|
||||
path
|
||||
);
|
||||
if let Ok(v) = axum::http::HeaderValue::from_str(&legacy_clear) {
|
||||
response
|
||||
.headers_mut()
|
||||
.append(axum::http::header::SET_COOKIE, v);
|
||||
}
|
||||
}
|
||||
session::append_cookies(
|
||||
&mut response,
|
||||
&session::clear_cookie_values(session::SESSION_COOKIE_NAME, config),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
@@ -159,11 +117,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()))
|
||||
}
|
||||
@@ -198,14 +198,13 @@ pub fn get_csrf_token(request: &Request) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::{AdminConfig, ProxyConfig, RuntimeEnv};
|
||||
use crate::config::{AdminConfig, ProxyConfig};
|
||||
use crate::state::AppState;
|
||||
use axum::{Router, middleware::from_fn_with_state, routing::get};
|
||||
use tower::ServiceExt;
|
||||
|
||||
fn state_with_admin_endpoint(admin_endpoint: &str) -> AppState {
|
||||
AppState::new(AdminConfig {
|
||||
env: RuntimeEnv::Production,
|
||||
host: String::new(),
|
||||
port: 3020,
|
||||
secret_key_base: "test-secret".to_owned(),
|
||||
@@ -213,6 +212,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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,77 +93,18 @@ 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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn serialize_then_parse_round_trips() {
|
||||
let flash = FlashData::success("Item saved");
|
||||
let encoded = serialize_flash(&flash);
|
||||
let decoded = parse_flash(&encoded).expect("must parse");
|
||||
assert_eq!(decoded.message, "Item saved");
|
||||
assert_eq!(decoded.flash_type, "success");
|
||||
assert!(decoded.detail.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_flash_returns_none_for_invalid_base64() {
|
||||
assert!(parse_flash("!!!not-base64!!!").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_flash_returns_none_for_invalid_json() {
|
||||
let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"not json");
|
||||
assert!(parse_flash(&encoded).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flash_success_sets_correct_type() {
|
||||
let f = FlashData::success("ok");
|
||||
assert_eq!(f.flash_type, "success");
|
||||
assert_eq!(f.message, "ok");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flash_error_sets_correct_type() {
|
||||
let f = FlashData::error("fail");
|
||||
assert_eq!(f.flash_type, "error");
|
||||
assert_eq!(f.message, "fail");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flash_info_sets_correct_type() {
|
||||
let f = FlashData::info("note");
|
||||
assert_eq!(f.flash_type, "info");
|
||||
assert_eq!(f.message, "note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn to_flash_message_maps_levels() {
|
||||
assert!(matches!(
|
||||
FlashData::success("").to_flash_message().level,
|
||||
FlashLevel::Success
|
||||
));
|
||||
assert!(matches!(
|
||||
FlashData::error("").to_flash_message().level,
|
||||
FlashLevel::Error
|
||||
));
|
||||
assert!(matches!(
|
||||
FlashData::info("").to_flash_message().level,
|
||||
FlashLevel::Info
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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,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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -4,14 +4,17 @@ use crate::{
|
||||
api::client::AdminApiClient,
|
||||
config::AdminConfig,
|
||||
oauth2,
|
||||
session::{self, LEGACY_SESSION_COOKIE_NAME, SESSION_COOKIE_NAME, SESSION_MAX_AGE},
|
||||
session::{
|
||||
self, SESSION_COOKIE_NAME, SESSION_MAX_AGE, admin_cookie_path, append_cookies,
|
||||
clear_cookie_values,
|
||||
},
|
||||
state::AppState,
|
||||
templates::pages::login,
|
||||
};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Query, State},
|
||||
http::{HeaderMap, HeaderValue, StatusCode, header},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{Html, IntoResponse, Redirect, Response},
|
||||
routing::{get, post},
|
||||
};
|
||||
@@ -87,7 +90,8 @@ async fn login_page(
|
||||
Some(_) => Some("Login error. Please try again."),
|
||||
None => None,
|
||||
};
|
||||
Html(login::login_page(config, error_msg).into_string()).into_response()
|
||||
let branding = state.self_hosted_branding().await;
|
||||
Html(login::login_page(config, branding.as_ref(), error_msg).into_string()).into_response()
|
||||
}
|
||||
|
||||
async fn auth_start(State(state): State<AppState>) -> Response {
|
||||
@@ -201,7 +205,7 @@ async fn oauth2_callback_finish(
|
||||
secure
|
||||
);
|
||||
|
||||
let mut cookies = clear_session_cookie_values(config);
|
||||
let mut cookies = clear_cookie_values(SESSION_COOKIE_NAME, config);
|
||||
cookies.push(session_cookie);
|
||||
oauth_finish_response(StatusCode::OK, &format!("{base}/dashboard"), &cookies)
|
||||
}
|
||||
@@ -215,7 +219,7 @@ async fn logout(State(state): State<AppState>, headers: axum::http::HeaderMap) -
|
||||
}
|
||||
redirect_with_cookies(
|
||||
&format!("{}/login", config.base_path),
|
||||
&clear_session_cookie_values(config),
|
||||
&clear_cookie_values(SESSION_COOKIE_NAME, config),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -358,45 +362,6 @@ pub(crate) fn json_string(value: &str) -> String {
|
||||
.replace('\u{2029}', "\\u2029")
|
||||
}
|
||||
|
||||
fn admin_cookie_path(config: &AdminConfig) -> &str {
|
||||
if config.base_path.is_empty() {
|
||||
"/"
|
||||
} else {
|
||||
config.base_path.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_cookie_value(name: &str, path: &str) -> String {
|
||||
format!("{name}=; Path={path}; HttpOnly; SameSite=Lax; Max-Age=0")
|
||||
}
|
||||
|
||||
fn clear_cookie_values(name: &str, config: &AdminConfig) -> Vec<String> {
|
||||
let admin_path = admin_cookie_path(config);
|
||||
let mut cookies = vec![clear_cookie_value(name, admin_path)];
|
||||
if admin_path != "/" {
|
||||
cookies.push(clear_cookie_value(name, "/"));
|
||||
}
|
||||
cookies
|
||||
}
|
||||
|
||||
fn clear_session_cookie_values(config: &AdminConfig) -> Vec<String> {
|
||||
let mut cookies = clear_cookie_values(SESSION_COOKIE_NAME, config);
|
||||
cookies.extend(clear_cookie_values(LEGACY_SESSION_COOKIE_NAME, config));
|
||||
cookies
|
||||
}
|
||||
|
||||
fn append_cookie(response: &mut Response, cookie: &str) {
|
||||
if let Ok(value) = HeaderValue::from_str(cookie) {
|
||||
response.headers_mut().append(header::SET_COOKIE, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn append_cookies(response: &mut Response, cookies: &[String]) {
|
||||
for cookie in cookies {
|
||||
append_cookie(response, cookie);
|
||||
}
|
||||
}
|
||||
|
||||
fn redirect_with_cookies(url: &str, cookies: &[String]) -> Response {
|
||||
let mut response = Redirect::to(url).into_response();
|
||||
append_cookies(&mut response, cookies);
|
||||
|
||||
+107
-44
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -143,7 +143,7 @@ pub(super) fn billing_result<T>(result: Result<T, ApiError>) -> FlashData {
|
||||
}
|
||||
}
|
||||
|
||||
fn validation_message(error: &ApiError) -> Option<String> {
|
||||
pub(super) fn validation_message(error: &ApiError) -> Option<String> {
|
||||
let ApiError::Http {
|
||||
status: 400,
|
||||
message,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::client::{AdminApiClient, ApiResultExt},
|
||||
api::client::{AdminApiClient, ApiResult, ApiResultExt},
|
||||
config::AdminConfig,
|
||||
middleware::{
|
||||
auth::AuthContext,
|
||||
csrf::CsrfToken,
|
||||
@@ -97,41 +98,23 @@ async fn discovery_approve(
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let base = &config.base_path;
|
||||
let form: DiscoveryActionForm = match Form::from_request(request, &state).await {
|
||||
Ok(Form(f)) => f,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "failed to parse discovery approve form");
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let guild_id = match clean_string(form.guild_id.as_deref().unwrap_or("")) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Guild ID is required"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let (form, guild_id) =
|
||||
match discovery_form(&state, request, "failed to parse discovery approve form").await {
|
||||
Ok(value) => value,
|
||||
Err(response) => return *response,
|
||||
};
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let reason = clean_string(form.reason.as_deref().unwrap_or(""));
|
||||
let result = client
|
||||
.approve_discovery_application(&guild_id, reason.as_deref())
|
||||
.await;
|
||||
let flash = match result.log_error("approve discovery application") {
|
||||
Some(_) => FlashData::success("Discovery application approved"),
|
||||
None => FlashData::error("Failed to approve discovery application"),
|
||||
};
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/discovery?tab=pending"),
|
||||
flash,
|
||||
config.secure_cookies(),
|
||||
finish_discovery(
|
||||
config,
|
||||
result,
|
||||
"approve discovery application",
|
||||
"Discovery application approved",
|
||||
"Failed to approve discovery application",
|
||||
"pending",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -141,39 +124,21 @@ async fn discovery_reject(
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let base = &config.base_path;
|
||||
let form: DiscoveryActionForm = match Form::from_request(request, &state).await {
|
||||
Ok(Form(f)) => f,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "failed to parse discovery reject form");
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let guild_id = match clean_string(form.guild_id.as_deref().unwrap_or("")) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Guild ID is required"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let (form, guild_id) =
|
||||
match discovery_form(&state, request, "failed to parse discovery reject form").await {
|
||||
Ok(value) => value,
|
||||
Err(response) => return *response,
|
||||
};
|
||||
let reason = form.reason.as_deref().unwrap_or("").trim();
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let result = client.reject_discovery_application(&guild_id, reason).await;
|
||||
let flash = match result.log_error("reject discovery application") {
|
||||
Some(_) => FlashData::success("Discovery application rejected"),
|
||||
None => FlashData::error("Failed to reject discovery application"),
|
||||
};
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/discovery?tab=pending"),
|
||||
flash,
|
||||
config.secure_cookies(),
|
||||
finish_discovery(
|
||||
config,
|
||||
result,
|
||||
"reject discovery application",
|
||||
"Discovery application rejected",
|
||||
"Failed to reject discovery application",
|
||||
"pending",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -183,38 +148,68 @@ async fn discovery_remove(
|
||||
request: Request,
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let base = &config.base_path;
|
||||
let form: DiscoveryActionForm = match Form::from_request(request, &state).await {
|
||||
Ok(Form(f)) => f,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "failed to parse discovery remove form");
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Invalid form data"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let guild_id = match clean_string(form.guild_id.as_deref().unwrap_or("")) {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
return flash::redirect_with_flash(
|
||||
&format!("{base}/discovery"),
|
||||
FlashData::error("Guild ID is required"),
|
||||
config.secure_cookies(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let (form, guild_id) =
|
||||
match discovery_form(&state, request, "failed to parse discovery remove form").await {
|
||||
Ok(value) => value,
|
||||
Err(response) => return *response,
|
||||
};
|
||||
let reason = form.reason.as_deref().unwrap_or("").trim();
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let result = client.remove_from_discovery(&guild_id, reason).await;
|
||||
let flash = match result.log_error("remove discovery guild") {
|
||||
Some(_) => FlashData::success("Guild removed from discovery"),
|
||||
None => FlashData::error("Failed to remove guild from discovery"),
|
||||
finish_discovery(
|
||||
config,
|
||||
result,
|
||||
"remove discovery guild",
|
||||
"Guild removed from discovery",
|
||||
"Failed to remove guild from discovery",
|
||||
"listed",
|
||||
)
|
||||
}
|
||||
|
||||
async fn discovery_form(
|
||||
state: &AppState,
|
||||
request: Request,
|
||||
log_label: &str,
|
||||
) -> Result<(DiscoveryActionForm, String), Box<Response>> {
|
||||
let config = state.config();
|
||||
let target = format!("{}/discovery", config.base_path);
|
||||
let form = match Form::<DiscoveryActionForm>::from_request(request, state).await {
|
||||
Ok(Form(form)) => form,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "{log_label}");
|
||||
return Err(Box::new(flash::redirect_with_flash(
|
||||
&target,
|
||||
FlashData::error("Invalid form data"),
|
||||
config.secure_cookies(),
|
||||
)));
|
||||
}
|
||||
};
|
||||
let Some(guild_id) = clean_string(form.guild_id.as_deref().unwrap_or("")) else {
|
||||
return Err(Box::new(flash::redirect_with_flash(
|
||||
&target,
|
||||
FlashData::error("Guild ID is required"),
|
||||
config.secure_cookies(),
|
||||
)));
|
||||
};
|
||||
Ok((form, guild_id))
|
||||
}
|
||||
|
||||
fn finish_discovery<T>(
|
||||
config: &AdminConfig,
|
||||
result: ApiResult<T>,
|
||||
log_label: &str,
|
||||
ok_msg: &str,
|
||||
err_msg: &str,
|
||||
tab: &str,
|
||||
) -> Response {
|
||||
let data = if result.log_error(log_label).is_some() {
|
||||
FlashData::success(ok_msg)
|
||||
} else {
|
||||
FlashData::error(err_msg)
|
||||
};
|
||||
flash::redirect_with_flash(
|
||||
&format!("{base}/discovery?tab=listed"),
|
||||
flash,
|
||||
&format!("{}/discovery?tab={tab}", config.base_path),
|
||||
data,
|
||||
config.secure_cookies(),
|
||||
)
|
||||
}
|
||||
@@ -78,8 +78,12 @@ pub async fn render(
|
||||
.map(|response| response.emojis)
|
||||
.map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild emojis"))
|
||||
.unwrap_or_default();
|
||||
Some(tabs::emojis::emojis_tab(
|
||||
config, &guild, &emojis, csrf_token,
|
||||
Some(tabs::emojis::guild_assets_tab(
|
||||
config,
|
||||
&guild,
|
||||
&emojis,
|
||||
csrf_token,
|
||||
tabs::emojis::GuildAssetKind::Emoji,
|
||||
))
|
||||
}
|
||||
"stickers" => {
|
||||
@@ -92,8 +96,30 @@ pub async fn render(
|
||||
.map(|response| response.stickers)
|
||||
.map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild stickers"))
|
||||
.unwrap_or_default();
|
||||
Some(tabs::stickers::stickers_tab(
|
||||
config, &guild, &stickers, csrf_token,
|
||||
Some(tabs::emojis::guild_assets_tab(
|
||||
config,
|
||||
&guild,
|
||||
&stickers,
|
||||
csrf_token,
|
||||
tabs::emojis::GuildAssetKind::Sticker,
|
||||
))
|
||||
}
|
||||
"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" => {
|
||||
@@ -163,7 +189,14 @@ pub async fn render(
|
||||
.await
|
||||
.map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild applications"))
|
||||
.unwrap_or_default();
|
||||
Some(tabs::applications::applications_tab(config, &guild, &apps))
|
||||
Some(
|
||||
crate::templates::pages::user_detail_tabs::applications::applications_tab(
|
||||
config,
|
||||
&apps,
|
||||
"No applications installed",
|
||||
"This guild does not have any installed applications.",
|
||||
),
|
||||
)
|
||||
}
|
||||
"archives" => {
|
||||
if !acl::has_any_permission(
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -32,7 +32,7 @@ pub struct ActionQuery {
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Request, State},
|
||||
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
|
||||
http::{HeaderValue, StatusCode, header},
|
||||
middleware::{Next, from_fn, from_fn_with_state},
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
@@ -50,9 +50,6 @@ const HTMX_JS: &str = include_str!("../../static/htmx.min.js");
|
||||
|
||||
const IMMUTABLE_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
|
||||
|
||||
const STRICT_TRANSPORT_SECURITY_VALUE: &str = "max-age=31536000; includeSubDomains; preload";
|
||||
const REFERRER_POLICY_VALUE: &str = "strict-origin-when-cross-origin";
|
||||
const X_FRAME_OPTIONS_VALUE: &str = "DENY";
|
||||
const PERMISSIONS_POLICY_VALUE: &str = "accelerometer=(), camera=(), geolocation=(), \
|
||||
gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()";
|
||||
|
||||
@@ -73,6 +70,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(),
|
||||
@@ -115,19 +116,7 @@ async fn security_headers_middleware(
|
||||
let headers = response.headers_mut();
|
||||
let config = state.config();
|
||||
|
||||
set_static_header(
|
||||
headers,
|
||||
header::STRICT_TRANSPORT_SECURITY,
|
||||
STRICT_TRANSPORT_SECURITY_VALUE,
|
||||
);
|
||||
set_static_header(headers, header::X_CONTENT_TYPE_OPTIONS, "nosniff");
|
||||
set_static_header(headers, header::REFERRER_POLICY, REFERRER_POLICY_VALUE);
|
||||
set_static_header(headers, header::X_FRAME_OPTIONS, X_FRAME_OPTIONS_VALUE);
|
||||
set_static_header(
|
||||
headers,
|
||||
HeaderName::from_static("permissions-policy"),
|
||||
PERMISSIONS_POLICY_VALUE,
|
||||
);
|
||||
fluxer_common::server::set_baseline_security_headers(headers, PERMISSIONS_POLICY_VALUE);
|
||||
|
||||
let csp = build_admin_csp(config);
|
||||
if let Ok(value) = HeaderValue::from_str(&csp) {
|
||||
@@ -146,7 +135,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(),
|
||||
@@ -165,12 +157,6 @@ fn csp_origin(endpoint: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn set_static_header(headers: &mut HeaderMap, name: HeaderName, value: &'static str) {
|
||||
headers
|
||||
.entry(name)
|
||||
.or_insert(HeaderValue::from_static(value));
|
||||
}
|
||||
|
||||
async fn cache_headers_middleware(request: Request, next: Next) -> Response {
|
||||
let mut response = next.run(request).await;
|
||||
if response.headers().contains_key(header::CACHE_CONTROL) {
|
||||
|
||||
@@ -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(¶ms),
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -8,18 +8,19 @@ use crate::{
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
|
||||
CaptchaConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigUpdateRequest, GatewayRolloutConfigUpdateRequest,
|
||||
GatewayRolloutMode, InstanceAttachmentDecayUpdateRequest,
|
||||
InstanceBlueskyIntegrationUpdateRequest, InstanceBlueskyKeyIntegrationUpdateRequest,
|
||||
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpIntegrationUpdateRequest, InstanceEmailSmtpTestRequest,
|
||||
InstanceGifIntegrationUpdateRequest, InstanceIntegrationsUpdateRequest,
|
||||
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
||||
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
||||
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
|
||||
LimitRuleFilters, PlutoniumPageConfigUpdateRequest, PremiumMode,
|
||||
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope,
|
||||
DomainMigrationConfigUpdateRequest, EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES,
|
||||
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigUpdateRequest,
|
||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceConfigUpdateRequest,
|
||||
InstanceEmailIntegrationUpdateRequest, InstanceEmailSmtpIntegrationUpdateRequest,
|
||||
InstanceEmailSmtpTestResponse, InstanceGifIntegrationUpdateRequest,
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitKeyMetadata, LimitRule, LimitRuleFilters, PremiumMode,
|
||||
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
|
||||
VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceE2eeScope, VoiceP2pConfigUpdateRequest,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -220,7 +221,7 @@ 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) {
|
||||
"update_voice_p2p" => match build_voice_p2p_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
@@ -232,21 +233,19 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"test_smtp" => match build_smtp_test_request(&form) {
|
||||
Ok(request) => match client.test_instance_smtp_config(&request).await {
|
||||
Ok(response) if response.ok => FlashData::success("SMTP connection verified"),
|
||||
Ok(response) => FlashData::error(
|
||||
response
|
||||
.error
|
||||
.unwrap_or_else(|| "SMTP validation failed".to_owned()),
|
||||
),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: test SMTP config");
|
||||
FlashData::error("Failed to validate SMTP configuration")
|
||||
}
|
||||
},
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"test_smtp" => {
|
||||
let update = build_integrations_update(&form);
|
||||
match client.update_instance_config(&update).await {
|
||||
Ok(_) => match client.test_saved_instance_smtp_config().await {
|
||||
Ok(response) => smtp_test_flash(response),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: test SMTP config");
|
||||
FlashData::error("Settings saved, but the SMTP test could not run")
|
||||
}
|
||||
},
|
||||
Err(error) => instance_config_result(Err::<(), _>(error)),
|
||||
}
|
||||
}
|
||||
"disable_single_community" => {
|
||||
let update = build_single_community_update(false);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
@@ -404,16 +403,33 @@ fn render_fragment_with_toast(markup: Markup, flash: &FlashData) -> Response {
|
||||
response
|
||||
}
|
||||
|
||||
fn instance_config_result<T, E: std::fmt::Display>(result: Result<T, E>) -> FlashData {
|
||||
fn instance_config_result<T>(result: Result<T, crate::api::client::ApiError>) -> FlashData {
|
||||
match result {
|
||||
Ok(_) => FlashData::success("Instance config updated"),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: update instance config");
|
||||
FlashData::error("Failed to update instance config")
|
||||
match super::billing_actions::validation_message(&error) {
|
||||
Some(message) => {
|
||||
FlashData::error(format!("Failed to update instance config: {message}"))
|
||||
}
|
||||
None => FlashData::error("Failed to update instance config"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn smtp_test_flash(response: InstanceEmailSmtpTestResponse) -> FlashData {
|
||||
if response.ok {
|
||||
return FlashData::success("Settings saved and SMTP connection verified");
|
||||
}
|
||||
let reason = response
|
||||
.error
|
||||
.unwrap_or_else(|| "the server gave no reason".to_owned());
|
||||
FlashData::error(format!(
|
||||
"Settings saved, but the SMTP test failed: {reason}"
|
||||
))
|
||||
}
|
||||
|
||||
fn build_sso_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
let flag = |key: &str| form.bool_value(key);
|
||||
let get = |key: &str| Some(form.clean(key));
|
||||
@@ -561,6 +577,32 @@ fn parse_experiment_user_ids(value: &str, label: &str) -> Result<Vec<String>, St
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn parse_experiment_country_codes(value: &str) -> Result<Vec<String>, String> {
|
||||
let mut codes: Vec<String> = Vec::new();
|
||||
let candidates = value
|
||||
.split(|character: char| character == ',' || character.is_whitespace())
|
||||
.filter(|candidate| !candidate.is_empty());
|
||||
for (index, candidate) in candidates.enumerate() {
|
||||
if candidate.len() != 2 || !candidate.bytes().all(|byte| byte.is_ascii_alphabetic()) {
|
||||
return Err(format!(
|
||||
"Rollout countries entry {} must be a two-letter country code",
|
||||
index + 1
|
||||
));
|
||||
}
|
||||
let code = candidate.to_ascii_uppercase();
|
||||
if codes.contains(&code) {
|
||||
continue;
|
||||
}
|
||||
if codes.len() == EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES {
|
||||
return Err(format!(
|
||||
"Rollout countries must contain at most {EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES} unique codes"
|
||||
));
|
||||
}
|
||||
codes.push(code);
|
||||
}
|
||||
Ok(codes)
|
||||
}
|
||||
|
||||
fn build_push_relay_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
InstanceConfigUpdateRequest {
|
||||
push_relay: Some(PushRelayConfigUpdateRequest {
|
||||
@@ -583,6 +625,10 @@ fn build_domain_migration_update(
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_country_codes: Some(parse_experiment_country_codes(
|
||||
form.first("domain_migration_rollout_country_codes")
|
||||
.unwrap_or_default(),
|
||||
)?),
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "domain_migration_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("domain_migration_included_user_ids")
|
||||
@@ -613,36 +659,45 @@ fn build_domain_migration_update(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_plutonium_page_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
fn build_voice_p2p_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
plutonium_page: Some(PlutoniumPageConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("plutonium_page_enabled")),
|
||||
voice_p2p: Some(VoiceP2pConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("voice_p2p_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"plutonium_page_rollout_basis_points",
|
||||
"voice_p2p_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "plutonium_page_rollout_salt")?,
|
||||
rollout_country_codes: Some(parse_experiment_country_codes(
|
||||
form.first("voice_p2p_rollout_country_codes")
|
||||
.unwrap_or_default(),
|
||||
)?),
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "voice_p2p_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("plutonium_page_included_user_ids")
|
||||
form.first("voice_p2p_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("plutonium_page_included_guild_ids")
|
||||
form.first("voice_p2p_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("plutonium_page_include_premium_users")),
|
||||
include_premium_users: Some(form.bool_value("voice_p2p_include_premium_users")),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("plutonium_page_excluded_user_ids")
|
||||
form.first("voice_p2p_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
max_participants: parse_form_number(
|
||||
form,
|
||||
"voice_p2p_max_participants",
|
||||
"Maximum participants",
|
||||
*VOICE_P2P_MAX_PARTICIPANTS_RANGE.start(),
|
||||
*VOICE_P2P_MAX_PARTICIPANTS_RANGE.end(),
|
||||
)?,
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
@@ -748,6 +803,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 +894,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"),
|
||||
@@ -848,7 +906,9 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
|
||||
port: smtp_port,
|
||||
username: clean("integration_smtp_username"),
|
||||
password: clean("integration_smtp_password"),
|
||||
secure: Some(form.bool_value("integration_smtp_secure")),
|
||||
tls_mode: form
|
||||
.first("integration_smtp_tls_mode")
|
||||
.map(|value| Some(value.trim().to_owned()).filter(|mode| !mode.is_empty())),
|
||||
}),
|
||||
disable_new_ip_authorization: Some(
|
||||
form.bool_value("integration_email_disable_new_ip_authorization"),
|
||||
@@ -891,29 +951,6 @@ fn build_media_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_smtp_test_request(form: &MultiValueForm) -> Result<InstanceEmailSmtpTestRequest, String> {
|
||||
let host = form
|
||||
.clean("integration_smtp_host")
|
||||
.ok_or_else(|| "SMTP host is required".to_owned())?;
|
||||
let port = form
|
||||
.first("integration_smtp_port")
|
||||
.and_then(|value| value.trim().parse::<u16>().ok())
|
||||
.ok_or_else(|| "SMTP port must be between 1 and 65535".to_owned())?;
|
||||
let username = form
|
||||
.clean("integration_smtp_username")
|
||||
.ok_or_else(|| "SMTP username is required".to_owned())?;
|
||||
let password = form
|
||||
.clean("integration_smtp_password")
|
||||
.ok_or_else(|| "SMTP password is required for validation".to_owned())?;
|
||||
Ok(InstanceEmailSmtpTestRequest {
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
secure: form.bool_value("integration_smtp_secure"),
|
||||
})
|
||||
}
|
||||
|
||||
fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
|
||||
InstanceConfigUpdateRequest {
|
||||
policy: Some(InstancePolicyUpdateRequest {
|
||||
@@ -999,6 +1036,7 @@ fn update_limit_rule_values(
|
||||
rule: &mut LimitRule,
|
||||
form: &MultiValueForm,
|
||||
limit_keys: &[String],
|
||||
metadata: &std::collections::BTreeMap<String, LimitKeyMetadata>,
|
||||
fallback_limits: Option<&std::collections::BTreeMap<String, u64>>,
|
||||
) {
|
||||
let mut limits = std::collections::BTreeMap::new();
|
||||
@@ -1012,12 +1050,19 @@ fn update_limit_rule_values(
|
||||
{
|
||||
limits.extend(defaults.clone());
|
||||
}
|
||||
for key in limit_keys {
|
||||
if metadata.get(key).is_some_and(|metadata| metadata.is_toggle)
|
||||
&& rule.limits.contains_key(key)
|
||||
{
|
||||
limits.entry(key.clone()).or_insert(0);
|
||||
}
|
||||
}
|
||||
rule.limits = limits;
|
||||
rule.filters = build_limit_filters(form);
|
||||
}
|
||||
|
||||
fn limit_config_result<T, E: std::fmt::Display>(
|
||||
result: Result<T, E>,
|
||||
fn limit_config_result<T>(
|
||||
result: Result<T, crate::api::client::ApiError>,
|
||||
success_message: &'static str,
|
||||
error_message: &'static str,
|
||||
) -> FlashData {
|
||||
@@ -1025,7 +1070,10 @@ fn limit_config_result<T, E: std::fmt::Display>(
|
||||
Ok(_) => FlashData::success(success_message),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: update limit config");
|
||||
FlashData::error(error_message)
|
||||
match super::billing_actions::validation_message(&error) {
|
||||
Some(message) => FlashData::error(format!("{error_message}: {message}")),
|
||||
None => FlashData::error(error_message),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1094,7 +1142,13 @@ pub async fn limit_config_post(
|
||||
.defaults
|
||||
.get(&rule_id)
|
||||
.or_else(|| current.defaults.get("default"));
|
||||
update_limit_rule_values(rule, &form, ¤t.limit_keys, fallback);
|
||||
update_limit_rule_values(
|
||||
rule,
|
||||
&form,
|
||||
¤t.limit_keys,
|
||||
¤t.metadata,
|
||||
fallback,
|
||||
);
|
||||
let request = LimitConfigUpdateRequest { limit_config };
|
||||
let result = client.update_limit_config(&request).await;
|
||||
let flash = limit_config_result(
|
||||
@@ -1104,6 +1158,43 @@ pub async fn limit_config_post(
|
||||
);
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
||||
}
|
||||
"reset" => {
|
||||
let rule = aq
|
||||
.rule
|
||||
.as_deref()
|
||||
.and_then(clean_string)
|
||||
.and_then(|rule_id| {
|
||||
limit_config
|
||||
.rules
|
||||
.iter_mut()
|
||||
.find(|rule| rule.id == rule_id)
|
||||
});
|
||||
let Some(rule) = rule else {
|
||||
return redirect_back_with_flash(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule not found"),
|
||||
secure_cookies,
|
||||
);
|
||||
};
|
||||
let Some(defaults) = current.defaults.get(&rule.id) else {
|
||||
return redirect_back_with_flash(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("This rule has no defaults to reset to"),
|
||||
secure_cookies,
|
||||
);
|
||||
};
|
||||
rule.limits = defaults.clone();
|
||||
let request = LimitConfigUpdateRequest { limit_config };
|
||||
let result = client.update_limit_config(&request).await;
|
||||
let flash = limit_config_result(
|
||||
result,
|
||||
"Limit rule reset to defaults",
|
||||
"Failed to reset limit rule",
|
||||
);
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
||||
}
|
||||
"delete" => {
|
||||
let rule_id = match aq.rule.as_deref().and_then(clean_string) {
|
||||
Some(rule_id) => rule_id,
|
||||
@@ -1195,6 +1286,100 @@ 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_integrations_update_sends_the_smtp_tls_mode_only_when_the_form_has_it() {
|
||||
let smtp_update = |body: &[u8]| {
|
||||
build_integrations_update(&MultiValueForm::parse(body))
|
||||
.integrations
|
||||
.and_then(|integrations| integrations.email)
|
||||
.and_then(|email| email.smtp)
|
||||
.expect("smtp update")
|
||||
};
|
||||
let body = |update| serde_json::to_value(update).expect("serialize smtp update");
|
||||
|
||||
let chosen = smtp_update(b"integration_email_present=1&integration_smtp_tls_mode=none");
|
||||
assert_eq!(body(chosen), serde_json::json!({"tls_mode": "none"}));
|
||||
|
||||
let automatic = smtp_update(b"integration_email_present=1&integration_smtp_tls_mode=");
|
||||
assert_eq!(body(automatic), serde_json::json!({"tls_mode": null}));
|
||||
|
||||
let from_an_older_page =
|
||||
smtp_update(b"integration_email_present=1&integration_smtp_secure=true");
|
||||
assert_eq!(body(from_an_older_page), serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_sso_update_keeps_repeated_allowed_domains() {
|
||||
let form = MultiValueForm::parse(
|
||||
@@ -1230,84 +1415,69 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_limit_filters_accepts_repeated_and_delimited_values() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"traits%5B%5D=staff&traits%5B%5D=partner%2Cvip&guild_features=COMMUNITY%0ANEWS",
|
||||
);
|
||||
let filters = build_limit_filters(&form).expect("filters");
|
||||
fn update_limit_rule_values_saves_unchecked_toggles_as_off() {
|
||||
let toggle = |key: &str, is_toggle: bool| {
|
||||
(
|
||||
key.to_owned(),
|
||||
LimitKeyMetadata {
|
||||
key: key.to_owned(),
|
||||
label: key.to_owned(),
|
||||
description: String::new(),
|
||||
category: "features".to_owned(),
|
||||
scope: "user".to_owned(),
|
||||
is_toggle,
|
||||
unit: None,
|
||||
min: None,
|
||||
max: None,
|
||||
},
|
||||
)
|
||||
};
|
||||
let metadata = std::collections::BTreeMap::from([
|
||||
toggle("feature_animated_avatar", true),
|
||||
toggle("feature_animated_banner", true),
|
||||
toggle("max_guilds", false),
|
||||
]);
|
||||
let limit_keys = metadata.keys().cloned().collect::<Vec<_>>();
|
||||
let mut rule = LimitRule {
|
||||
id: "premium".to_owned(),
|
||||
filters: None,
|
||||
limits: std::collections::BTreeMap::from([
|
||||
("feature_animated_avatar".to_owned(), 1),
|
||||
("feature_animated_banner".to_owned(), 1),
|
||||
("max_guilds".to_owned(), 200),
|
||||
]),
|
||||
modified_fields: None,
|
||||
};
|
||||
let form =
|
||||
MultiValueForm::parse(b"traits=premium&feature_animated_banner=1&max_guilds=250");
|
||||
update_limit_rule_values(&mut rule, &form, &limit_keys, &metadata, None);
|
||||
assert_eq!(
|
||||
filters.traits,
|
||||
vec!["staff".to_owned(), "partner".to_owned(), "vip".to_owned()]
|
||||
rule.limits,
|
||||
std::collections::BTreeMap::from([
|
||||
("feature_animated_avatar".to_owned(), 0),
|
||||
("feature_animated_banner".to_owned(), 1),
|
||||
("max_guilds".to_owned(), 250),
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
filters.guild_features,
|
||||
vec!["COMMUNITY".to_owned(), "NEWS".to_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_user_ids_splits_newlines_and_commas() {
|
||||
let mut custom = LimitRule {
|
||||
id: "vip".to_owned(),
|
||||
filters: None,
|
||||
limits: std::collections::BTreeMap::from([("max_guilds".to_owned(), 300)]),
|
||||
modified_fields: None,
|
||||
};
|
||||
let form = MultiValueForm::parse(b"traits=vip&max_guilds=300");
|
||||
update_limit_rule_values(&mut custom, &form, &limit_keys, &metadata, None);
|
||||
assert_eq!(
|
||||
parse_experiment_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs")
|
||||
.expect("valid IDs"),
|
||||
vec![
|
||||
"1".to_owned(),
|
||||
"2".to_owned(),
|
||||
"3".to_owned(),
|
||||
"4".to_owned(),
|
||||
"5".to_owned()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_user_ids_dedupes_preserving_order() {
|
||||
assert_eq!(
|
||||
parse_experiment_user_ids("20,10,20,10,30", "Included user IDs").expect("valid IDs"),
|
||||
vec!["20".to_owned(), "10".to_owned(), "30".to_owned()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_user_ids_rejects_non_digit_and_overlong_values() {
|
||||
for value in [
|
||||
"abc",
|
||||
"12a",
|
||||
"-1",
|
||||
"1.0",
|
||||
"999999999999999999999",
|
||||
"<script>",
|
||||
] {
|
||||
assert_eq!(
|
||||
parse_experiment_user_ids(&format!("123,{value}"), "Included user IDs")
|
||||
.expect_err("invalid ID"),
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
"{value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_user_ids_rejects_exceeding_the_cap() {
|
||||
let value = (0..EXPERIMENT_MAX_TARGETED_USERS)
|
||||
.map(|index| index.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let ids = parse_experiment_user_ids(&format!("{value}\n999"), "Included user IDs")
|
||||
.expect("valid IDs at cap");
|
||||
assert_eq!(ids.len(), EXPERIMENT_MAX_TARGETED_USERS);
|
||||
assert_eq!(ids.last(), Some(&"999".to_owned()));
|
||||
assert_eq!(
|
||||
parse_experiment_user_ids(&format!("{value}\n1000"), "Included user IDs")
|
||||
.expect_err("too many IDs"),
|
||||
"Included user IDs must contain at most 1000 unique IDs"
|
||||
custom.limits,
|
||||
std::collections::BTreeMap::from([("max_guilds".to_owned(), 300)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_domain_migration_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true&domain_migration_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&domain_migration_include_premium_users=true",
|
||||
b"domain_migration_enabled=true&domain_migration_rollout_basis_points=%20250%20&domain_migration_rollout_salt=%20domain-migration-v2%20&domain_migration_included_user_ids=1500000000000000001%0A1500000000000000002&domain_migration_excluded_user_ids=1500000000000000003%2C%201500000000000000004&domain_migration_anonymous_rollout_basis_points=%20100%20&domain_migration_standalone_forwarding=true&domain_migration_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&domain_migration_include_premium_users=true&domain_migration_rollout_country_codes=se%2C%20NO%0Ase",
|
||||
);
|
||||
let update = build_domain_migration_update(&form)
|
||||
.expect("valid form")
|
||||
@@ -1333,6 +1503,10 @@ mod tests {
|
||||
assert_eq!(update.anonymous_rollout_basis_points, Some(100));
|
||||
assert_eq!(update.standalone_forwarding, Some(true));
|
||||
assert_eq!(update.include_premium_users, Some(true));
|
||||
assert_eq!(
|
||||
update.rollout_country_codes,
|
||||
Some(vec!["SE".to_owned(), "NO".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_guild_ids,
|
||||
Some(vec![
|
||||
@@ -1350,6 +1524,7 @@ mod tests {
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"domain_migration": {
|
||||
"enabled": false,
|
||||
"rollout_country_codes": [],
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
@@ -1359,50 +1534,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_domain_migration_update_rejects_invalid_rollout_fields() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"domain_migration_rollout_basis_points=10001",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"domain_migration_anonymous_rollout_basis_points=10001",
|
||||
"Anonymous rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"domain_migration_anonymous_rollout_basis_points=abc",
|
||||
"Anonymous rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"domain_migration_rollout_salt=%20%20",
|
||||
"Rollout salt must be between 1 and 64 characters",
|
||||
),
|
||||
(
|
||||
format!("domain_migration_rollout_salt={}", "x".repeat(65)).as_str(),
|
||||
"Rollout salt must be between 1 and 64 characters",
|
||||
),
|
||||
(
|
||||
"domain_migration_rollout_salt=caf%C3%A9",
|
||||
"Rollout salt must use printable ASCII",
|
||||
),
|
||||
(
|
||||
"domain_migration_included_user_ids=123%2Cinvalid",
|
||||
"Included user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
(
|
||||
"domain_migration_excluded_user_ids=123%2Cinvalid",
|
||||
"Excluded user IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_domain_migration_update(&form).expect_err("invalid rollout field"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_push_relay_update_reads_the_consent_checkbox() {
|
||||
let unchecked = build_push_relay_update(&MultiValueForm::parse(b"_csrf=token"));
|
||||
@@ -1445,57 +1576,23 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_captcha_update_rejects_difficulty_outside_the_supported_range() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"captcha_cost=999",
|
||||
"Cost must be a whole number between 1000 and 20000",
|
||||
),
|
||||
(
|
||||
"captcha_cost=20001",
|
||||
"Cost must be a whole number between 1000 and 20000",
|
||||
),
|
||||
(
|
||||
"captcha_max_counter=99",
|
||||
"Maximum counter must be a whole number between 100 and 20000",
|
||||
),
|
||||
(
|
||||
"captcha_max_counter=20001",
|
||||
"Maximum counter must be a whole number between 100 and 20000",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_captcha_update(&form).expect_err("invalid field"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domain_migration_update_rejects_an_invalid_included_guild_id() {
|
||||
fn build_voice_p2p_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"domain_migration_included_guild_ids=1500000000000000005%0Anot-a-guild",
|
||||
b"voice_p2p_enabled=true&voice_p2p_rollout_basis_points=%20500%20&voice_p2p_rollout_salt=%20voice-p2p-v2%20&voice_p2p_included_user_ids=1500000000000000001&voice_p2p_excluded_user_ids=1500000000000000002&voice_p2p_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&voice_p2p_include_premium_users=true&voice_p2p_rollout_country_codes=se%2C%20NO%0Ase&voice_p2p_max_participants=%203%20",
|
||||
);
|
||||
assert_eq!(
|
||||
build_domain_migration_update(&form).expect_err("invalid guild id"),
|
||||
"Included guild IDs entry 2 must contain 1 to 20 decimal digits"
|
||||
);
|
||||
}
|
||||
|
||||
#[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)
|
||||
let update = build_voice_p2p_update(&form)
|
||||
.expect("valid form")
|
||||
.plutonium_page
|
||||
.expect("plutonium page update");
|
||||
.voice_p2p
|
||||
.expect("voice p2p 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.rollout_salt, Some("voice-p2p-v2".to_owned()));
|
||||
assert_eq!(update.include_premium_users, Some(true));
|
||||
assert_eq!(update.max_participants, Some(3));
|
||||
assert_eq!(
|
||||
update.rollout_country_codes,
|
||||
Some(vec!["SE".to_owned(), "NO".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_guild_ids,
|
||||
Some(vec![
|
||||
@@ -1514,13 +1611,14 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_plutonium_page_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
fn build_voice_p2p_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");
|
||||
let request = build_voice_p2p_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"plutonium_page": {
|
||||
serde_json::json!({"voice_p2p": {
|
||||
"enabled": false,
|
||||
"rollout_country_codes": [],
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
@@ -1529,26 +1627,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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");
|
||||
@@ -1559,67 +1637,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_rejects_invalid_numbers() {
|
||||
for (key, message, below_min, above_max) in [
|
||||
(
|
||||
"experiment_delivery_poll_interval_seconds",
|
||||
"Poll interval must be a whole number between 60 and 86400",
|
||||
"59",
|
||||
"86401",
|
||||
),
|
||||
(
|
||||
"experiment_delivery_poll_jitter_percent",
|
||||
"Poll jitter must be a whole number between 0 and 50",
|
||||
"-1",
|
||||
"51",
|
||||
),
|
||||
] {
|
||||
for value in [
|
||||
"",
|
||||
"%20%20",
|
||||
"abc",
|
||||
"-1",
|
||||
"1.5",
|
||||
"9999999999999999999999999",
|
||||
below_min,
|
||||
above_max,
|
||||
] {
|
||||
let form = MultiValueForm::parse(format!("{key}={value}").as_bytes());
|
||||
assert_eq!(
|
||||
build_experiment_delivery_update(&form).expect_err("invalid number"),
|
||||
message,
|
||||
"{key}={value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_accepts_inclusive_bounds() {
|
||||
for (interval, jitter) in [(60, 0), (86_400, 50)] {
|
||||
let form = MultiValueForm::parse(format!("experiment_delivery_poll_interval_seconds={interval}&experiment_delivery_poll_jitter_percent={jitter}").as_bytes());
|
||||
let request = build_experiment_delivery_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"experiment_delivery": {"poll_interval_seconds": interval, "poll_jitter_percent": jitter}})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_accepts_padded_numbers() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"experiment_delivery_poll_interval_seconds=%20900%20&experiment_delivery_poll_jitter_percent=%2025%20",
|
||||
);
|
||||
let update = build_experiment_delivery_update(&form)
|
||||
.expect("valid form")
|
||||
.experiment_delivery
|
||||
.expect("experiment delivery update");
|
||||
assert_eq!(update.poll_interval_seconds, Some(900));
|
||||
assert_eq!(update.poll_jitter_percent, Some(25));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_limit_rule_values_reads_checked_limit_keys() {
|
||||
let form = MultiValueForm::parse(b"message_send=1&traits%5B%5D=trial");
|
||||
@@ -1629,7 +1646,13 @@ mod tests {
|
||||
limits: std::collections::BTreeMap::new(),
|
||||
modified_fields: None,
|
||||
};
|
||||
update_limit_rule_values(&mut rule, &form, &["message_send".to_owned()], None);
|
||||
update_limit_rule_values(
|
||||
&mut rule,
|
||||
&form,
|
||||
&["message_send".to_owned()],
|
||||
&std::collections::BTreeMap::new(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(rule.limits.get("message_send"), Some(&1));
|
||||
assert_eq!(
|
||||
rule.filters.expect("filters").traits,
|
||||
|
||||
@@ -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},
|
||||
@@ -259,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",
|
||||
)
|
||||
@@ -308,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 {
|
||||
@@ -319,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.",
|
||||
),
|
||||
@@ -380,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");
|
||||
@@ -427,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()),
|
||||
@@ -462,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" => {
|
||||
|
||||
@@ -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,
|
||||
@@ -166,7 +161,12 @@ pub async fn render(
|
||||
.await
|
||||
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list user applications"))
|
||||
.unwrap_or_default();
|
||||
Some(tabs::applications::applications_tab(config, &apps))
|
||||
Some(tabs::applications::applications_tab(
|
||||
config,
|
||||
&apps,
|
||||
"No applications owned",
|
||||
"This user does not own any applications.",
|
||||
))
|
||||
}
|
||||
"archives" => {
|
||||
if !acl::has_any_permission(
|
||||
@@ -298,6 +298,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),
|
||||
|
||||
@@ -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." }
|
||||
},
|
||||
@@ -376,31 +470,3 @@ fn append_query_params(url: &mut String, params: &[(String, String)]) {
|
||||
url.push_str(&urlencoding::encode(value));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn badge_name_follows_the_cached_branding_and_falls_back_to_the_default() {
|
||||
let gold = PremiumBranding {
|
||||
name: Some("Gold".to_owned()),
|
||||
premium_enabled: true,
|
||||
};
|
||||
assert_eq!(premium_badge_name(Some(&gold)).as_deref(), Some("Gold"));
|
||||
let unnamed = PremiumBranding {
|
||||
name: None,
|
||||
premium_enabled: true,
|
||||
};
|
||||
assert_eq!(
|
||||
premium_badge_name(Some(&unnamed)).as_deref(),
|
||||
Some("Premium")
|
||||
);
|
||||
let everyone = PremiumBranding {
|
||||
name: Some("Gold".to_owned()),
|
||||
premium_enabled: false,
|
||||
};
|
||||
assert_eq!(premium_badge_name(Some(&everyone)), None);
|
||||
assert_eq!(premium_badge_name(None).as_deref(), Some("Premium"));
|
||||
}
|
||||
}
|
||||
@@ -264,36 +264,6 @@ mod tests {
|
||||
assert_eq!(body["allowed_guild_ids"], serde_json::json!(["1", "2"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_clears_restriction_lists_the_form_submitted_empty() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"region_id=us-east&server_id=s1&required_guild_features=&allowed_guild_ids=",
|
||||
);
|
||||
let body = build_server_body(&form);
|
||||
assert_eq!(body["required_guild_features"], serde_json::json!([]));
|
||||
assert_eq!(body["allowed_guild_ids"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_leaves_restriction_lists_alone_when_the_form_omits_them() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"region_id=us-east&server_id=s1&endpoint=wss%3A%2F%2Fvoice.example&is_active=false&vip_only=true",
|
||||
);
|
||||
let body = build_server_body(&form);
|
||||
let object = body.as_object().unwrap();
|
||||
assert!(!object.contains_key("required_guild_features"));
|
||||
assert!(!object.contains_key("allowed_guild_ids"));
|
||||
assert_eq!(body["is_active"], serde_json::json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_region_body_clears_restriction_lists_the_form_submitted_empty() {
|
||||
let form = MultiValueForm::parse(b"id=us-east&required_guild_features=&allowed_guild_ids=");
|
||||
let body = build_region_body(&form);
|
||||
assert_eq!(body["required_guild_features"], serde_json::json!([]));
|
||||
assert_eq!(body["allowed_guild_ids"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_region_body_leaves_restriction_lists_alone_when_the_form_omits_them() {
|
||||
let form = MultiValueForm::parse(b"id=us-east&name=US%20East");
|
||||
@@ -311,13 +281,6 @@ mod tests {
|
||||
assert_eq!(body["soft_connection_limit"], serde_json::json!(250));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_clears_soft_connection_limit_when_the_field_is_empty() {
|
||||
let form = MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=");
|
||||
let body = build_server_body(&form);
|
||||
assert_eq!(body["soft_connection_limit"], serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_omits_soft_connection_limit_when_the_field_is_absent_or_invalid() {
|
||||
let absent = MultiValueForm::parse(b"region_id=us-east&server_id=s1&is_active=true");
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::config::AdminConfig;
|
||||
use axum::{
|
||||
http::{HeaderValue, header},
|
||||
response::Response,
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use rand::RngExt;
|
||||
@@ -109,10 +114,42 @@ pub fn verify_csrf_token(token: &str, user_id: &str, secret_key: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
pub const LEGACY_SESSION_COOKIE_NAME: &str = "session";
|
||||
pub const SESSION_COOKIE_NAME: &str = "admin_session";
|
||||
pub const SESSION_MAX_AGE: i64 = MAX_AGE_SECONDS as i64;
|
||||
|
||||
pub(crate) fn admin_cookie_path(config: &AdminConfig) -> &str {
|
||||
if config.base_path.is_empty() {
|
||||
"/"
|
||||
} else {
|
||||
config.base_path.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear_cookie_value(name: &str, path: &str) -> String {
|
||||
format!("{name}=; Path={path}; HttpOnly; SameSite=Lax; Max-Age=0")
|
||||
}
|
||||
|
||||
pub(crate) fn clear_cookie_values(name: &str, config: &AdminConfig) -> Vec<String> {
|
||||
let admin_path = admin_cookie_path(config);
|
||||
let mut cookies = vec![clear_cookie_value(name, admin_path)];
|
||||
if admin_path != "/" {
|
||||
cookies.push(clear_cookie_value(name, "/"));
|
||||
}
|
||||
cookies
|
||||
}
|
||||
|
||||
pub(crate) fn append_cookie(response: &mut Response, cookie: &str) {
|
||||
if let Ok(value) = HeaderValue::from_str(cookie) {
|
||||
response.headers_mut().append(header::SET_COOKIE, value);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn append_cookies(response: &mut Response, cookies: &[String]) {
|
||||
for cookie in cookies {
|
||||
append_cookie(response, cookie);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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,87 @@ impl AppState {
|
||||
self.remember_premium_branding(branding.clone());
|
||||
Some(branding)
|
||||
}
|
||||
|
||||
pub async fn self_hosted_branding(&self) -> Option<PremiumBranding> {
|
||||
if !self.config().self_hosted {
|
||||
return None;
|
||||
}
|
||||
self.premium_branding(&AdminApiClient::anonymous(
|
||||
self.http_client(),
|
||||
self.config(),
|
||||
))
|
||||
.await
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -8,13 +8,10 @@
|
||||
--font-display: "Fluxer Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
--color-brand-primary: hsl(242 70% 55%);
|
||||
--color-brand-primary-dark: hsl(242 70% 47%);
|
||||
--color-brand-primary-rgb: 93 79 192;
|
||||
}
|
||||
|
||||
:root {
|
||||
--brand-primary: hsl(242 70% 55%);
|
||||
--focus-ring-color: hsl(242 70% 55% / 0.45);
|
||||
--focus-ring-offset: #ffffff;
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -35,6 +32,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 +74,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 +85,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))
|
||||
|
||||
Loaded 100 of 6970 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user