mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 20:22:11 +09:00
Compare commits
112
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -50,6 +50,8 @@
|
||||
|
||||
/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,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
|
||||
@@ -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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Closes #
|
||||
Resolves https://feedback.fluxer.com/p/
|
||||
|
||||
<!-- Repeat this line for each resolved issue, up to 20. Remove the placeholder only if no issue is resolved and the approval gate does not apply. -->
|
||||
<!-- Repeat this line for each feedback.fluxer.com post this resolves, up to 20. Remove the placeholder only if no post is resolved and the approval gate does not apply. -->
|
||||
|
||||
## Summary
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ jobs:
|
||||
BUILD_VERSION=${{ needs.meta.outputs.build_version }}
|
||||
SOURCE_SHA=${{ github.sha }}
|
||||
SOURCE_DATE=${{ steps.source.outputs.date }}
|
||||
FLUXER_SELF_HOSTED=true
|
||||
APP_ASSETS_REF=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:${{ needs.meta.outputs.build_version }}-assets
|
||||
APP_ASSETS_PLATFORM=linux/amd64
|
||||
cache-from: type=registry,ref=ghcr.io/${{ env.GHCR_OWNER }}/fluxer-app-proxy-self-hosted:buildcache-${{ matrix.platform }}
|
||||
|
||||
@@ -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 }}
|
||||
@@ -547,6 +766,17 @@ jobs:
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_payload
|
||||
|
||||
- name: Download desktop module packages
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
name: desktop-modules-${{ needs.meta.outputs.build_channel }}-${{ needs.meta.outputs.version }}-${{ needs.meta.outputs.source_sha }}
|
||||
path: desktop-modules
|
||||
|
||||
- name: Build desktop module manifest
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step build_module_manifest
|
||||
|
||||
- name: Prepare GitHub release assets
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
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: ""
|
||||
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 || '' }}
|
||||
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 }
|
||||
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
|
||||
|
||||
@@ -476,6 +476,9 @@ jobs:
|
||||
- name: Lint JSX for browser-translation safety
|
||||
run: pnpm exec eslint . --max-warnings 0
|
||||
|
||||
- name: Check data-flx attributes
|
||||
run: pnpm --filter fluxer_app theming:data-flx:check
|
||||
|
||||
i18n:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
@@ -502,7 +505,6 @@ jobs:
|
||||
run: |
|
||||
if ! git diff --exit-code -- \
|
||||
packages/errors/src/i18n/locales \
|
||||
packages/errors/src/i18n/ErrorI18nTypes.generated.ts \
|
||||
fluxer_api/pkgs/email/src/email_i18n/locales \
|
||||
fluxer_api/pkgs/email/src/email_i18n/EmailI18nTypes.generated.ts \
|
||||
fluxer_api/src/api/content_i18n/locales; then
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
/fluxer_app/pkgs/libfluxcore/
|
||||
/fluxer_app/pkgs/libfluxwebp/
|
||||
/fluxer_app/src/features/i18n/locales/*/messages.mjs
|
||||
**/auto-i18n-reviewed-unchanged.json.lock
|
||||
**/weblate/locales/auto-i18n-reviewed-unchanged.json
|
||||
/fluxer_app/src/features/messaging/utils/markdown/parser/MarkdownParserWasmBytes.ts
|
||||
/fluxer_app/src/features/theme/styles/generated/
|
||||
/fluxer_app/src/features/ui/components/SVGMasks.tsx
|
||||
@@ -44,6 +46,8 @@
|
||||
|
||||
/app-dist-output/
|
||||
/artifacts/
|
||||
/desktop-shared-assets/
|
||||
/desktop-modules/
|
||||
/s3_payload/
|
||||
/upload_staging/
|
||||
|
||||
|
||||
Generated
+35
-159
@@ -863,24 +863,12 @@ version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
@@ -1048,12 +1036,6 @@ version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "color_quant"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
@@ -1100,16 +1082,6 @@ version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "cookie"
|
||||
version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87"
|
||||
dependencies = [
|
||||
"time",
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -1685,15 +1657,6 @@ version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ff"
|
||||
version = "0.13.1"
|
||||
@@ -1710,6 +1673,16 @@ version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.12"
|
||||
@@ -1729,7 +1702,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide 0.9.1",
|
||||
"miniz_oxide",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
@@ -1741,7 +1714,7 @@ dependencies = [
|
||||
"aws-config",
|
||||
"aws-sdk-s3",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"brotli",
|
||||
"chrono",
|
||||
"clap",
|
||||
"hex",
|
||||
@@ -1750,6 +1723,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tar",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"walkdir",
|
||||
@@ -1764,10 +1738,8 @@ dependencies = [
|
||||
"axum",
|
||||
"clap",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"image",
|
||||
"libc",
|
||||
"regex",
|
||||
"reqwest",
|
||||
@@ -1789,12 +1761,10 @@ dependencies = [
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
"moka",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -1851,7 +1821,6 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http 0.7.1",
|
||||
"tracing",
|
||||
@@ -1872,15 +1841,12 @@ dependencies = [
|
||||
"fluxer_common",
|
||||
"fluxer_markdown_parser",
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
"linkify",
|
||||
"mime_guess",
|
||||
"moka",
|
||||
"rmp-serde",
|
||||
"scylla",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -1937,13 +1903,11 @@ dependencies = [
|
||||
"deadpool-postgres",
|
||||
"futures",
|
||||
"libc",
|
||||
"moka",
|
||||
"rmp-serde",
|
||||
"rustls",
|
||||
"scylla",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
"tokio-postgres-rustls",
|
||||
@@ -1962,7 +1926,6 @@ dependencies = [
|
||||
"entities",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"infer",
|
||||
"moka",
|
||||
"regex",
|
||||
@@ -1970,7 +1933,6 @@ dependencies = [
|
||||
"scraper",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -1988,7 +1950,6 @@ dependencies = [
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
"moka",
|
||||
"rmp-serde",
|
||||
"scylla",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -2005,7 +1966,6 @@ dependencies = [
|
||||
"axum",
|
||||
"base64 0.23.1",
|
||||
"chrono",
|
||||
"cookie",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"maud",
|
||||
@@ -2042,7 +2002,6 @@ dependencies = [
|
||||
"hex",
|
||||
"rand 0.10.2",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"tokio",
|
||||
@@ -2262,16 +2221,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gif"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
|
||||
dependencies = [
|
||||
"color_quant",
|
||||
"weezl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "group"
|
||||
version = "0.13.0"
|
||||
@@ -2661,34 +2610,6 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"color_quant",
|
||||
"gif",
|
||||
"image-webp",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
"zune-core",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error 2.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
@@ -3014,16 +2935,6 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
@@ -3065,16 +2976,6 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multiversion"
|
||||
version = "0.9.0"
|
||||
@@ -3458,19 +3359,6 @@ dependencies = [
|
||||
"plotters-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
@@ -3643,24 +3531,12 @@ dependencies = [
|
||||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.12"
|
||||
@@ -4173,7 +4049,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"quick-error 1.2.3",
|
||||
"quick-error",
|
||||
"tempfile",
|
||||
"wait-timeout",
|
||||
]
|
||||
@@ -4790,6 +4666,17 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -5594,12 +5481,6 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "2.1.3"
|
||||
@@ -5818,6 +5699,16 @@ dependencies = [
|
||||
"tls_codec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xmlparser"
|
||||
version = "0.13.6"
|
||||
@@ -5978,18 +5869,3 @@ dependencies = [
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
|
||||
# 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">
|
||||
|
||||
Vendored
-1
@@ -112,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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -142,14 +142,6 @@ MEILI_MASTER_KEY=CHANGE_ME
|
||||
#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
|
||||
@@ -263,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=
|
||||
@@ -314,7 +306,7 @@ FLUXER_KLIPY_API_KEY=
|
||||
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=
|
||||
@@ -327,7 +319,10 @@ FLUXER_EMAIL_SMTP_SECURE=true
|
||||
FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_DISCOVERY_MIN_MEMBER_COUNT=1
|
||||
|
||||
# Instance identity and account policy.
|
||||
# Instance identity and account policy. The terms, privacy and community
|
||||
# guidelines links have no variable here. Set them in the admin panel under
|
||||
# Instance Config. Until a guidelines link is set, the clients, report forms and
|
||||
# enforcement emails show none.
|
||||
#FLUXER_APP_PRODUCT_NAME=Fluxer
|
||||
#FLUXER_APP_ICON_URL=
|
||||
#FLUXER_APP_SYMBOL_URL=
|
||||
@@ -345,6 +340,17 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#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
|
||||
|
||||
@@ -132,11 +132,6 @@ x-fluxer-env: &fluxer-env
|
||||
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:-}
|
||||
@@ -158,6 +153,9 @@ x-fluxer-env: &fluxer-env
|
||||
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:-}
|
||||
@@ -671,6 +669,7 @@ services:
|
||||
FLUXER_S3_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-}
|
||||
FLUXER_STATIC_CDN_ENDPOINT: ${FLUXER_STATIC_CDN_ENDPOINT:-}
|
||||
FLUXER_MEDIA_ENDPOINT: ${FLUXER_MEDIA_ENDPOINT:-}
|
||||
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
|
||||
DISCOVERY_REFRESH_INTERVAL_MS: ${DISCOVERY_REFRESH_INTERVAL_MS:-}
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
|
||||
@@ -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");
|
||||
|
||||
+2015
-414
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,6 +70,7 @@ 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";
|
||||
@@ -90,6 +90,7 @@ pub const USER_VIEW_DOB: &str = "user:view:dob";
|
||||
pub const USER_VIEW_EMAIL: &str = "user:view:email";
|
||||
pub const USER_VIEW_IP: &str = "user:view:ip";
|
||||
pub const USER_TEMP_BAN: &str = "user:temp_ban";
|
||||
pub const USER_UPDATE_BOT_STATUS: &str = "user:update:bot_status";
|
||||
pub const USER_UPDATE_DOB: &str = "user:update:dob";
|
||||
pub const USER_UPDATE_EMAIL: &str = "user:update:email";
|
||||
pub const USER_UPDATE_FLAGS: &str = "user:update:flags";
|
||||
@@ -149,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,
|
||||
@@ -177,6 +177,7 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
MESSAGE_DELETE,
|
||||
MESSAGE_LOOKUP,
|
||||
MESSAGE_SHRED,
|
||||
REPORT_DELETE,
|
||||
REPORT_RESOLVE,
|
||||
REPORT_VIEW,
|
||||
REPORT_VIEW_REPORTER_PII,
|
||||
@@ -196,6 +197,7 @@ pub const ALL_ACLS: &[&str] = &[
|
||||
USER_VIEW_EMAIL,
|
||||
USER_VIEW_IP,
|
||||
USER_TEMP_BAN,
|
||||
USER_UPDATE_BOT_STATUS,
|
||||
USER_UPDATE_DOB,
|
||||
USER_UPDATE_EMAIL,
|
||||
USER_UPDATE_FLAGS,
|
||||
|
||||
@@ -17,6 +17,7 @@ pub mod user_flag_bits {
|
||||
pub const FRIENDLY_BOT: u64 = 1 << 4;
|
||||
pub const FRIENDLY_BOT_MANUAL_APPROVAL: u64 = 1 << 5;
|
||||
pub const SPAMMER: u64 = 1 << 6;
|
||||
pub const PROFILE_HIDDEN: u64 = 1 << 7;
|
||||
pub const HIGH_GLOBAL_RATE_LIMIT: u64 = 1 << 33;
|
||||
pub const DELETED: u64 = 1 << 34;
|
||||
pub const SELF_DELETED: u64 = 1 << 36;
|
||||
@@ -58,6 +59,10 @@ pub const USER_FLAGS: &[U64Flag] = &[
|
||||
name: "SPAMMER",
|
||||
value: user_flag_bits::SPAMMER,
|
||||
},
|
||||
U64Flag {
|
||||
name: "PROFILE_HIDDEN",
|
||||
value: user_flag_bits::PROFILE_HIDDEN,
|
||||
},
|
||||
U64Flag {
|
||||
name: "HIGH_GLOBAL_RATE_LIMIT",
|
||||
value: user_flag_bits::HIGH_GLOBAL_RATE_LIMIT,
|
||||
@@ -154,3 +159,32 @@ pub const PREMIUM_FLAGS: &[I32Flag] = &[
|
||||
value: 1 << 8,
|
||||
},
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::USER_FLAGS;
|
||||
|
||||
#[test]
|
||||
fn user_flags_cover_every_flag_in_the_admin_spec() {
|
||||
let spec: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../openapi-admin.json")).expect("admin spec");
|
||||
let values = spec["components"]["schemas"]["UserFlags"]["x-bitflagValues"]
|
||||
.as_array()
|
||||
.expect("UserFlags bitflag values");
|
||||
assert!(!values.is_empty());
|
||||
for entry in values {
|
||||
let name = entry["name"].as_str().expect("flag name");
|
||||
let value: u64 = entry["value"]
|
||||
.as_str()
|
||||
.expect("flag value")
|
||||
.parse()
|
||||
.expect("numeric flag value");
|
||||
assert!(
|
||||
USER_FLAGS
|
||||
.iter()
|
||||
.any(|flag| flag.name == name && flag.value == value),
|
||||
"{name} ({value}) is missing from USER_FLAGS"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -337,9 +337,9 @@ fn guild_update_response(
|
||||
.map_err(ApiError::Parse)?,
|
||||
features: guild.features.into_iter().map(String::from).collect(),
|
||||
nsfw_level: guild.nsfw_level.map(i32::from),
|
||||
nsfw: guild.nsfw,
|
||||
content_warning_level: guild.content_warning_level.map(i32::from),
|
||||
content_warning_text: guild.content_warning_text.map(String::from),
|
||||
nsfw: None,
|
||||
content_warning_level: None,
|
||||
content_warning_text: None,
|
||||
description: None,
|
||||
vanity_url_code: None,
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::api::generated::snowflake;
|
||||
|
||||
use super::client::{AdminApiClient, ApiResult};
|
||||
use super::types::{ActiveJobsResponse, CancelJobResponse, GetJobResponse, ListJobsResponse};
|
||||
use super::types::{CancelJobResponse, GetJobResponse, ListJobsResponse};
|
||||
|
||||
pub struct ListJobsParams {
|
||||
pub limit: u32,
|
||||
@@ -59,15 +59,6 @@ impl AdminApiClient {
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn list_active_jobs(&self) -> ApiResult<ActiveJobsResponse> {
|
||||
let response = self
|
||||
.generated()
|
||||
.list_admin_active_jobs()
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_field<'a>(cursor: Option<&'a serde_json::Value>, field: &str) -> &'a str {
|
||||
|
||||
@@ -5,8 +5,7 @@ use crate::api::generated::{snowflake, types as generated_types};
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
BrowseChannelResponse, DeleteAllUserMessagesResponse, LookupMessageResponse,
|
||||
MessageShredResponse, MessageShredStatusResponse, NcmecAttachmentSubmitResult,
|
||||
SearchChannelMessagesResponse,
|
||||
MessageShredResponse, MessageShredStatusResponse, SearchChannelMessagesResponse,
|
||||
};
|
||||
|
||||
impl AdminApiClient {
|
||||
@@ -30,37 +29,6 @@ impl AdminApiClient {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn report_attachment_to_ncmec(
|
||||
&self,
|
||||
channel_id: &str,
|
||||
message_id: &str,
|
||||
attachment_id: &str,
|
||||
filename: &str,
|
||||
reporter_full_name: &str,
|
||||
source_report_id: Option<&str>,
|
||||
) -> ApiResult<NcmecAttachmentSubmitResult> {
|
||||
let body = generated_types::ReportAttachmentToNcmecRequest {
|
||||
attachment_id: snowflake(attachment_id),
|
||||
channel_id: snowflake(channel_id),
|
||||
confirmed_viewed: true,
|
||||
filename: generated_types::ReportAttachmentToNcmecRequestFilename::try_from(filename)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
message_id: snowflake(message_id),
|
||||
reporter_full_name:
|
||||
generated_types::ReportAttachmentToNcmecRequestReporterFullName::try_from(
|
||||
reporter_full_name,
|
||||
)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?,
|
||||
source_report_id: source_report_id.map(snowflake),
|
||||
};
|
||||
let response = self
|
||||
.generated()
|
||||
.create_admin_ncmec_report(&body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
}
|
||||
|
||||
pub async fn lookup_message(
|
||||
&self,
|
||||
channel_id: &str,
|
||||
|
||||
@@ -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,11 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::api::generated::snowflake;
|
||||
use crate::api::generated::{snowflake, types::UpdateReportRequestResolution};
|
||||
|
||||
use super::client::{AdminApiClient, ApiError, ApiResult};
|
||||
use super::types::{
|
||||
ListReportsResponse, ReportEntry, ResolveReportResponse, SearchReportsResponse,
|
||||
ReportEntry, ReportLegalHoldResponse, ReportReasonListResponse, ResolveReportResponse,
|
||||
SearchReportsResponse,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -13,8 +14,10 @@ pub struct SearchReportsParams<'a> {
|
||||
pub status: Option<i32>,
|
||||
pub report_type: Option<i32>,
|
||||
pub category: Option<&'a str>,
|
||||
pub reason: Option<&'a str>,
|
||||
pub reporter_id: Option<&'a str>,
|
||||
pub reported_user_id: Option<&'a str>,
|
||||
pub reported_webhook_id: Option<&'a str>,
|
||||
pub reported_guild_id: Option<&'a str>,
|
||||
pub reported_channel_id: Option<&'a str>,
|
||||
pub guild_context_id: Option<&'a str>,
|
||||
@@ -26,23 +29,6 @@ pub struct SearchReportsParams<'a> {
|
||||
}
|
||||
|
||||
impl AdminApiClient {
|
||||
pub async fn list_reports(
|
||||
&self,
|
||||
status: Option<i32>,
|
||||
limit: u32,
|
||||
offset: Option<u32>,
|
||||
) -> ApiResult<ListReportsResponse> {
|
||||
let status = status.map(report_status).transpose()?.unwrap_or_default();
|
||||
let limit = limit.to_string();
|
||||
let offset = offset.map(|value| value.to_string()).unwrap_or_default();
|
||||
let query_params = [
|
||||
("status", status),
|
||||
("limit", limit.as_str()),
|
||||
("offset", offset.as_str()),
|
||||
];
|
||||
self.get("/admin/reports", Some(&query_params)).await
|
||||
}
|
||||
|
||||
pub async fn get_report(&self, report_id: &str) -> ApiResult<ReportEntry> {
|
||||
let response = self
|
||||
.generated()
|
||||
@@ -55,11 +41,12 @@ impl AdminApiClient {
|
||||
pub async fn resolve_report(
|
||||
&self,
|
||||
report_id: &str,
|
||||
resolution: UpdateReportRequestResolution,
|
||||
public_comment: Option<&str>,
|
||||
notify_reporter: bool,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<ResolveReportResponse> {
|
||||
let mut body = serde_json::json!({"status": "resolved"});
|
||||
let mut body = serde_json::json!({"status": "resolved", "resolution": resolution});
|
||||
if let Some(public_comment) = public_comment {
|
||||
body["public_comment"] = serde_json::Value::from(public_comment);
|
||||
}
|
||||
@@ -72,6 +59,40 @@ impl AdminApiClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_report_legal_hold(
|
||||
&self,
|
||||
report_id: &str,
|
||||
legal_hold_until: Option<&str>,
|
||||
legal_hold_reason: Option<&str>,
|
||||
) -> ApiResult<ReportLegalHoldResponse> {
|
||||
let body = serde_json::json!({
|
||||
"legal_hold_until": legal_hold_until,
|
||||
"legal_hold_reason": legal_hold_until.and(legal_hold_reason),
|
||||
});
|
||||
self.post_with_reason(
|
||||
&format!(
|
||||
"/admin/reports/{}/legal-hold",
|
||||
urlencoding::encode(report_id)
|
||||
),
|
||||
Some(&body),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_report(
|
||||
&self,
|
||||
report_id: &str,
|
||||
audit_log_reason: Option<&str>,
|
||||
) -> ApiResult<()> {
|
||||
self.delete_void_with_reason(
|
||||
&format!("/admin/reports/{}", urlencoding::encode(report_id)),
|
||||
None,
|
||||
audit_log_reason,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn search_reports(
|
||||
&self,
|
||||
params: &SearchReportsParams<'_>,
|
||||
@@ -98,11 +119,16 @@ impl AdminApiClient {
|
||||
("status", status),
|
||||
("report_type", report_type),
|
||||
("category", params.category.unwrap_or_default()),
|
||||
("reason", params.reason.unwrap_or_default()),
|
||||
("reporter_id", params.reporter_id.unwrap_or_default()),
|
||||
(
|
||||
"reported_user_id",
|
||||
params.reported_user_id.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"reported_webhook_id",
|
||||
params.reported_webhook_id.unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"reported_guild_id",
|
||||
params.reported_guild_id.unwrap_or_default(),
|
||||
@@ -127,6 +153,10 @@ impl AdminApiClient {
|
||||
self.get("/admin/reports", Some(&query_params)).await
|
||||
}
|
||||
|
||||
pub async fn list_report_reasons(&self) -> ApiResult<ReportReasonListResponse> {
|
||||
self.get("/admin/report-reasons", None).await
|
||||
}
|
||||
|
||||
pub async fn search_reports_by_reporter(
|
||||
&self,
|
||||
reporter_id: &str,
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -27,8 +27,6 @@ pub struct InstanceConfigResponse {
|
||||
#[serde(default)]
|
||||
pub domain_migration: DomainMigrationConfigResponse,
|
||||
#[serde(default)]
|
||||
pub plutonium_page: PlutoniumPageConfigResponse,
|
||||
#[serde(default)]
|
||||
pub captcha: CaptchaConfigResponse,
|
||||
#[serde(default)]
|
||||
pub experiment_delivery: ExperimentDeliveryConfigResponse,
|
||||
@@ -419,6 +417,8 @@ pub struct AppSetupConfigResponse {
|
||||
pub struct AppLegalConfigResponse {
|
||||
pub terms_url: Option<String>,
|
||||
pub privacy_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub guidelines_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
@@ -507,7 +507,6 @@ impl VoiceE2eeScope {
|
||||
|
||||
pub const EXPERIMENT_MAX_TARGETED_USERS: usize = 1_000;
|
||||
pub const DOMAIN_MIGRATION_DEFAULT_SALT: &str = "domain-migration-v1";
|
||||
pub const PLUTONIUM_PAGE_DEFAULT_SALT: &str = "plutonium-page-v1";
|
||||
pub const CAPTCHA_COST_RANGE: std::ops::RangeInclusive<u32> = 1_000..=20_000;
|
||||
pub const CAPTCHA_MAX_COUNTER_RANGE: std::ops::RangeInclusive<u32> = 100..=20_000;
|
||||
|
||||
@@ -579,52 +578,6 @@ pub struct DomainMigrationConfigUpdateRequest {
|
||||
pub standalone_forwarding: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct PlutoniumPageConfigResponse {
|
||||
pub enabled: bool,
|
||||
pub config_version: u64,
|
||||
pub rollout_basis_points: u32,
|
||||
pub rollout_salt: String,
|
||||
pub included_user_ids: Vec<String>,
|
||||
pub included_guild_ids: Vec<String>,
|
||||
pub include_premium_users: bool,
|
||||
pub excluded_user_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for PlutoniumPageConfigResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
config_version: 0,
|
||||
rollout_basis_points: 0,
|
||||
rollout_salt: PLUTONIUM_PAGE_DEFAULT_SALT.to_owned(),
|
||||
included_user_ids: Vec::new(),
|
||||
included_guild_ids: Vec::new(),
|
||||
include_premium_users: false,
|
||||
excluded_user_ids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct PlutoniumPageConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_basis_points: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rollout_salt: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_user_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub included_guild_ids: Option<Vec<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_premium_users: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub excluded_user_ids: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
#[serde(default)]
|
||||
pub struct CaptchaConfigResponse {
|
||||
@@ -771,8 +724,6 @@ pub struct InstanceConfigUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub domain_migration: Option<DomainMigrationConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plutonium_page: Option<PlutoniumPageConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub captcha: Option<CaptchaConfigUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub experiment_delivery: Option<ExperimentDeliveryConfigUpdateRequest>,
|
||||
@@ -978,6 +929,8 @@ pub struct AppLegalConfigUpdateRequest {
|
||||
pub terms_url: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub privacy_url: Option<Option<String>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub guidelines_url: Option<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -1075,24 +1028,17 @@ mod tests {
|
||||
.expect("admin schema");
|
||||
let domain_migration = serde_json::from_value::<DomainMigrationConfigResponse>(json!({}))
|
||||
.expect("default domain migration config");
|
||||
let plutonium_page = serde_json::from_value::<PlutoniumPageConfigResponse>(json!({}))
|
||||
.expect("default plutonium page config");
|
||||
let captcha = serde_json::from_value::<CaptchaConfigResponse>(json!({}))
|
||||
.expect("default captcha config");
|
||||
let delivery = serde_json::from_value::<ExperimentDeliveryConfigResponse>(json!({}))
|
||||
.expect("default delivery config");
|
||||
let domain_migration =
|
||||
serde_json::to_value(domain_migration).expect("serializable domain migration config");
|
||||
let plutonium_page =
|
||||
serde_json::to_value(plutonium_page).expect("serializable plutonium page config");
|
||||
let captcha = serde_json::to_value(captcha).expect("serializable captcha config");
|
||||
let delivery = serde_json::to_value(delivery).expect("serializable delivery config");
|
||||
let generated_domain_migration: generated_types::DomainMigrationConfigResponse =
|
||||
serde_json::from_value(domain_migration.clone())
|
||||
.expect("generated domain migration config contract");
|
||||
let generated_plutonium_page: generated_types::PlutoniumPageConfigResponse =
|
||||
serde_json::from_value(plutonium_page.clone())
|
||||
.expect("generated plutonium page config contract");
|
||||
let generated_captcha: generated_types::CaptchaConfigResponse =
|
||||
serde_json::from_value(captcha.clone()).expect("generated captcha config contract");
|
||||
let generated_delivery: generated_types::ExperimentDeliveryConfigResponse =
|
||||
@@ -1102,11 +1048,6 @@ mod tests {
|
||||
.expect("serializable generated domain migration config"),
|
||||
domain_migration
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_plutonium_page)
|
||||
.expect("serializable generated plutonium page config"),
|
||||
plutonium_page
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(generated_captcha).expect("serializable generated captcha config"),
|
||||
captcha
|
||||
@@ -1118,7 +1059,6 @@ mod tests {
|
||||
);
|
||||
for (name, value) in [
|
||||
("DomainMigrationConfigResponse", domain_migration),
|
||||
("PlutoniumPageConfigResponse", plutonium_page),
|
||||
("CaptchaConfigResponse", captcha),
|
||||
("ExperimentDeliveryConfigResponse", delivery),
|
||||
] {
|
||||
@@ -1153,25 +1093,4 @@ mod tests {
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plutonium_page_update_preserves_empty_lists_and_omitted_fields() {
|
||||
let update = PlutoniumPageConfigUpdateRequest {
|
||||
included_user_ids: Some(Vec::new()),
|
||||
excluded_user_ids: Some(Vec::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let value = serde_json::to_value(update).expect("serializable update");
|
||||
serde_json::from_value::<generated_types::PlutoniumPageConfigUpdateRequest>(value.clone())
|
||||
.expect("generated update contract");
|
||||
assert_eq!(
|
||||
value,
|
||||
json!({"included_user_ids": [], "excluded_user_ids": []})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(PlutoniumPageConfigUpdateRequest::default())
|
||||
.expect("serializable update"),
|
||||
json!({})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -306,6 +306,28 @@ impl AdminApiClient {
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn set_bot_status(&self, user_id: &str, is_bot: bool) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserBotStatusRequest { bot: is_bot };
|
||||
let response = self
|
||||
.generated()
|
||||
.set_admin_user_bot_status(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn set_system_status(&self, user_id: &str, is_system: bool) -> ApiResult<AdminUser> {
|
||||
let body = generated_types::AdminUserSystemStatusRequest { system: is_system };
|
||||
let response = self
|
||||
.generated()
|
||||
.set_admin_user_system_status(&snowflake(user_id), &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
let resp: UserMutationResponse = self.generated_value(response.into_inner())?;
|
||||
Ok(resp.user)
|
||||
}
|
||||
|
||||
pub async fn change_username(
|
||||
&self,
|
||||
user_id: &str,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use fluxer_common::config::{
|
||||
normalize_base_path, normalize_public_endpoint_from_env, read_bool_env, read_env,
|
||||
env_value, normalize_base_path, normalize_public_endpoint_from_env, read_bool_env, read_env,
|
||||
read_first_env, trim_trailing_slash,
|
||||
};
|
||||
|
||||
const DEFAULT_ADMIN_OAUTH_CLIENT_ID: &str = "1234567890123456789";
|
||||
const DEFAULT_REPORTS_BUCKET_ORIGIN: &str = "https://fluxer-reports.ewr1.vultrobjects.com";
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AdminConfig {
|
||||
@@ -17,6 +18,7 @@ pub struct AdminConfig {
|
||||
pub api_endpoint: String,
|
||||
pub media_endpoint: String,
|
||||
pub static_cdn_endpoint: String,
|
||||
pub reports_bucket_origin: String,
|
||||
pub admin_endpoint: String,
|
||||
pub web_app_endpoint: String,
|
||||
pub oauth_client_id: String,
|
||||
@@ -76,6 +78,7 @@ impl AdminConfig {
|
||||
static_cdn_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(
|
||||
&read_env("FLUXER_STATIC_CDN_ENDPOINT", ""),
|
||||
)),
|
||||
reports_bucket_origin: reports_bucket_origin_from_env(),
|
||||
|
||||
admin_endpoint,
|
||||
web_app_endpoint: normalize_public_endpoint_from_env(&trim_trailing_slash(&read_env(
|
||||
@@ -120,6 +123,72 @@ impl AdminConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn reports_bucket_origin_from_env() -> String {
|
||||
let public_endpoint = env_value("FLUXER_S3_PUBLIC_ENDPOINT")
|
||||
.map(|value| normalize_public_endpoint_from_env(value.trim()));
|
||||
let endpoint = env_value("FLUXER_S3_ENDPOINT");
|
||||
presign_endpoint(
|
||||
public_endpoint.as_deref(),
|
||||
endpoint.as_deref(),
|
||||
&read_env("FLUXER_S3_BUCKET_UPLOADS", "fluxer-uploads"),
|
||||
)
|
||||
.and_then(|endpoint| {
|
||||
bucket_origin(
|
||||
&endpoint,
|
||||
read_bool_env("FLUXER_S3_FORCE_PATH_STYLE", false),
|
||||
&read_env("FLUXER_S3_BUCKET_REPORTS", "fluxer-reports"),
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| DEFAULT_REPORTS_BUCKET_ORIGIN.to_owned())
|
||||
}
|
||||
|
||||
fn presign_endpoint(
|
||||
public_endpoint: Option<&str>,
|
||||
endpoint: Option<&str>,
|
||||
uploads_bucket: &str,
|
||||
) -> Option<url::Url> {
|
||||
let Some(public_endpoint) = public_endpoint else {
|
||||
return url::Url::parse(endpoint?.trim()).ok();
|
||||
};
|
||||
let mut parsed = url::Url::parse(public_endpoint).ok()?;
|
||||
let host = parsed.host_str()?.to_owned();
|
||||
if let Some(shared_host) = host.strip_prefix(&format!("{uploads_bucket}.")) {
|
||||
parsed.set_host(Some(shared_host)).ok()?;
|
||||
}
|
||||
Some(parsed)
|
||||
}
|
||||
|
||||
fn bucket_origin(endpoint: &url::Url, force_path_style: bool, bucket: &str) -> Option<String> {
|
||||
if !matches!(endpoint.scheme(), "http" | "https") {
|
||||
return None;
|
||||
}
|
||||
let path_style = force_path_style
|
||||
|| !matches!(endpoint.host(), Some(url::Host::Domain(_)))
|
||||
|| !is_virtual_hostable_bucket(bucket, endpoint.scheme() == "http");
|
||||
if path_style {
|
||||
return Some(endpoint.origin().ascii_serialization());
|
||||
}
|
||||
let mut virtual_host = endpoint.clone();
|
||||
virtual_host
|
||||
.set_host(Some(&format!("{bucket}.{}", endpoint.host_str()?)))
|
||||
.ok()?;
|
||||
Some(virtual_host.origin().ascii_serialization())
|
||||
}
|
||||
|
||||
fn is_virtual_hostable_bucket(bucket: &str, allow_dots: bool) -> bool {
|
||||
if allow_dots && bucket.contains('.') {
|
||||
return bucket
|
||||
.split('.')
|
||||
.all(|label| is_virtual_hostable_bucket(label, false));
|
||||
}
|
||||
(3..=63).contains(&bucket.len())
|
||||
&& bucket
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
|
||||
&& !bucket.starts_with('-')
|
||||
&& !bucket.ends_with('-')
|
||||
}
|
||||
|
||||
impl RuntimeEnv {
|
||||
pub(crate) fn from_env_value(value: &str) -> Self {
|
||||
match value {
|
||||
@@ -138,7 +207,7 @@ mod tests {
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
const MANAGED_ENV: [&str; 10] = [
|
||||
const MANAGED_ENV: [&str; 15] = [
|
||||
"FLUXER_ENV",
|
||||
"FLUXER_ADMIN_HOST",
|
||||
"FLUXER_ADMIN_PORT",
|
||||
@@ -149,6 +218,11 @@ mod tests {
|
||||
"FLUXER_MEDIA_ENDPOINT",
|
||||
"FLUXER_STATIC_CDN_ENDPOINT",
|
||||
"FLUXER_BASE_DOMAIN",
|
||||
"FLUXER_S3_ENDPOINT",
|
||||
"FLUXER_S3_PUBLIC_ENDPOINT",
|
||||
"FLUXER_S3_FORCE_PATH_STYLE",
|
||||
"FLUXER_S3_BUCKET_UPLOADS",
|
||||
"FLUXER_S3_BUCKET_REPORTS",
|
||||
];
|
||||
|
||||
fn config_from_env(vars: &[(&str, &str)]) -> AdminConfig {
|
||||
@@ -238,6 +312,7 @@ mod tests {
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: String::new(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
reports_bucket_origin: String::new(),
|
||||
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
@@ -266,6 +341,7 @@ mod tests {
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: String::new(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
reports_bucket_origin: String::new(),
|
||||
|
||||
admin_endpoint: String::new(),
|
||||
web_app_endpoint: String::new(),
|
||||
@@ -368,4 +444,237 @@ mod tests {
|
||||
format!("{api_admin_endpoint}/oauth2_callback")
|
||||
);
|
||||
}
|
||||
|
||||
fn origin_for(endpoint: &str, force_path_style: bool, bucket: &str) -> Option<String> {
|
||||
bucket_origin(
|
||||
&url::Url::parse(endpoint).expect("valid endpoint"),
|
||||
force_path_style,
|
||||
bucket,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bucket_origin_matches_the_addressing_of_presigned_urls() {
|
||||
let cases = [
|
||||
(
|
||||
"https://ewr1.vultrobjects.com",
|
||||
false,
|
||||
"fluxer-reports",
|
||||
"https://fluxer-reports.ewr1.vultrobjects.com",
|
||||
),
|
||||
(
|
||||
"https://ewr1.vultrobjects.com/",
|
||||
true,
|
||||
"fluxer-reports",
|
||||
"https://ewr1.vultrobjects.com",
|
||||
),
|
||||
(
|
||||
"http://seaweedfs:8333",
|
||||
true,
|
||||
"fluxer-reports",
|
||||
"http://seaweedfs:8333",
|
||||
),
|
||||
(
|
||||
"http://seaweedfs:8333",
|
||||
false,
|
||||
"fluxer-reports",
|
||||
"http://fluxer-reports.seaweedfs:8333",
|
||||
),
|
||||
(
|
||||
"http://127.0.0.1:8333",
|
||||
false,
|
||||
"fluxer-reports",
|
||||
"http://127.0.0.1:8333",
|
||||
),
|
||||
(
|
||||
"http://[::1]:8333",
|
||||
false,
|
||||
"fluxer-reports",
|
||||
"http://[::1]:8333",
|
||||
),
|
||||
(
|
||||
"https://s3.example.com:9000",
|
||||
false,
|
||||
"fluxer-reports",
|
||||
"https://fluxer-reports.s3.example.com:9000",
|
||||
),
|
||||
(
|
||||
"https://s3.example.com:443/base/path",
|
||||
false,
|
||||
"fluxer-reports",
|
||||
"https://fluxer-reports.s3.example.com",
|
||||
),
|
||||
(
|
||||
"https://S3.Example.com",
|
||||
false,
|
||||
"fluxer-reports",
|
||||
"https://fluxer-reports.s3.example.com",
|
||||
),
|
||||
(
|
||||
"https://s3.example.com",
|
||||
false,
|
||||
"reports.example",
|
||||
"https://s3.example.com",
|
||||
),
|
||||
(
|
||||
"http://s3.example.com",
|
||||
false,
|
||||
"reports.example",
|
||||
"http://reports.example.s3.example.com",
|
||||
),
|
||||
(
|
||||
"http://s3.example.com",
|
||||
false,
|
||||
"a.example",
|
||||
"http://s3.example.com",
|
||||
),
|
||||
(
|
||||
"https://s3.example.com",
|
||||
false,
|
||||
"Reports",
|
||||
"https://s3.example.com",
|
||||
),
|
||||
(
|
||||
"https://s3.example.com",
|
||||
false,
|
||||
"ab",
|
||||
"https://s3.example.com",
|
||||
),
|
||||
(
|
||||
"https://s3.example.com",
|
||||
false,
|
||||
"reports_bucket",
|
||||
"https://s3.example.com",
|
||||
),
|
||||
(
|
||||
"https://s3.example.com",
|
||||
false,
|
||||
"-reports",
|
||||
"https://s3.example.com",
|
||||
),
|
||||
(
|
||||
"https://s3.example.com",
|
||||
false,
|
||||
"192.168.1.1",
|
||||
"https://s3.example.com",
|
||||
),
|
||||
];
|
||||
for (endpoint, force_path_style, bucket, expected) in cases {
|
||||
assert_eq!(
|
||||
origin_for(endpoint, force_path_style, bucket).as_deref(),
|
||||
Some(expected),
|
||||
"{endpoint} {force_path_style} {bucket}"
|
||||
);
|
||||
}
|
||||
assert_eq!(origin_for("ftp://s3.example.com", true, "reports"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presign_endpoint_prefers_the_public_endpoint_and_drops_the_uploads_bucket_host() {
|
||||
let host = |public: Option<&str>, endpoint: Option<&str>| {
|
||||
presign_endpoint(public, endpoint, "fluxer-uploads").map(|url| url.to_string())
|
||||
};
|
||||
assert_eq!(
|
||||
host(
|
||||
Some("https://fluxer-uploads.ewr1.vultrobjects.com"),
|
||||
Some("https://internal.example")
|
||||
)
|
||||
.as_deref(),
|
||||
Some("https://ewr1.vultrobjects.com/")
|
||||
);
|
||||
assert_eq!(
|
||||
host(
|
||||
Some("https://cdn.example.com"),
|
||||
Some("http://seaweedfs:8333")
|
||||
)
|
||||
.as_deref(),
|
||||
Some("https://cdn.example.com/")
|
||||
);
|
||||
assert_eq!(
|
||||
host(None, Some("http://seaweedfs:8333")).as_deref(),
|
||||
Some("http://seaweedfs:8333/")
|
||||
);
|
||||
assert_eq!(host(Some("not a url"), Some("http://seaweedfs:8333")), None);
|
||||
assert_eq!(host(None, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reports_bucket_origin_defaults_to_the_hosted_bucket() {
|
||||
let config = config_from_env(&[]);
|
||||
assert_eq!(config.reports_bucket_origin, DEFAULT_REPORTS_BUCKET_ORIGIN);
|
||||
|
||||
let config = config_from_env(&[("FLUXER_S3_ENDPOINT", "not a url")]);
|
||||
assert_eq!(config.reports_bucket_origin, DEFAULT_REPORTS_BUCKET_ORIGIN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reports_bucket_origin_follows_the_object_store_settings() {
|
||||
let hosted = config_from_env(&[
|
||||
("FLUXER_S3_ENDPOINT", "https://ewr1.vultrobjects.com"),
|
||||
(
|
||||
"FLUXER_S3_PUBLIC_ENDPOINT",
|
||||
"https://fluxer-uploads.ewr1.vultrobjects.com",
|
||||
),
|
||||
]);
|
||||
assert_eq!(hosted.reports_bucket_origin, DEFAULT_REPORTS_BUCKET_ORIGIN);
|
||||
|
||||
let bundled = config_from_env(&[
|
||||
("FLUXER_S3_ENDPOINT", "http://seaweedfs:8333"),
|
||||
(
|
||||
"FLUXER_S3_PUBLIC_ENDPOINT",
|
||||
"https://objects.fluxer.example",
|
||||
),
|
||||
("FLUXER_S3_FORCE_PATH_STYLE", "true"),
|
||||
]);
|
||||
assert_eq!(
|
||||
bundled.reports_bucket_origin,
|
||||
"https://objects.fluxer.example"
|
||||
);
|
||||
|
||||
let internal_only = config_from_env(&[
|
||||
("FLUXER_S3_ENDPOINT", "http://seaweedfs:8333"),
|
||||
("FLUXER_S3_FORCE_PATH_STYLE", "true"),
|
||||
]);
|
||||
assert_eq!(internal_only.reports_bucket_origin, "http://seaweedfs:8333");
|
||||
|
||||
let outside = config_from_env(&[
|
||||
(
|
||||
"FLUXER_S3_ENDPOINT",
|
||||
"https://s3.eu-central-1.amazonaws.com",
|
||||
),
|
||||
("FLUXER_S3_FORCE_PATH_STYLE", "false"),
|
||||
("FLUXER_S3_BUCKET_UPLOADS", "example-uploads"),
|
||||
("FLUXER_S3_BUCKET_REPORTS", "example-reports"),
|
||||
]);
|
||||
assert_eq!(
|
||||
outside.reports_bucket_origin,
|
||||
"https://example-reports.s3.eu-central-1.amazonaws.com"
|
||||
);
|
||||
|
||||
let renamed_uploads = config_from_env(&[
|
||||
("FLUXER_S3_ENDPOINT", "https://s3.example.com"),
|
||||
(
|
||||
"FLUXER_S3_PUBLIC_ENDPOINT",
|
||||
"https://example-uploads.s3.example.com",
|
||||
),
|
||||
("FLUXER_S3_BUCKET_UPLOADS", "example-uploads"),
|
||||
("FLUXER_S3_BUCKET_REPORTS", "example-reports"),
|
||||
]);
|
||||
assert_eq!(
|
||||
renamed_uploads.reports_bucket_origin,
|
||||
"https://example-reports.s3.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_default_public_port_reaches_the_reports_bucket_origin() {
|
||||
let config = config_from_env(&[
|
||||
("FLUXER_BASE_DOMAIN", "fluxer.example"),
|
||||
("FLUXER_PUBLIC_PORT", "19080"),
|
||||
("FLUXER_S3_ENDPOINT", "http://seaweedfs:8333"),
|
||||
("FLUXER_S3_PUBLIC_ENDPOINT", "http://fluxer.example"),
|
||||
("FLUXER_S3_FORCE_PATH_STYLE", "true"),
|
||||
]);
|
||||
assert_eq!(config.reports_bucket_origin, "http://fluxer.example:19080");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
use crate::{
|
||||
api::types::AdminUser,
|
||||
middleware::flash::{self, FlashData},
|
||||
middleware::flash,
|
||||
session::{self, Session},
|
||||
state::AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
@@ -65,10 +64,6 @@ pub async fn require_auth(
|
||||
response
|
||||
}
|
||||
|
||||
pub fn get_flash(request: &Request) -> Option<FlashData> {
|
||||
request.extensions().get::<FlashData>().cloned()
|
||||
}
|
||||
|
||||
fn admin_cookie_path(config: &crate::config::AdminConfig) -> &str {
|
||||
if config.base_path.is_empty() {
|
||||
"/"
|
||||
@@ -159,11 +154,3 @@ async fn fetch_admin_user(
|
||||
Err(_) => AdminFetchResult::None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_auth_context(request: &Request) -> Option<&AuthContext> {
|
||||
request.extensions().get::<AuthContext>()
|
||||
}
|
||||
|
||||
pub fn require_auth_context(request: &Request) -> Result<&AuthContext, Box<Response>> {
|
||||
get_auth_context(request).ok_or_else(|| Box::new(StatusCode::UNAUTHORIZED.into_response()))
|
||||
}
|
||||
|
||||
@@ -213,6 +213,7 @@ mod tests {
|
||||
api_endpoint: String::new(),
|
||||
media_endpoint: String::new(),
|
||||
static_cdn_endpoint: String::new(),
|
||||
reports_bucket_origin: String::new(),
|
||||
admin_endpoint: admin_endpoint.to_owned(),
|
||||
web_app_endpoint: String::new(),
|
||||
oauth_client_id: String::new(),
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use maud::{DOCTYPE, html};
|
||||
|
||||
pub struct AppError {
|
||||
pub status: StatusCode,
|
||||
pub message: String,
|
||||
pub detail: Option<String>,
|
||||
pub home_url: String,
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
pub fn not_found(message: &str, base_path: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
message: message.to_owned(),
|
||||
detail: None,
|
||||
home_url: if base_path.is_empty() {
|
||||
"/".to_owned()
|
||||
} else {
|
||||
base_path.to_owned()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn internal(message: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: message.to_owned(),
|
||||
detail: None,
|
||||
home_url: "/login".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forbidden(message: &str) -> Self {
|
||||
Self {
|
||||
status: StatusCode::FORBIDDEN,
|
||||
message: message.to_owned(),
|
||||
detail: None,
|
||||
home_url: "/login".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_detail(mut self, detail: String) -> Self {
|
||||
self.detail = Some(detail);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_base_path(mut self, base_path: &str) -> Self {
|
||||
self.home_url = if base_path.is_empty() {
|
||||
"/".to_owned()
|
||||
} else {
|
||||
base_path.to_owned()
|
||||
};
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> Response {
|
||||
let status_code = self.status.as_u16();
|
||||
let status_text = self.status.canonical_reason().unwrap_or("Error");
|
||||
let home_url = &self.home_url;
|
||||
let (border, bg, text_color) = if status_code >= 500 {
|
||||
("border-red-300", "bg-red-50", "text-red-800")
|
||||
} else if status_code == 403 {
|
||||
("border-yellow-300", "bg-yellow-50", "text-yellow-800")
|
||||
} else {
|
||||
("border-neutral-300", "bg-neutral-50", "text-neutral-800")
|
||||
};
|
||||
let markup = html! {
|
||||
(DOCTYPE)
|
||||
html lang="en" {
|
||||
head {
|
||||
meta charset="UTF-8";
|
||||
meta name="viewport" content="width=device-width, initial-scale=1.0";
|
||||
title { (status_code) " " (status_text) " ~ Fluxer Admin" }
|
||||
}
|
||||
body class="min-h-screen bg-neutral-50 flex items-center justify-center" {
|
||||
div class="mx-auto max-w-lg px-4 py-16 text-center" {
|
||||
h1 class="text-6xl font-bold text-neutral-300 mb-4" {
|
||||
(status_code)
|
||||
}
|
||||
h2 class="text-xl font-semibold text-neutral-700 mb-2" {
|
||||
(status_text)
|
||||
}
|
||||
div class={"rounded-lg border px-4 py-3 text-sm mb-6 " (border) " " (bg) " " (text_color)} {
|
||||
div { (self.message) }
|
||||
@if let Some(ref detail) = self.detail {
|
||||
div class="mt-2 break-all rounded border border-current/20 \
|
||||
bg-white/60 px-3 py-2 text-xs" {
|
||||
(detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
a href=(home_url)
|
||||
class="text-blue-600 hover:text-blue-800 hover:underline text-sm" {
|
||||
"Go to admin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
(self.status, Html(markup.into_string())).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for AppError {
|
||||
fn from(err: anyhow::Error) -> Self {
|
||||
tracing::error!(?err, "internal server error");
|
||||
Self {
|
||||
status: StatusCode::INTERNAL_SERVER_ERROR,
|
||||
message: "An internal error occurred.".to_owned(),
|
||||
detail: None,
|
||||
home_url: "/login".to_owned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,16 +93,20 @@ pub fn clear_flash_cookie(response: &mut Response) {
|
||||
}
|
||||
|
||||
pub fn redirect_with_flash(url: &str, flash: FlashData, secure: bool) -> Response {
|
||||
let encoded = serialize_flash(&flash);
|
||||
let mut response = Redirect::to(url).into_response();
|
||||
set_flash_cookie(&mut response, &flash, secure);
|
||||
response
|
||||
}
|
||||
|
||||
pub fn set_flash_cookie(response: &mut Response, flash: &FlashData, secure: bool) {
|
||||
let encoded = serialize_flash(flash);
|
||||
let secure_flag = if secure { "; Secure" } else { "" };
|
||||
let cookie_value = format!(
|
||||
"{FLASH_COOKIE_NAME}={encoded}; Path=/; HttpOnly; SameSite=Lax; Max-Age=60{secure_flag}"
|
||||
);
|
||||
let mut response = Redirect::to(url).into_response();
|
||||
if let Ok(v) = HeaderValue::from_str(&cookie_value) {
|
||||
response.headers_mut().append(header::SET_COOKIE, v);
|
||||
}
|
||||
response
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
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,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;
|
||||
|
||||
@@ -201,7 +202,13 @@ async fn execute_check(
|
||||
};
|
||||
match result {
|
||||
Ok(r) if r.banned => match r.expires_at {
|
||||
Some(expires_at) => ("info", format!("{value} is banned until {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")),
|
||||
|
||||
@@ -96,6 +96,24 @@ pub async fn render(
|
||||
config, &guild, &stickers, csrf_token,
|
||||
))
|
||||
}
|
||||
"threads" => {
|
||||
if !acl::has_permission(admin_acls, acl::GUILD_LOOKUP) {
|
||||
return None;
|
||||
}
|
||||
let threads = client
|
||||
.list_guild_threads(guild_id)
|
||||
.await
|
||||
.map(|response| response.threads)
|
||||
.map_err(|error| tracing::warn!(%error, guild_id, "admin API request failed: list guild threads"))
|
||||
.unwrap_or_default();
|
||||
Some(tabs::threads::threads_tab(
|
||||
config,
|
||||
&guild,
|
||||
&threads,
|
||||
acl::has_permission(admin_acls, acl::MESSAGE_DELETE_ALL),
|
||||
csrf_token,
|
||||
))
|
||||
}
|
||||
"audit_log" | "audit-log" => {
|
||||
if !acl::has_permission(admin_acls, acl::GUILD_AUDIT_LOG_VIEW) {
|
||||
return None;
|
||||
|
||||
@@ -433,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(
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,10 @@ fn build_admin_csp(config: &AdminConfig) -> String {
|
||||
"default-src 'self'".to_owned(),
|
||||
"script-src 'self' 'unsafe-inline'".to_owned(),
|
||||
"style-src 'self' 'unsafe-inline'".to_owned(),
|
||||
format!("img-src 'self' data: blob: {static_cdn} {media} https://fluxer-reports.ewr1.vultrobjects.com"),
|
||||
format!(
|
||||
"img-src 'self' data: blob: {static_cdn} {media} {}",
|
||||
config.reports_bucket_origin
|
||||
),
|
||||
"font-src 'self'".to_owned(),
|
||||
"connect-src 'self'".to_owned(),
|
||||
"object-src 'none'".to_owned(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,8 +18,8 @@ use crate::{
|
||||
InstanceMediaUpdateRequest, InstancePolicyUpdateRequest,
|
||||
InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest,
|
||||
InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule,
|
||||
LimitRuleFilters, PlutoniumPageConfigUpdateRequest, PremiumMode,
|
||||
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest, VoiceE2eeScope,
|
||||
LimitRuleFilters, PremiumMode, PushRelayConfigUpdateRequest, RegistrationMode,
|
||||
SsoConfigUpdateRequest, VoiceE2eeScope,
|
||||
},
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -220,10 +220,6 @@ pub async fn instance_config_post(
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_plutonium_page" => match build_plutonium_page_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"update_captcha" => match build_captcha_update(&form) {
|
||||
Ok(update) => instance_config_result(client.update_instance_config(&update).await),
|
||||
Err(message) => FlashData::error(message),
|
||||
@@ -613,41 +609,6 @@ fn build_domain_migration_update(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_plutonium_page_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
plutonium_page: Some(PlutoniumPageConfigUpdateRequest {
|
||||
enabled: Some(form.bool_value("plutonium_page_enabled")),
|
||||
rollout_basis_points: parse_form_number(
|
||||
form,
|
||||
"plutonium_page_rollout_basis_points",
|
||||
"Rollout basis points",
|
||||
0,
|
||||
EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX,
|
||||
)?,
|
||||
rollout_salt: parse_experiment_rollout_salt(form, "plutonium_page_rollout_salt")?,
|
||||
included_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("plutonium_page_included_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included user IDs",
|
||||
)?),
|
||||
included_guild_ids: Some(parse_experiment_user_ids(
|
||||
form.first("plutonium_page_included_guild_ids")
|
||||
.unwrap_or_default(),
|
||||
"Included guild IDs",
|
||||
)?),
|
||||
include_premium_users: Some(form.bool_value("plutonium_page_include_premium_users")),
|
||||
excluded_user_ids: Some(parse_experiment_user_ids(
|
||||
form.first("plutonium_page_excluded_user_ids")
|
||||
.unwrap_or_default(),
|
||||
"Excluded user IDs",
|
||||
)?),
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn build_captcha_update(form: &MultiValueForm) -> Result<InstanceConfigUpdateRequest, String> {
|
||||
Ok(InstanceConfigUpdateRequest {
|
||||
captcha: Some(CaptchaConfigUpdateRequest {
|
||||
@@ -748,6 +709,7 @@ fn build_app_legal_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest
|
||||
legal: Some(AppLegalConfigUpdateRequest {
|
||||
terms_url: optional("app_terms_url"),
|
||||
privacy_url: optional("app_privacy_url"),
|
||||
guidelines_url: optional("app_guidelines_url"),
|
||||
}),
|
||||
registration: None,
|
||||
}),
|
||||
@@ -1197,6 +1159,47 @@ 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(
|
||||
@@ -1516,72 +1519,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_plutonium_page_update_reads_the_rollout_fields() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"plutonium_page_enabled=true&plutonium_page_rollout_basis_points=%20500%20&plutonium_page_rollout_salt=%20plutonium-page-v2%20&plutonium_page_included_user_ids=1500000000000000001&plutonium_page_excluded_user_ids=1500000000000000002&plutonium_page_included_guild_ids=1500000000000000005%0A1500000000000000006%2C1500000000000000005&plutonium_page_include_premium_users=true",
|
||||
);
|
||||
let update = build_plutonium_page_update(&form)
|
||||
.expect("valid form")
|
||||
.plutonium_page
|
||||
.expect("plutonium page update");
|
||||
assert_eq!(update.enabled, Some(true));
|
||||
assert_eq!(update.rollout_basis_points, Some(500));
|
||||
assert_eq!(update.rollout_salt, Some("plutonium-page-v2".to_owned()));
|
||||
assert_eq!(update.include_premium_users, Some(true));
|
||||
assert_eq!(
|
||||
update.included_guild_ids,
|
||||
Some(vec![
|
||||
"1500000000000000005".to_owned(),
|
||||
"1500000000000000006".to_owned()
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
update.included_user_ids,
|
||||
Some(vec!["1500000000000000001".to_owned()])
|
||||
);
|
||||
assert_eq!(
|
||||
update.excluded_user_ids,
|
||||
Some(vec!["1500000000000000002".to_owned()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_plutonium_page_update_leaves_the_feature_inert_when_nothing_is_submitted() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
let request = build_plutonium_page_update(&form).expect("valid form");
|
||||
assert_eq!(
|
||||
serde_json::to_value(request).expect("serializable update"),
|
||||
serde_json::json!({"plutonium_page": {
|
||||
"enabled": false,
|
||||
"included_user_ids": [],
|
||||
"included_guild_ids": [],
|
||||
"include_premium_users": false,
|
||||
"excluded_user_ids": [],
|
||||
}})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_plutonium_page_update_rejects_invalid_rollout_fields() {
|
||||
for (form, message) in [
|
||||
(
|
||||
"plutonium_page_rollout_basis_points=10001",
|
||||
"Rollout basis points must be a whole number between 0 and 10000",
|
||||
),
|
||||
(
|
||||
"plutonium_page_included_guild_ids=1500000000000000005%0Anot-a-guild",
|
||||
"Included guild IDs entry 2 must contain 1 to 20 decimal digits",
|
||||
),
|
||||
] {
|
||||
let form = MultiValueForm::parse(form.as_bytes());
|
||||
assert_eq!(
|
||||
build_plutonium_page_update(&form).expect_err("invalid field"),
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_experiment_delivery_update_leaves_both_fields_unchanged_when_absent() {
|
||||
let form = MultiValueForm::parse(b"_csrf=token");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::plural::count_noun;
|
||||
use crate::{
|
||||
admin_flags,
|
||||
api::client::{AdminApiClient, ApiError},
|
||||
@@ -178,6 +179,22 @@ pub async fn dispatch(
|
||||
"Failed to clear user fields",
|
||||
)
|
||||
}
|
||||
"set_bot_status" => {
|
||||
let val = form.bool_value("bot");
|
||||
DispatchOutcome::from_result(
|
||||
client.set_bot_status(user_id, val).await,
|
||||
"Bot status updated successfully",
|
||||
"Failed to update bot status",
|
||||
)
|
||||
}
|
||||
"set_system_status" => {
|
||||
let val = form.bool_value("system");
|
||||
DispatchOutcome::from_result(
|
||||
client.set_system_status(user_id, val).await,
|
||||
"System status updated successfully",
|
||||
"Failed to update system status",
|
||||
)
|
||||
}
|
||||
"change_username" => {
|
||||
let Some(username) = get("username") else {
|
||||
return DispatchOutcome::error("Username is required");
|
||||
@@ -295,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 {
|
||||
@@ -306,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.",
|
||||
),
|
||||
@@ -419,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()),
|
||||
@@ -454,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" => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
use crate::{
|
||||
api::{
|
||||
client::{AdminApiClient, ApiResultExt},
|
||||
types::{AccountIdentityMode, AccountIdentitySettings, PremiumBranding},
|
||||
types::{AccountIdentityMode, AccountIdentitySettings, PremiumBranding, ReportReasonEntry},
|
||||
},
|
||||
config::AdminConfig,
|
||||
};
|
||||
@@ -26,6 +26,7 @@ struct AppStateInner {
|
||||
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 {
|
||||
@@ -40,6 +41,7 @@ impl AppState {
|
||||
http_client,
|
||||
premium_branding: Mutex::new(None),
|
||||
account_identity: Mutex::new(None),
|
||||
report_reasons: Mutex::new(None),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -126,6 +128,35 @@ impl AppState {
|
||||
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 {
|
||||
|
||||
@@ -35,6 +35,12 @@ h1, h2, h3, h4, h5, h6 {
|
||||
input, select, textarea {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
select,
|
||||
input:not([type="checkbox"], [type="radio"], [type="hidden"], [type="range"], [type="color"], [type="file"]) {
|
||||
height: auto;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
a:focus-visible,
|
||||
@@ -71,33 +77,6 @@ main:focus {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.flash-success {
|
||||
border: 1px solid #86efac;
|
||||
background-color: #f0fdf4;
|
||||
color: #166534;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.flash-error {
|
||||
border: 1px solid #fca5a5;
|
||||
background-color: #fef2f2;
|
||||
color: #991b1b;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.flash-info {
|
||||
border: 1px solid #93c5fd;
|
||||
background-color: #eff6ff;
|
||||
color: #1e40af;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.drawer-panel {
|
||||
inset: unset;
|
||||
margin: 0;
|
||||
@@ -109,8 +88,6 @@ main:focus {
|
||||
}
|
||||
|
||||
.drawer-panel[data-drawer-side="right"] { right: 0; top: 0; }
|
||||
.drawer-panel[data-drawer-side="left"] { left: 0; top: 0; }
|
||||
.drawer-panel[data-drawer-side="bottom"] { bottom: 0; left: 0; right: 0; }
|
||||
|
||||
.drawer-panel[data-open],
|
||||
.drawer-panel:popover-open {
|
||||
|
||||
@@ -5,16 +5,12 @@ use maud::{Markup, html};
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum AlertVariant {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
Success,
|
||||
}
|
||||
|
||||
pub fn alert(variant: AlertVariant, title: Option<&str>, content: Markup) -> Markup {
|
||||
let variant_classes = match variant {
|
||||
AlertVariant::Info => "bg-blue-50 border-blue-200 text-blue-700",
|
||||
AlertVariant::Warning => "bg-neutral-50 border-neutral-200 text-neutral-700",
|
||||
AlertVariant::Error => "bg-red-50 border-red-200 text-red-700",
|
||||
AlertVariant::Success => "bg-green-50 border-green-200 text-green-700",
|
||||
};
|
||||
html! {
|
||||
@@ -30,15 +26,3 @@ pub fn alert(variant: AlertVariant, title: Option<&str>, content: Markup) -> Mar
|
||||
pub fn alert_info(content: Markup) -> Markup {
|
||||
alert(AlertVariant::Info, None, content)
|
||||
}
|
||||
|
||||
pub fn alert_error(title: &str, content: Markup) -> Markup {
|
||||
alert(AlertVariant::Error, Some(title), content)
|
||||
}
|
||||
|
||||
pub fn alert_success(title: &str, content: Markup) -> Markup {
|
||||
alert(AlertVariant::Success, Some(title), content)
|
||||
}
|
||||
|
||||
pub fn alert_warning(title: &str, content: Markup) -> Markup {
|
||||
alert(AlertVariant::Warning, Some(title), content)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
use crate::{
|
||||
api::types::Archive,
|
||||
templates::components::form::{checkbox, csrf_input, submit_button},
|
||||
@@ -72,7 +73,7 @@ fn archives_table(base: &str, archives: &[Archive]) -> Markup {
|
||||
@for archive in archives {
|
||||
tr {
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(archive.requested_at)
|
||||
(format_admin_timestamp(&archive.requested_at))
|
||||
}
|
||||
td class="px-4 py-3 text-sm text-neutral-900" {
|
||||
(status_text(archive))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
use crate::{
|
||||
api::types::AuditLogEntry,
|
||||
templates::pages::audit_logs_table::{
|
||||
@@ -27,7 +28,7 @@ fn log_row(base_path: &str, log: &AuditLogEntry, index: usize) -> Markup {
|
||||
(table_row(html! {
|
||||
(table_cell(true, html! {
|
||||
span class="text-sm whitespace-nowrap" {
|
||||
(log.created_at)
|
||||
(format_admin_timestamp(&log.created_at))
|
||||
}
|
||||
}))
|
||||
(table_cell(false, badge(&format_action(action), action_badge_variant(action))))
|
||||
|
||||
@@ -12,7 +12,3 @@ pub fn auto_refresh(enabled: bool, interval_ms: u32) -> Markup {
|
||||
script defer { (PreEscaped(script)) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn auto_refresh_default(enabled: bool) -> Markup {
|
||||
auto_refresh(enabled, 3000)
|
||||
}
|
||||
|
||||
@@ -29,11 +29,3 @@ pub fn badge(label: &str, variant: BadgeVariant) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn flag_badge(label: &str, active: bool) -> Markup {
|
||||
if active {
|
||||
badge(label, BadgeVariant::Success)
|
||||
} else {
|
||||
badge(label, BadgeVariant::Default)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,10 +44,6 @@ pub fn data_field_mono(label: &str, value: &str) -> Markup {
|
||||
data_field(label, data_field_value(value, DATA_FIELD_MONO_CLASS))
|
||||
}
|
||||
|
||||
pub fn data_field_link(label: &str, href: &str, display: &str) -> Markup {
|
||||
data_field(label, data_field_link_value(href, display, "break-words"))
|
||||
}
|
||||
|
||||
pub fn data_field_link_mono(label: &str, href: &str, display: &str) -> Markup {
|
||||
data_field(label, data_field_link_value(href, display, "break-all"))
|
||||
}
|
||||
@@ -66,20 +62,3 @@ pub fn data_grid(cols: u8, content: Markup) -> Markup {
|
||||
div class=(col_class) { (content) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn metadata_row(label: &str, value: Markup) -> Markup {
|
||||
html! {
|
||||
div class="flex flex-wrap gap-x-2 gap-y-0.5" {
|
||||
span class="flex-shrink-0 text-neutral-500 text-sm" {
|
||||
(label) ":"
|
||||
}
|
||||
span class="min-w-0 break-words text-neutral-900 text-sm" {
|
||||
(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn metadata_row_text(label: &str, value: &str) -> Markup {
|
||||
metadata_row(label, html! { (value) })
|
||||
}
|
||||
|
||||
@@ -7,22 +7,16 @@ use super::icons::{close_icon, spinner_icon};
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum DrawerSide {
|
||||
Right,
|
||||
Left,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum DrawerWidth {
|
||||
Sm,
|
||||
Md,
|
||||
Lg,
|
||||
Xl,
|
||||
}
|
||||
|
||||
fn width_class(width: DrawerWidth) -> &'static str {
|
||||
match width {
|
||||
DrawerWidth::Sm => "sm:max-w-sm",
|
||||
DrawerWidth::Md => "sm:max-w-md",
|
||||
DrawerWidth::Lg => "sm:max-w-lg",
|
||||
DrawerWidth::Xl => "sm:max-w-xl",
|
||||
}
|
||||
@@ -31,24 +25,18 @@ fn width_class(width: DrawerWidth) -> &'static str {
|
||||
fn side_class(side: DrawerSide) -> &'static str {
|
||||
match side {
|
||||
DrawerSide::Right => "right-0 top-0 h-[100dvh] w-full sm:w-[92vw]",
|
||||
DrawerSide::Left => "left-0 top-0 h-[100dvh] w-full sm:w-[92vw]",
|
||||
DrawerSide::Bottom => "bottom-0 left-0 right-0 max-h-[92dvh] w-full",
|
||||
}
|
||||
}
|
||||
|
||||
fn side_off_class(side: DrawerSide) -> &'static str {
|
||||
match side {
|
||||
DrawerSide::Right => "translate-x-full",
|
||||
DrawerSide::Left => "-translate-x-full",
|
||||
DrawerSide::Bottom => "translate-y-full",
|
||||
}
|
||||
}
|
||||
|
||||
fn side_name(side: DrawerSide) -> &'static str {
|
||||
match side {
|
||||
DrawerSide::Right => "right",
|
||||
DrawerSide::Left => "left",
|
||||
DrawerSide::Bottom => "bottom",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +56,7 @@ pub fn drawer(
|
||||
"drawer-panel pointer-events-auto fixed z-50 flex flex-col \
|
||||
bg-white shadow-2xl {} {} {}",
|
||||
side_class(side),
|
||||
match side {
|
||||
DrawerSide::Bottom => "",
|
||||
_ => width_class(width),
|
||||
},
|
||||
width_class(width),
|
||||
side_off_class(side),
|
||||
);
|
||||
html! {
|
||||
@@ -150,16 +135,6 @@ pub fn drawer_loading_state() -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn drawer_error_state(message: &str) -> Markup {
|
||||
html! {
|
||||
div role="alert"
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-4 \
|
||||
text-red-800 text-sm" {
|
||||
(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn drawer_controller_script() -> Markup {
|
||||
html! {
|
||||
script defer { (PreEscaped(DRAWER_CONTROLLER_SCRIPT)) }
|
||||
|
||||
@@ -2,25 +2,6 @@
|
||||
|
||||
use maud::{Markup, html};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum EmptyStateVariant {
|
||||
Empty,
|
||||
Loading,
|
||||
Error,
|
||||
}
|
||||
|
||||
pub fn empty_state(variant: EmptyStateVariant, content: Markup) -> Markup {
|
||||
let classes = match variant {
|
||||
EmptyStateVariant::Empty | EmptyStateVariant::Loading => {
|
||||
"text-neutral-500 text-center py-8"
|
||||
}
|
||||
EmptyStateVariant::Error => "text-red-600 text-center py-8",
|
||||
};
|
||||
html! {
|
||||
div class=(classes) { (content) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty_state_full(icon: Option<Markup>, title: &str, description: Option<&str>) -> Markup {
|
||||
html! {
|
||||
div class="flex flex-col items-center justify-center py-12 text-center" {
|
||||
@@ -62,11 +43,3 @@ pub fn not_found_state(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty_state_text(message: &str) -> Markup {
|
||||
html! {
|
||||
div class="text-neutral-500 text-center py-8" {
|
||||
(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,14 +9,3 @@ pub fn error_alert(error: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_card(title: &str, message: &str) -> Markup {
|
||||
html! {
|
||||
div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm sm:p-6" {
|
||||
div class="flex flex-col gap-4" {
|
||||
h3 class="text-base font-semibold text-gray-900" { (title) }
|
||||
p class="text-sm text-neutral-500" { (message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,10 +151,6 @@ pub fn danger_button(label: &str) -> Markup {
|
||||
button_markup(label, "submit", DANGER_BUTTON_CLASS)
|
||||
}
|
||||
|
||||
pub fn secondary_button(label: &str) -> Markup {
|
||||
button_markup(label, "button", SECONDARY_BUTTON_CLASS)
|
||||
}
|
||||
|
||||
pub fn form_field_group(
|
||||
label: &str,
|
||||
name: &str,
|
||||
@@ -222,7 +218,7 @@ pub fn checkbox(name: &str, value: &str, label: &str, checked: bool, enabled: bo
|
||||
div class="checkbox-custom" {
|
||||
(PreEscaped(CHECKMARK_SVG))
|
||||
}
|
||||
span class="min-w-0 break-all text-sm leading-5 text-neutral-900" { (label) }
|
||||
span class="min-w-0 [overflow-wrap:anywhere] text-sm leading-5 text-neutral-900" { (label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,39 +23,6 @@ pub fn paperclip_icon(color: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn checkmark_icon(color: &str) -> Markup {
|
||||
html! {
|
||||
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"
|
||||
class={"inline-block h-4 w-4 " (color)}
|
||||
{
|
||||
(PreEscaped(concat!(
|
||||
r#"<rect width="256" height="256" fill="none"/>"#,
|
||||
r#"<polyline points="40 144 96 200 224 72" fill="none""#,
|
||||
r#" stroke="currentColor" stroke-linecap="round""#,
|
||||
r#" stroke-linejoin="round" stroke-width="24"/>"#,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn x_icon(color: &str) -> Markup {
|
||||
html! {
|
||||
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"
|
||||
class={"inline-block h-4 w-4 " (color)}
|
||||
{
|
||||
(PreEscaped(concat!(
|
||||
r#"<rect width="256" height="256" fill="none"/>"#,
|
||||
r#"<line x1="200" y1="56" x2="56" y2="200" fill="none""#,
|
||||
r#" stroke="currentColor" stroke-linecap="round""#,
|
||||
r#" stroke-linejoin="round" stroke-width="24"/>"#,
|
||||
r#"<line x1="200" y1="200" x2="56" y2="56" fill="none""#,
|
||||
r#" stroke="currentColor" stroke-linecap="round""#,
|
||||
r#" stroke-linejoin="round" stroke-width="24"/>"#,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close_icon() -> Markup {
|
||||
html! {
|
||||
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||
@@ -82,63 +49,3 @@ pub fn spinner_icon() -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn search_icon() -> Markup {
|
||||
html! {
|
||||
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||
class="h-4 w-4" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
{
|
||||
(PreEscaped(concat!(
|
||||
r#"<circle cx="11" cy="11" r="8"/>"#,
|
||||
r#"<line x1="21" y1="21" x2="16.65" y2="16.65"/>"#,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn chevron_right_icon() -> Markup {
|
||||
html! {
|
||||
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||
class="h-4 w-4" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
{
|
||||
(PreEscaped(r#"<polyline points="9 18 15 12 9 6"/>"#))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn external_link_icon() -> Markup {
|
||||
html! {
|
||||
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||
class="h-3.5 w-3.5" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
{
|
||||
(PreEscaped(concat!(
|
||||
r#"<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8"#,
|
||||
r#"a2 2 0 0 1 2-2h6"/>"#,
|
||||
r#"<polyline points="15 3 21 3 21 9"/>"#,
|
||||
r#"<line x1="10" y1="14" x2="21" y2="3"/>"#,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn copy_icon() -> Markup {
|
||||
html! {
|
||||
svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
||||
class="h-3.5 w-3.5" fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
{
|
||||
(PreEscaped(concat!(
|
||||
r#"<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>"#,
|
||||
r#"<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9"#,
|
||||
r#"a2 2 0 0 1 2 2v1"/>"#,
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,9 +12,14 @@ pub struct Attachment {
|
||||
pub width: Option<u32>,
|
||||
pub height: Option<u32>,
|
||||
pub size: Option<u64>,
|
||||
pub ncmec_status: String,
|
||||
pub ncmec_report_id: Option<String>,
|
||||
pub ncmec_failure_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct MissingAttachment {
|
||||
pub id: String,
|
||||
pub filename: String,
|
||||
pub content_type: Option<String>,
|
||||
pub size: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
@@ -27,12 +32,15 @@ pub struct Message {
|
||||
pub author_global_name: Option<String>,
|
||||
pub author_discriminator: String,
|
||||
pub author_avatar: Option<String>,
|
||||
pub webhook_id: Option<String>,
|
||||
pub author_bot: Option<bool>,
|
||||
pub channel_id: String,
|
||||
pub channel_nsfw: Option<bool>,
|
||||
pub channel_content_warning_level: Option<i32>,
|
||||
pub channel_content_warning_text: Option<String>,
|
||||
pub guild_nsfw: Option<bool>,
|
||||
pub attachments: Vec<Attachment>,
|
||||
pub missing_attachments: Vec<MissingAttachment>,
|
||||
}
|
||||
|
||||
pub fn ordered_messages(values: &[Value]) -> Vec<Message> {
|
||||
@@ -48,6 +56,12 @@ fn message_from_value(value: &Value) -> Message {
|
||||
.flatten()
|
||||
.map(attachment_from_value)
|
||||
.collect();
|
||||
let missing_attachments = value["missing_attachments"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(missing_attachment_from_value)
|
||||
.collect();
|
||||
Message {
|
||||
id: value_id(&value["id"]).unwrap_or_default(),
|
||||
content: value["content"].as_str().unwrap_or("").to_owned(),
|
||||
@@ -61,6 +75,8 @@ fn message_from_value(value: &Value) -> Message {
|
||||
author_discriminator: value_id(&value["author_discriminator"])
|
||||
.unwrap_or_else(|| "0000".to_owned()),
|
||||
author_avatar: value["author_avatar"].as_str().map(ToOwned::to_owned),
|
||||
webhook_id: value_id(&value["webhook_id"]),
|
||||
author_bot: value["author_bot"].as_bool(),
|
||||
channel_id: value_id(&value["channel_id"]).unwrap_or_default(),
|
||||
channel_nsfw: value["channel_nsfw"].as_bool(),
|
||||
channel_content_warning_level: value["channel_content_warning_level"]
|
||||
@@ -71,6 +87,16 @@ fn message_from_value(value: &Value) -> Message {
|
||||
.map(ToOwned::to_owned),
|
||||
guild_nsfw: value["guild_nsfw"].as_bool(),
|
||||
attachments,
|
||||
missing_attachments,
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_attachment_from_value(value: &Value) -> MissingAttachment {
|
||||
MissingAttachment {
|
||||
id: value_id(&value["id"]).unwrap_or_default(),
|
||||
filename: value["filename"].as_str().unwrap_or("").to_owned(),
|
||||
content_type: value["content_type"].as_str().map(ToOwned::to_owned),
|
||||
size: value["size"].as_u64(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,14 +110,6 @@ fn attachment_from_value(value: &Value) -> Attachment {
|
||||
width: value["width"].as_u64().map(|n| n as u32),
|
||||
height: value["height"].as_u64().map(|n| n as u32),
|
||||
size: value["size"].as_u64(),
|
||||
ncmec_status: value["ncmec_status"]
|
||||
.as_str()
|
||||
.unwrap_or("not_submitted")
|
||||
.to_owned(),
|
||||
ncmec_report_id: value["ncmec_report_id"].as_str().map(ToOwned::to_owned),
|
||||
ncmec_failure_reason: value["ncmec_failure_reason"]
|
||||
.as_str()
|
||||
.map(ToOwned::to_owned),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,10 +157,7 @@ mod tests {
|
||||
"content_type": "image/png",
|
||||
"width": 640,
|
||||
"height": 480,
|
||||
"size": 4096,
|
||||
"ncmec_status": "submitted",
|
||||
"ncmec_report_id": "report-id",
|
||||
"ncmec_failure_reason": "previous failure"
|
||||
"size": 4096
|
||||
}]
|
||||
});
|
||||
|
||||
@@ -157,6 +172,8 @@ mod tests {
|
||||
author_global_name: Some("Alice".into()),
|
||||
author_discriminator: "1234".into(),
|
||||
author_avatar: Some("avatar-hash".into()),
|
||||
webhook_id: None,
|
||||
author_bot: None,
|
||||
channel_id: "100".into(),
|
||||
channel_nsfw: Some(false),
|
||||
channel_content_warning_level: Some(2),
|
||||
@@ -171,14 +188,70 @@ mod tests {
|
||||
width: Some(640),
|
||||
height: Some(480),
|
||||
size: Some(4096),
|
||||
ncmec_status: "submitted".into(),
|
||||
ncmec_report_id: Some("report-id".into()),
|
||||
ncmec_failure_reason: Some("previous failure".into()),
|
||||
}],
|
||||
missing_attachments: vec![],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_author_bot_flags() {
|
||||
let bot = message_from_value(&json!({"author_id": "42", "author_bot": true}));
|
||||
assert_eq!(bot.author_bot, Some(true));
|
||||
let human = message_from_value(&json!({"author_id": "43", "author_bot": false}));
|
||||
assert_eq!(human.author_bot, Some(false));
|
||||
let webhook = message_from_value(
|
||||
&json!({"author_id": "500", "webhook_id": "500", "author_bot": null}),
|
||||
);
|
||||
assert_eq!(webhook.author_bot, None);
|
||||
let old = message_from_value(&json!({"author_id": "44"}));
|
||||
assert_eq!(old.author_bot, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_missing_attachments() {
|
||||
let message = message_from_value(&json!({
|
||||
"id": "10",
|
||||
"attachments": [],
|
||||
"missing_attachments": [{
|
||||
"id": 9007199254740993_u64,
|
||||
"filename": "evidence.png",
|
||||
"nsfw": null,
|
||||
"content_type": "image/png",
|
||||
"width": 640,
|
||||
"height": 480,
|
||||
"size": 4096
|
||||
}]
|
||||
}));
|
||||
assert!(message.attachments.is_empty());
|
||||
assert_eq!(
|
||||
message.missing_attachments,
|
||||
vec![MissingAttachment {
|
||||
id: "9007199254740993".into(),
|
||||
filename: "evidence.png".into(),
|
||||
content_type: Some("image/png".into()),
|
||||
size: Some(4096),
|
||||
}]
|
||||
);
|
||||
let old = message_from_value(&json!({"id": "11", "attachments": []}));
|
||||
assert!(old.missing_attachments.is_empty());
|
||||
let null = message_from_value(&json!({"id": "12", "missing_attachments": null}));
|
||||
assert!(null.missing_attachments.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_webhook_authors() {
|
||||
let webhook = message_from_value(&json!({
|
||||
"author_id": "500",
|
||||
"author_username": "Harbor Bulletin",
|
||||
"webhook_id": "500"
|
||||
}));
|
||||
assert_eq!(webhook.webhook_id.as_deref(), Some("500"));
|
||||
assert_eq!(webhook.author_id, "500");
|
||||
let user = message_from_value(&json!({"author_id": "42", "webhook_id": null}));
|
||||
assert_eq!(user.webhook_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retains_display_defaults_for_incomplete_snapshots() {
|
||||
let message = message_from_value(&json!({"attachments": [{}]}));
|
||||
@@ -187,6 +260,7 @@ mod tests {
|
||||
assert_eq!(message.author_discriminator, "0000");
|
||||
assert_eq!(message.content, "");
|
||||
assert_eq!(message.author_global_name, None);
|
||||
assert_eq!(message.webhook_id, None);
|
||||
assert_eq!(message.channel_nsfw, None);
|
||||
assert_eq!(
|
||||
message.attachments,
|
||||
@@ -199,9 +273,6 @@ mod tests {
|
||||
width: None,
|
||||
height: None,
|
||||
size: None,
|
||||
ncmec_status: "not_submitted".into(),
|
||||
ncmec_report_id: None,
|
||||
ncmec_failure_reason: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
|
||||
use super::badge::{BadgeVariant, badge};
|
||||
use super::icons::paperclip_icon;
|
||||
use super::media::user_avatar_url;
|
||||
use super::nsfw_indicators::{attachment_nsfw_badge, channel_nsfw_state_badge};
|
||||
use super::user_display::format_user_display;
|
||||
use crate::config::AdminConfig;
|
||||
use crate::routes::auth::json_string;
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
|
||||
use super::message_data::{Attachment, Message};
|
||||
|
||||
@@ -17,33 +19,6 @@ fn is_image(att: &Attachment) -> bool {
|
||||
.is_some_and(|ct| ct.starts_with("image/"))
|
||||
}
|
||||
|
||||
fn ncmec_badge(att: &Attachment) -> Markup {
|
||||
match att.ncmec_status.as_str() {
|
||||
"submitted" => {
|
||||
let label = att
|
||||
.ncmec_report_id
|
||||
.as_deref()
|
||||
.map(|id| format!("NCMEC {id}"))
|
||||
.unwrap_or_else(|| "Reported to NCMEC".into());
|
||||
html! {
|
||||
span class="rounded bg-green-100 px-2 py-0.5 text-[11px] text-green-800"
|
||||
title=(label) { (label) }
|
||||
}
|
||||
}
|
||||
"failed" => {
|
||||
let title = att
|
||||
.ncmec_failure_reason
|
||||
.as_deref()
|
||||
.unwrap_or("NCMEC report failed");
|
||||
html! {
|
||||
span class="rounded bg-red-100 px-2 py-0.5 text-[11px] text-red-800"
|
||||
title=(title) { "NCMEC Failed" }
|
||||
}
|
||||
}
|
||||
_ => html! {},
|
||||
}
|
||||
}
|
||||
|
||||
fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
|
||||
let mut images = msg.attachments.iter().filter(|a| is_image(a)).peekable();
|
||||
if images.peek().is_none() {
|
||||
@@ -70,7 +45,6 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
|
||||
a href=(att.url) target="_blank" rel="noopener noreferrer"
|
||||
class="font-medium text-blue-600 hover:underline" { (att.filename) }
|
||||
(attachment_nsfw_badge(att.nsfw.unwrap_or(false)))
|
||||
(ncmec_badge(att))
|
||||
}
|
||||
@if include_delete {
|
||||
div class="flex flex-wrap gap-2" {
|
||||
@@ -80,32 +54,6 @@ fn render_image_attachments(msg: &Message, include_delete: bool) -> Markup {
|
||||
transition-colors hover:bg-red-50 hover:text-red-700"
|
||||
data-channel-id=(msg.channel_id)
|
||||
data-message-id=(msg.id) { "Delete" }
|
||||
button type="button"
|
||||
class="ncmec-report-btn rounded bg-white px-2.5 py-1 text-xs \
|
||||
shadow-sm ring-1 ring-neutral-200 transition-colors \
|
||||
hover:bg-neutral-100 disabled:cursor-not-allowed \
|
||||
disabled:opacity-70"
|
||||
data-channel-id=(msg.channel_id)
|
||||
data-message-id=(msg.id)
|
||||
data-attachment-id=(att.id)
|
||||
data-filename=(att.filename)
|
||||
data-content-type=(att.content_type.as_deref().unwrap_or(""))
|
||||
data-size=(att.size.map(|s| s.to_string()).unwrap_or_default())
|
||||
data-author-id=(msg.author_id)
|
||||
data-ncmec-status=(att.ncmec_status)
|
||||
data-ncmec-report-id=(att.ncmec_report_id.as_deref().unwrap_or(""))
|
||||
disabled[att.ncmec_status == "submitted"]
|
||||
title=(if att.ncmec_status == "submitted" {
|
||||
"Already reported to NCMEC"
|
||||
} else {
|
||||
"Report this image to NCMEC"
|
||||
}) {
|
||||
@if att.ncmec_status == "submitted" {
|
||||
"Reported to NCMEC"
|
||||
} @else {
|
||||
"Report to NCMEC"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,7 +81,29 @@ fn render_other_attachments(msg: &Message, has_content_or_images: bool) -> Marku
|
||||
a href=(att.url) target="_blank" rel="noopener noreferrer"
|
||||
class="text-blue-600 hover:underline" { (att.filename) }
|
||||
(attachment_nsfw_badge(att.nsfw.unwrap_or(false)))
|
||||
(ncmec_badge(att))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_missing_attachments(msg: &Message) -> Markup {
|
||||
if msg.missing_attachments.is_empty() {
|
||||
return html! {};
|
||||
}
|
||||
html! {
|
||||
div class="mt-1.5 space-y-1" {
|
||||
@for att in &msg.missing_attachments {
|
||||
div class="flex flex-wrap items-center gap-2 text-amber-800 text-xs"
|
||||
data-missing-attachment=(att.id) {
|
||||
(paperclip_icon("text-amber-500"))
|
||||
span class="break-words" {
|
||||
@if att.filename.is_empty() {
|
||||
"An attachment was not preserved in the report snapshot"
|
||||
} @else {
|
||||
(att.filename) " was not preserved in the report snapshot"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,6 +149,10 @@ fn message_row(
|
||||
(render_image_attachments(msg, include_delete))
|
||||
(render_other_attachments(msg, has_content_or_images))
|
||||
}
|
||||
(render_missing_attachments(msg))
|
||||
div class="mt-1 text-neutral-400 text-xs" {
|
||||
span class="" { (msg.id) }
|
||||
}
|
||||
@if include_delete && !has_images {
|
||||
div class="absolute top-0 right-2 hidden group-hover:block" {
|
||||
button type="button"
|
||||
@@ -208,20 +182,39 @@ fn message_row(
|
||||
style="display:grid;grid-template-columns:16px 40px 16px minmax(0,1fr);"
|
||||
data-message-id=(msg.id) data-message-row="" {
|
||||
div style="grid-row:1;grid-column:1;" {}
|
||||
a href={(base_path) "/users/" (msg.author_id)}
|
||||
title=(msg.author_id) class="block flex-shrink-0"
|
||||
style="grid-row:1;grid-column:2;align-self:start;" {
|
||||
img src=(avatar_url) alt=(msg.author_username)
|
||||
class="rounded-full" style="width:40px;height:40px;";
|
||||
@if let Some(webhook_id) = &msg.webhook_id {
|
||||
span title=(webhook_id) class="block flex-shrink-0"
|
||||
style="grid-row:1;grid-column:2;align-self:start;" {
|
||||
img src=(avatar_url) alt=(msg.author_username)
|
||||
class="rounded-full" style="width:40px;height:40px;";
|
||||
}
|
||||
} @else {
|
||||
a href={(base_path) "/users/" (msg.author_id)}
|
||||
title=(msg.author_id) class="block flex-shrink-0"
|
||||
style="grid-row:1;grid-column:2;align-self:start;" {
|
||||
img src=(avatar_url) alt=(msg.author_username)
|
||||
class="rounded-full" style="width:40px;height:40px;";
|
||||
}
|
||||
}
|
||||
div style="grid-row:1;grid-column:3;" {}
|
||||
div class="min-w-0" style="grid-column:4;" {
|
||||
div class="flex items-baseline gap-2" {
|
||||
a href={(base_path) "/users/" (msg.author_id)}
|
||||
class="font-medium text-neutral-900 text-sm hover:underline"
|
||||
title=(msg.author_id) { (tag) }
|
||||
@if let Some(webhook_id) = &msg.webhook_id {
|
||||
span class="font-medium text-neutral-900 text-sm"
|
||||
title=(webhook_id) data-message-webhook=(webhook_id) { (msg.author_username) }
|
||||
span class="self-center" { (badge("Webhook", BadgeVariant::Info)) }
|
||||
} @else {
|
||||
a href={(base_path) "/users/" (msg.author_id)}
|
||||
class="font-medium text-neutral-900 text-sm hover:underline"
|
||||
title=(msg.author_id) { (tag) }
|
||||
@if msg.author_bot == Some(true) {
|
||||
span class="self-center" data-message-author-bot=(msg.author_id) {
|
||||
(badge("Bot", BadgeVariant::Info))
|
||||
}
|
||||
}
|
||||
}
|
||||
span class="text-neutral-400 text-xs" {
|
||||
" \u{2014} " (msg.timestamp)
|
||||
" \u{2014} " (format_admin_timestamp(&msg.timestamp))
|
||||
}
|
||||
(channel_nsfw_state_badge(
|
||||
msg.channel_nsfw.unwrap_or(false),
|
||||
@@ -239,6 +232,7 @@ fn message_row(
|
||||
(render_image_attachments(msg, include_delete))
|
||||
(render_other_attachments(msg, has_content_or_images))
|
||||
}
|
||||
(render_missing_attachments(msg))
|
||||
div class="mt-1 text-neutral-400 text-xs" {
|
||||
span class="" { (msg.id) }
|
||||
}
|
||||
@@ -267,7 +261,9 @@ pub fn message_list(
|
||||
html! {
|
||||
div class="divide-y-0" {
|
||||
@for (i, msg) in messages.iter().enumerate() {
|
||||
@let is_grouped = i > 0 && messages[i - 1].author_id == msg.author_id;
|
||||
@let is_grouped = i > 0
|
||||
&& messages[i - 1].author_id == msg.author_id
|
||||
&& messages[i - 1].author_username == msg.author_username;
|
||||
@let is_highlighted = highlight_message_id == Some(msg.id.as_str());
|
||||
@let avatar_url = user_avatar_url(
|
||||
config, &msg.author_id, msg.author_avatar.as_deref(), 160, true,
|
||||
@@ -322,37 +318,6 @@ pub fn message_deletion_script(csrf_token: &str) -> Markup {
|
||||
toast('error', 'Failed to delete message.');
|
||||
});
|
||||
}
|
||||
function reportNcmec(b) {
|
||||
var name = prompt('Type your full name to confirm you personally viewed this image and want to submit it to NCMEC.');
|
||||
if (!name || !name.trim()) return;
|
||||
var fields = new URLSearchParams();
|
||||
fields.append('channel_id', b.dataset.channelId || '');
|
||||
fields.append('message_id', b.dataset.messageId || '');
|
||||
fields.append('attachment_id', b.dataset.attachmentId || '');
|
||||
fields.append('filename', b.dataset.filename || '');
|
||||
fields.append('reporter_full_name', name.trim());
|
||||
fields.append('confirmed_viewed', 'true');
|
||||
b.disabled = true;
|
||||
b.textContent = 'Reporting...';
|
||||
toast('info', 'Submitting NCMEC report...');
|
||||
post('report-to-ncmec', fields).then(function(r) {
|
||||
return r.json().catch(function() {
|
||||
return null;
|
||||
}).then(function(data) {
|
||||
if (!r.ok || !data || data.success !== true) throw new Error(data && (data.error || data.message) || 'Failed to report attachment to NCMEC');
|
||||
return data;
|
||||
});
|
||||
}).then(function(data) {
|
||||
b.textContent = 'Reported to NCMEC';
|
||||
b.dataset.ncmecStatus = 'submitted';
|
||||
if (data.ncmec_report_id) b.dataset.ncmecReportId = data.ncmec_report_id;
|
||||
toast('success', 'NCMEC report submitted.');
|
||||
}).catch(function(err) {
|
||||
b.disabled = false;
|
||||
b.textContent = 'Report to NCMEC';
|
||||
toast('error', err && err.message ? err.message : 'Failed to report attachment to NCMEC.');
|
||||
});
|
||||
}
|
||||
document.addEventListener('click', function(e) {
|
||||
var t = e.target;
|
||||
if (!(t instanceof HTMLElement)) return;
|
||||
@@ -360,12 +325,6 @@ pub fn message_deletion_script(csrf_token: &str) -> Markup {
|
||||
if (d instanceof HTMLButtonElement) {
|
||||
e.preventDefault();
|
||||
deleteMessage(d);
|
||||
return;
|
||||
}
|
||||
var n = t.closest('.ncmec-report-btn');
|
||||
if (n instanceof HTMLButtonElement && !n.disabled) {
|
||||
e.preventDefault();
|
||||
reportNcmec(n);
|
||||
}
|
||||
});
|
||||
})();"#
|
||||
|
||||
@@ -17,12 +17,12 @@ pub mod message_data;
|
||||
pub mod message_list;
|
||||
pub mod nsfw_indicators;
|
||||
pub mod page_container;
|
||||
pub mod report_category;
|
||||
pub mod report_webhook;
|
||||
pub mod resource_link;
|
||||
pub mod section_card;
|
||||
pub mod stack;
|
||||
pub mod table;
|
||||
pub mod tooltip;
|
||||
pub mod typography;
|
||||
pub mod user_display;
|
||||
pub mod user_profile_badges;
|
||||
pub mod voice;
|
||||
|
||||
@@ -2,21 +2,6 @@
|
||||
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
|
||||
pub fn page_container(spacing: &str, content: Markup) -> Markup {
|
||||
let class = match spacing {
|
||||
"sm" => "space-y-4",
|
||||
"lg" => "space-y-8",
|
||||
_ => "space-y-6",
|
||||
};
|
||||
html! {
|
||||
div class=(class) { (content) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn page_container_md(content: Markup) -> Markup {
|
||||
page_container("md", content)
|
||||
}
|
||||
|
||||
pub fn page_header(title: &str, description: Option<&str>) -> Markup {
|
||||
page_header_full(title, description, None, None, None, maud::html! {})
|
||||
}
|
||||
@@ -122,25 +107,3 @@ pub fn detail_row(label: &str, value: Markup) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tabs(tabs: &[(&str, &str, bool)], base_url: &str) -> Markup {
|
||||
html! {
|
||||
div class="border-b border-neutral-200 mb-6" {
|
||||
nav class="-mb-px flex gap-6" aria-label="Tabs" {
|
||||
@for (id, label, active) in tabs {
|
||||
@let classes = if *active {
|
||||
"border-brand-primary text-brand-primary whitespace-nowrap border-b-2 \
|
||||
pb-3 pt-1 text-sm font-medium"
|
||||
} else {
|
||||
"border-transparent text-neutral-500 hover:border-neutral-300 \
|
||||
hover:text-neutral-700 whitespace-nowrap border-b-2 pb-3 pt-1 \
|
||||
text-sm font-medium"
|
||||
};
|
||||
a href={(base_url) "?tab=" (id)} class=(classes) {
|
||||
(label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use maud::{Markup, html};
|
||||
|
||||
const REPORT_CATEGORIES: &[(&str, &str)] = &[
|
||||
("harassment", "Harassment or bullying"),
|
||||
("hate_speech", "Hate speech"),
|
||||
("spam", "Spam"),
|
||||
("illegal_activity", "Illegal activity"),
|
||||
("impersonation", "Impersonation"),
|
||||
("child_safety", "Child safety concerns"),
|
||||
("other", "Other"),
|
||||
("violent_content", "Violent or graphic content"),
|
||||
("nsfw_violation", "NSFW policy violation"),
|
||||
("doxxing", "Sharing personal information"),
|
||||
("self_harm", "Self-harm or suicide"),
|
||||
("malicious_links", "Malicious links"),
|
||||
("spam_account", "Spam account"),
|
||||
("underage_user", "Underage user"),
|
||||
("inappropriate_profile", "Inappropriate profile"),
|
||||
("raid_coordination", "Raid coordination"),
|
||||
("malware_distribution", "Malware distribution"),
|
||||
("extremist_community", "Extremist community"),
|
||||
];
|
||||
|
||||
pub fn report_category_label(category: &str) -> &str {
|
||||
REPORT_CATEGORIES
|
||||
.iter()
|
||||
.find_map(|(key, label)| (*key == category).then_some(*label))
|
||||
.unwrap_or(category)
|
||||
}
|
||||
|
||||
pub fn reason_repeats_category(category: &str, reason_label: &str) -> bool {
|
||||
report_category_label(category).trim().to_lowercase() == reason_label.trim().to_lowercase()
|
||||
}
|
||||
|
||||
pub fn report_category_options(selected: &str) -> Vec<(&str, &str)> {
|
||||
let mut options = std::iter::once(("", "All"))
|
||||
.chain(REPORT_CATEGORIES.iter().copied())
|
||||
.collect::<Vec<_>>();
|
||||
if !options.iter().any(|(key, _)| *key == selected) {
|
||||
options.push((selected, selected));
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
pub fn report_category(category: &str) -> Markup {
|
||||
html! {
|
||||
span data-report-category=(category) title=(category) {
|
||||
(report_category_label(category))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn known_categories_get_their_label() {
|
||||
assert_eq!(
|
||||
report_category_label("harassment"),
|
||||
"Harassment or bullying"
|
||||
);
|
||||
assert_eq!(report_category_label("spam"), "Spam");
|
||||
assert_eq!(report_category_label("spam_account"), "Spam account");
|
||||
assert_eq!(
|
||||
report_category_label("extremist_community"),
|
||||
"Extremist community"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_categories_fall_back_to_the_key() {
|
||||
assert_eq!(report_category_label("future_value"), "future_value");
|
||||
assert_eq!(report_category_label(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasons_that_repeat_the_category_label_are_detected() {
|
||||
assert!(reason_repeats_category("spam", "Spam"));
|
||||
assert!(reason_repeats_category("spam", "spam"));
|
||||
assert!(reason_repeats_category(
|
||||
"harassment",
|
||||
"Harassment or bullying"
|
||||
));
|
||||
assert!(reason_repeats_category(
|
||||
"harassment",
|
||||
" harassment OR bullying "
|
||||
));
|
||||
assert!(!reason_repeats_category("harassment", "Hate speech"));
|
||||
assert!(!reason_repeats_category(
|
||||
"child_safety",
|
||||
"Child sexual abuse material"
|
||||
));
|
||||
assert!(reason_repeats_category("future_value", "future_value"));
|
||||
assert!(!reason_repeats_category("future_value", "Spam"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_category_key_is_unique_and_labelled() {
|
||||
let mut keys = REPORT_CATEGORIES
|
||||
.iter()
|
||||
.map(|(key, _)| *key)
|
||||
.collect::<Vec<_>>();
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
assert_eq!(keys.len(), REPORT_CATEGORIES.len());
|
||||
assert_eq!(keys.len(), 18);
|
||||
for (key, label) in REPORT_CATEGORIES {
|
||||
assert_ne!(key, label);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options_start_with_all_and_keep_an_unknown_selection() {
|
||||
let options = report_category_options("");
|
||||
assert_eq!(options.first(), Some(&("", "All")));
|
||||
assert_eq!(options.len(), 19);
|
||||
assert_eq!(report_category_options("spam").len(), 19);
|
||||
let unknown = report_category_options("future_value");
|
||||
assert_eq!(unknown.len(), 20);
|
||||
assert_eq!(unknown.last(), Some(&("future_value", "future_value")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_markup_shows_the_label_and_keeps_the_key() {
|
||||
let markup = report_category("doxxing").into_string();
|
||||
assert!(
|
||||
markup.contains(r#"data-report-category="doxxing""#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(">Sharing personal information<"),
|
||||
"{markup}"
|
||||
);
|
||||
let unknown = report_category("future_value").into_string();
|
||||
assert!(
|
||||
unknown.contains(r#"data-report-category="future_value""#),
|
||||
"{unknown}"
|
||||
);
|
||||
assert!(unknown.contains(">future_value<"), "{unknown}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use maud::{Markup, html};
|
||||
|
||||
use super::badge::{BadgeVariant, badge};
|
||||
use super::media::user_avatar_url;
|
||||
use crate::{
|
||||
api::types::ReportEntry,
|
||||
config::AdminConfig,
|
||||
utils::{timestamps::format_admin_timestamp, user_tag::user_tag},
|
||||
};
|
||||
|
||||
pub struct ReportedWebhook<'a> {
|
||||
pub id: &'a str,
|
||||
pub name: String,
|
||||
pub configured_name: Option<&'a str>,
|
||||
pub configured_avatar_url: Option<String>,
|
||||
pub avatar_url: String,
|
||||
pub reports_href: String,
|
||||
pub kind: Option<String>,
|
||||
pub created_at: Option<String>,
|
||||
pub channel_id: Option<&'a str>,
|
||||
pub guild_id: Option<&'a str>,
|
||||
pub application: Option<WebhookApplication<'a>>,
|
||||
pub record_deleted: bool,
|
||||
pub creator: Option<WebhookCreator<'a>>,
|
||||
}
|
||||
|
||||
pub struct WebhookApplication<'a> {
|
||||
pub id: &'a str,
|
||||
pub href: String,
|
||||
}
|
||||
|
||||
pub struct WebhookCreator<'a> {
|
||||
pub id: &'a str,
|
||||
pub label: String,
|
||||
pub avatar_url: String,
|
||||
pub href: String,
|
||||
pub account_deleted: bool,
|
||||
pub bot: bool,
|
||||
}
|
||||
|
||||
pub fn webhook_type_label(webhook_type: i32) -> String {
|
||||
match webhook_type {
|
||||
1 => "Incoming".to_owned(),
|
||||
2 => "Channel Follower".to_owned(),
|
||||
other => format!("Type {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn non_blank(value: Option<&str>) -> Option<&str> {
|
||||
value.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
pub fn reported_webhook<'a>(
|
||||
config: &AdminConfig,
|
||||
report: &'a ReportEntry,
|
||||
) -> Option<ReportedWebhook<'a>> {
|
||||
let id = report.reported_webhook_id.as_deref()?;
|
||||
let name = non_blank(report.reported_webhook_name.as_deref())
|
||||
.map_or_else(|| format!("Webhook {id}"), str::to_owned);
|
||||
let configured_name = non_blank(report.reported_webhook_default_name.as_deref())
|
||||
.filter(|configured| *configured != name);
|
||||
let channel_id = non_blank(report.reported_webhook_channel_id.as_deref());
|
||||
let guild_id = non_blank(report.reported_webhook_guild_id.as_deref());
|
||||
let record_present = report.reported_webhook_type.is_some()
|
||||
|| channel_id.is_some()
|
||||
|| guild_id.is_some()
|
||||
|| non_blank(report.reported_webhook_default_name.as_deref()).is_some();
|
||||
let configured_avatar_hash = non_blank(report.reported_webhook_default_avatar_hash.as_deref());
|
||||
let configured_avatar_url = (record_present
|
||||
&& configured_avatar_hash != non_blank(report.reported_webhook_avatar_hash.as_deref()))
|
||||
.then(|| user_avatar_url(config, id, configured_avatar_hash, 80, true));
|
||||
let created_at = non_blank(report.reported_webhook_created_at.as_deref());
|
||||
Some(ReportedWebhook {
|
||||
id,
|
||||
name,
|
||||
configured_name,
|
||||
configured_avatar_url,
|
||||
avatar_url: user_avatar_url(
|
||||
config,
|
||||
id,
|
||||
report.reported_webhook_avatar_hash.as_deref(),
|
||||
80,
|
||||
true,
|
||||
),
|
||||
reports_href: format!(
|
||||
"{}/reports?reported_webhook_id={}",
|
||||
config.base_path,
|
||||
urlencoding::encode(id)
|
||||
),
|
||||
kind: report.reported_webhook_type.map(webhook_type_label),
|
||||
created_at: created_at.map(format_admin_timestamp),
|
||||
channel_id,
|
||||
guild_id,
|
||||
application: non_blank(report.reported_webhook_application_id.as_deref()).map(|id| {
|
||||
WebhookApplication {
|
||||
id,
|
||||
href: format!(
|
||||
"{}/applications/{}",
|
||||
config.base_path,
|
||||
urlencoding::encode(id)
|
||||
),
|
||||
}
|
||||
}),
|
||||
record_deleted: created_at.is_some() && !record_present,
|
||||
creator: webhook_creator(config, report),
|
||||
})
|
||||
}
|
||||
|
||||
fn webhook_creator<'a>(
|
||||
config: &AdminConfig,
|
||||
report: &'a ReportEntry,
|
||||
) -> Option<WebhookCreator<'a>> {
|
||||
let id = report.reported_webhook_creator_id.as_deref()?;
|
||||
let username = non_blank(report.reported_webhook_creator_username.as_deref());
|
||||
let bot = report.reported_webhook_application_id.as_deref() == Some(id);
|
||||
let tag = match username {
|
||||
Some(username) => Some(user_tag(
|
||||
username,
|
||||
report
|
||||
.reported_webhook_creator_discriminator
|
||||
.as_deref()
|
||||
.unwrap_or("0000"),
|
||||
bot,
|
||||
)),
|
||||
None => non_blank(report.reported_webhook_creator_tag.as_deref()).map(str::to_owned),
|
||||
};
|
||||
let label = match (
|
||||
non_blank(report.reported_webhook_creator_global_name.as_deref()),
|
||||
tag,
|
||||
) {
|
||||
(Some(display), Some(tag)) => format!("{display} ({tag})"),
|
||||
(_, Some(tag)) => tag,
|
||||
(Some(display), None) => display.to_owned(),
|
||||
(None, None) => format!("User {id}"),
|
||||
};
|
||||
Some(WebhookCreator {
|
||||
id,
|
||||
label,
|
||||
avatar_url: user_avatar_url(
|
||||
config,
|
||||
id,
|
||||
report.reported_webhook_creator_avatar_hash.as_deref(),
|
||||
80,
|
||||
true,
|
||||
),
|
||||
href: format!("{}/users/{}", config.base_path, urlencoding::encode(id)),
|
||||
account_deleted: username.is_none()
|
||||
&& non_blank(report.reported_webhook_creator_tag.as_deref()).is_none()
|
||||
&& non_blank(report.reported_webhook_creator_global_name.as_deref()).is_none(),
|
||||
bot,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn webhook_identity(webhook: &ReportedWebhook<'_>) -> Markup {
|
||||
html! {
|
||||
span class="inline-flex min-w-0 items-center gap-2" data-report-webhook=(webhook.id) {
|
||||
img src=(webhook.avatar_url) alt=(format!("{}'s avatar", webhook.name))
|
||||
class="h-8 w-8 flex-shrink-0 rounded-full object-cover";
|
||||
span class="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5" {
|
||||
span class="font-medium text-neutral-900 text-sm break-words" { (webhook.name) }
|
||||
(badge("Webhook", BadgeVariant::Info))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn webhook_creator_link(creator: &WebhookCreator<'_>) -> Markup {
|
||||
html! {
|
||||
a href=(creator.href) title=(creator.id) data-report-webhook-creator=(creator.id)
|
||||
class="inline-flex min-w-0 items-center gap-2 text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
|
||||
img src=(creator.avatar_url) alt=(format!("{}'s avatar", creator.label))
|
||||
class="h-8 w-8 flex-shrink-0 rounded-full object-cover";
|
||||
span class="break-words" { (creator.label) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn webhook_creator_badges(creator: &WebhookCreator<'_>) -> Markup {
|
||||
html! {
|
||||
@if creator.bot {
|
||||
span class="inline-flex" data-report-webhook-creator-bot { (badge("Bot", BadgeVariant::Info)) }
|
||||
}
|
||||
@if creator.account_deleted {
|
||||
span class="inline-flex" data-report-webhook-creator-deleted { (badge("Account Deleted", BadgeVariant::Default)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,8 +10,6 @@ pub enum ResourceType {
|
||||
|
||||
const RESOURCE_LINK_CLASS: &str = "text-neutral-900 underline decoration-neutral-300 \
|
||||
hover:text-neutral-600 hover:decoration-neutral-500 text-sm";
|
||||
const NAV_LINK_CLASS: &str = "label rounded-lg border border-neutral-300 bg-white \
|
||||
px-3 py-2 text-neutral-700 transition-colors hover:bg-neutral-50";
|
||||
|
||||
impl ResourceType {
|
||||
fn path_segment(self) -> &'static str {
|
||||
@@ -20,20 +18,6 @@ impl ResourceType {
|
||||
ResourceType::Guild => "guilds",
|
||||
}
|
||||
}
|
||||
|
||||
fn peek_drawer_id(self) -> &'static str {
|
||||
match self {
|
||||
ResourceType::User => "user-peek",
|
||||
ResourceType::Guild => "guild-peek",
|
||||
}
|
||||
}
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
ResourceType::User => "User",
|
||||
ResourceType::Guild => "Guild",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resource_link(
|
||||
@@ -52,40 +36,3 @@ pub fn resource_link(
|
||||
a href=(href) class=(RESOURCE_LINK_CLASS) { (display) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resource_link_peek(
|
||||
base_path: &str,
|
||||
resource_type: ResourceType,
|
||||
resource_id: &str,
|
||||
display: Markup,
|
||||
peek_title: Option<&str>,
|
||||
) -> Markup {
|
||||
let href = format!(
|
||||
"{}/{}/{}",
|
||||
base_path,
|
||||
resource_type.path_segment(),
|
||||
resource_id,
|
||||
);
|
||||
let fragment_href = format!("{}/fragment", href);
|
||||
let title = peek_title
|
||||
.map(|t| t.to_owned())
|
||||
.unwrap_or_else(|| format!("{} {}", resource_type.label(), resource_id));
|
||||
html! {
|
||||
a href=(href)
|
||||
class=(RESOURCE_LINK_CLASS)
|
||||
data-drawer-open=(resource_type.peek_drawer_id())
|
||||
data-drawer-href=(fragment_href)
|
||||
hx-get=(fragment_href)
|
||||
hx-target={"#" (resource_type.peek_drawer_id()) "-body"}
|
||||
hx-swap="innerHTML"
|
||||
data-drawer-title=(title) {
|
||||
(display)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn nav_link(href: &str, content: Markup) -> Markup {
|
||||
html! {
|
||||
a href=(href) class=(NAV_LINK_CLASS) { (content) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use maud::{Markup, html};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum StackGap {
|
||||
Sm,
|
||||
Md,
|
||||
Lg,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum StackAlign {
|
||||
Start,
|
||||
Center,
|
||||
End,
|
||||
Stretch,
|
||||
}
|
||||
|
||||
fn gap_class(gap: StackGap) -> &'static str {
|
||||
match gap {
|
||||
StackGap::Sm => "gap-2",
|
||||
StackGap::Md => "gap-4",
|
||||
StackGap::Lg => "gap-6",
|
||||
}
|
||||
}
|
||||
|
||||
fn align_class(align: StackAlign) -> &'static str {
|
||||
match align {
|
||||
StackAlign::Start => "items-start",
|
||||
StackAlign::Center => "items-center",
|
||||
StackAlign::End => "items-end",
|
||||
StackAlign::Stretch => "items-stretch",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn vstack(gap: StackGap, align: StackAlign, content: Markup) -> Markup {
|
||||
let classes = format!("flex flex-col {} {}", gap_class(gap), align_class(align),);
|
||||
html! {
|
||||
div class=(classes) { (content) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hstack(gap: StackGap, align: StackAlign, content: Markup) -> Markup {
|
||||
let classes = format!("flex flex-row {} {}", gap_class(gap), align_class(align),);
|
||||
html! {
|
||||
div class=(classes) { (content) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stack(content: Markup) -> Markup {
|
||||
vstack(StackGap::Md, StackAlign::Stretch, content)
|
||||
}
|
||||
@@ -45,10 +45,6 @@ pub fn table_cell(muted: bool, content: Markup) -> Markup {
|
||||
html! { td class=(table_cell_class(muted)) { (content) } }
|
||||
}
|
||||
|
||||
pub fn table_cell_span(muted: bool, col_span: u32, content: Markup) -> Markup {
|
||||
html! { td class=(table_cell_class(muted)) colspan=(col_span) { (content) } }
|
||||
}
|
||||
|
||||
fn table_cell_class(muted: bool) -> &'static str {
|
||||
if muted {
|
||||
"px-3 py-3 text-sm sm:px-6 sm:py-4 text-neutral-600"
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use maud::{Markup, html};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum HeadingLevel {
|
||||
H1,
|
||||
H2,
|
||||
H3,
|
||||
H4,
|
||||
H5,
|
||||
H6,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum HeadingSize {
|
||||
Xs,
|
||||
Sm,
|
||||
Base,
|
||||
Lg,
|
||||
Xl,
|
||||
Xl2,
|
||||
Xl3,
|
||||
Xl4,
|
||||
}
|
||||
|
||||
fn default_heading_classes(level: HeadingLevel) -> &'static str {
|
||||
match level {
|
||||
HeadingLevel::H1 => "text-3xl font-bold",
|
||||
HeadingLevel::H2 => "text-2xl font-semibold",
|
||||
HeadingLevel::H3 => "text-xl font-semibold",
|
||||
HeadingLevel::H4 => "text-lg font-semibold",
|
||||
HeadingLevel::H5 => "text-base font-semibold",
|
||||
HeadingLevel::H6 => "text-sm font-semibold",
|
||||
}
|
||||
}
|
||||
|
||||
fn custom_size_class(size: HeadingSize) -> &'static str {
|
||||
match size {
|
||||
HeadingSize::Xs => "text-xs",
|
||||
HeadingSize::Sm => "text-sm",
|
||||
HeadingSize::Base => "text-base",
|
||||
HeadingSize::Lg => "text-lg",
|
||||
HeadingSize::Xl => "text-xl",
|
||||
HeadingSize::Xl2 => "text-2xl",
|
||||
HeadingSize::Xl3 => "text-3xl",
|
||||
HeadingSize::Xl4 => "text-4xl",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn heading(level: HeadingLevel, size: Option<HeadingSize>, content: Markup) -> Markup {
|
||||
let size_classes = match size {
|
||||
Some(s) => custom_size_class(s),
|
||||
None => default_heading_classes(level),
|
||||
};
|
||||
let classes = format!("text-gray-900 tracking-tight {size_classes}");
|
||||
match level {
|
||||
HeadingLevel::H1 => html! { h1 class=(classes) { (content) } },
|
||||
HeadingLevel::H2 => html! { h2 class=(classes) { (content) } },
|
||||
HeadingLevel::H3 => html! { h3 class=(classes) { (content) } },
|
||||
HeadingLevel::H4 => html! { h4 class=(classes) { (content) } },
|
||||
HeadingLevel::H5 => html! { h5 class=(classes) { (content) } },
|
||||
HeadingLevel::H6 => html! { h6 class=(classes) { (content) } },
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum TextSize {
|
||||
Xs,
|
||||
Sm,
|
||||
Base,
|
||||
Lg,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum TextColor {
|
||||
Default,
|
||||
Muted,
|
||||
Primary,
|
||||
Danger,
|
||||
Success,
|
||||
}
|
||||
|
||||
pub fn text(size: TextSize, color: TextColor, content: Markup) -> Markup {
|
||||
let size_class = match size {
|
||||
TextSize::Xs => "text-xs",
|
||||
TextSize::Sm => "text-sm",
|
||||
TextSize::Base => "text-base",
|
||||
TextSize::Lg => "text-lg",
|
||||
};
|
||||
let color_class = match color {
|
||||
TextColor::Default => "text-gray-900",
|
||||
TextColor::Muted => "text-neutral-500",
|
||||
TextColor::Primary => "text-brand-primary",
|
||||
TextColor::Danger => "text-red-600",
|
||||
TextColor::Success => "text-green-600",
|
||||
};
|
||||
html! {
|
||||
p class={(size_class) " font-normal " (color_class)} { (content) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn caption(content: Markup) -> Markup {
|
||||
html! {
|
||||
p class="text-xs text-gray-500" { (content) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum CaptionVariant {
|
||||
Default,
|
||||
Error,
|
||||
Success,
|
||||
}
|
||||
|
||||
pub fn caption_variant(variant: CaptionVariant, content: Markup) -> Markup {
|
||||
let color = match variant {
|
||||
CaptionVariant::Default => "text-gray-500",
|
||||
CaptionVariant::Error => "text-red-600",
|
||||
CaptionVariant::Success => "text-green-600",
|
||||
};
|
||||
html! {
|
||||
p class={"text-xs " (color)} { (content) }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label_text(for_id: Option<&str>, required: bool, content: Markup) -> Markup {
|
||||
let classes = "block text-xs font-semibold uppercase tracking-wide text-neutral-500";
|
||||
html! {
|
||||
label for=[for_id] class=(classes) {
|
||||
(content)
|
||||
@if required {
|
||||
span class="ml-1 text-red-600" { "*" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn section_heading(title: &str, actions: Option<Markup>) -> Markup {
|
||||
match actions {
|
||||
Some(action_content) => html! {
|
||||
div class="mb-4 flex items-center justify-between" {
|
||||
h2 class="font-semibold text-gray-900 text-xl" { (title) }
|
||||
div class="flex items-center gap-2" { (action_content) }
|
||||
}
|
||||
},
|
||||
None => html! {
|
||||
h2 class="mb-4 font-semibold text-gray-900 text-xl" { (title) }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inline_text(content: &str) -> Markup {
|
||||
html! { span { (content) } }
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::admin_flags::user_flag_bits;
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
use maud::{Markup, html};
|
||||
|
||||
pub mod premium_types {
|
||||
@@ -22,18 +23,21 @@ fn premium_tooltip(
|
||||
if is_self_hosted {
|
||||
let name = self_hosted_premium_name?;
|
||||
return Some(match premium_since {
|
||||
Some(since) => format!("{name} subscriber since {since}"),
|
||||
Some(since) => format!("{name} subscriber since {}", format_admin_timestamp(since)),
|
||||
None => name.to_owned(),
|
||||
});
|
||||
}
|
||||
Some(if premium_type == premium_types::LIFETIME {
|
||||
match premium_since {
|
||||
Some(since) => format!("Fluxer Visionary since {since}"),
|
||||
Some(since) => format!("Fluxer Visionary since {}", format_admin_timestamp(since)),
|
||||
None => "Fluxer Visionary".into(),
|
||||
}
|
||||
} else {
|
||||
match premium_since {
|
||||
Some(since) => format!("Fluxer Plutonium subscriber since {since}"),
|
||||
Some(since) => format!(
|
||||
"Fluxer Plutonium subscriber since {}",
|
||||
format_admin_timestamp(since)
|
||||
),
|
||||
None => "Fluxer Plutonium".into(),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -126,9 +126,3 @@ fn render_flash(flash: &crate::api::types::FlashMessage) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn flash_container() -> Markup {
|
||||
html! {
|
||||
div id="flash-container" class="mb-4 sm:mb-6 empty:hidden" {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,10 +489,11 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
|
||||
element = element.firstElementChild;
|
||||
depth += 1;
|
||||
}
|
||||
var level = status >= 400 ? 'error' : 'success';
|
||||
var level = 'success';
|
||||
if (className.indexOf('red-') >= 0) level = 'error';
|
||||
if (className.indexOf('green-') >= 0) level = 'success';
|
||||
if (className.indexOf('blue-') >= 0) level = 'info';
|
||||
if (status >= 400) level = 'error';
|
||||
return {
|
||||
level: level,
|
||||
message: text || (level === 'error' ? 'Action failed.' : 'Done.')
|
||||
@@ -514,6 +515,20 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
|
||||
}
|
||||
}
|
||||
|
||||
function refreshAfterSuccess(detail, level) {
|
||||
if (level !== 'success') return;
|
||||
var elt = (detail && detail.requestConfig && detail.requestConfig.elt) || mutationElement(detail);
|
||||
var form = elt && elt.closest ? elt.closest('form') : null;
|
||||
var selector = form ? form.getAttribute('data-admin-refresh-on-success') : '';
|
||||
if (!selector || !document.querySelector(selector)) return;
|
||||
if (!window.htmx || typeof window.htmx.ajax !== 'function') return;
|
||||
window.htmx.ajax('GET', window.location.pathname + window.location.search, {
|
||||
target: selector,
|
||||
swap: 'outerHTML',
|
||||
select: selector
|
||||
});
|
||||
}
|
||||
|
||||
document.body.addEventListener('showFlash', function (evt) {
|
||||
var d = evt.detail || {};
|
||||
showToast(d.level || 'info', d.message || '');
|
||||
@@ -533,11 +548,12 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
|
||||
var xhr = event.detail.xhr;
|
||||
var parsed = isLoginResponse(xhr)
|
||||
? {level: 'error', message: 'Session expired. Sign in again.'}
|
||||
: parseFlashResponse(xhr ? xhr.responseText : '', xhr ? xhr.status : 200);
|
||||
: parseAdminToastHeader(xhr) || parseFlashResponse(xhr ? xhr.responseText : '', xhr ? xhr.status : 200);
|
||||
event.detail.shouldSwap = false;
|
||||
event.detail.isError = false;
|
||||
markToastHandled(event.detail);
|
||||
showToast(parsed.level, parsed.message);
|
||||
refreshAfterSuccess(event.detail, parsed.level);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('htmx:afterRequest', function (event) {
|
||||
@@ -548,6 +564,7 @@ pub const HTMX_FLASH_SCRIPT: &str = r#"
|
||||
if (serverToast) {
|
||||
showToast(serverToast.level, serverToast.message);
|
||||
markToastHandled(event.detail);
|
||||
refreshAfterSuccess(event.detail, serverToast.level);
|
||||
return;
|
||||
}
|
||||
var failed = !event.detail.successful || (xhr && xhr.status >= 400) || isLoginResponse(xhr);
|
||||
@@ -568,25 +585,6 @@ window.__adminCopyToClipboard = function (text, btn, successLabel) {
|
||||
};
|
||||
"#;
|
||||
|
||||
pub const ARCHIVE_POLL_SCRIPT: &str = r#"
|
||||
(function () {
|
||||
var rows = document.querySelectorAll('tr[data-archive-id]');
|
||||
var hasPending = false;
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
var status = rows[i].querySelector('.archive-status');
|
||||
if (status && status.textContent.indexOf('Completed') === -1 && status.textContent.indexOf('Failed') === -1) {
|
||||
hasPending = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasPending) {
|
||||
setTimeout(function () {
|
||||
window.location.reload();
|
||||
}, 5000);
|
||||
}
|
||||
})();
|
||||
"#;
|
||||
|
||||
pub const SH_LINK_REWRITE_SCRIPT: &str = r#"
|
||||
(function () {
|
||||
if (window.location.search.indexOf('sh=1') === -1) return;
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::{acl, config::AdminConfig};
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
|
||||
use super::layout_scripts::SIDEBAR_ACTIVE_SCROLL_SCRIPT;
|
||||
pub use super::layout_sidebar_nav::{NAV_SECTIONS, NavItem, NavSection};
|
||||
use super::layout_sidebar_nav::NAV_SECTIONS;
|
||||
|
||||
pub fn render_sidebar(
|
||||
config: &AdminConfig,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
use crate::{
|
||||
api::types::{CreateAdminApiKeyResponse, FlashMessage, ListAdminApiKeyEntry},
|
||||
config::AdminConfig,
|
||||
@@ -127,14 +128,14 @@ fn api_key_item(base: &str, csrf_token: &str, key: &ListAdminApiKeyEntry) -> Mar
|
||||
"Key ID: " (key_id)
|
||||
}
|
||||
p class="text-sm text-neutral-500" {
|
||||
"Created: " (created_at)
|
||||
"Created: " (format_admin_timestamp(created_at))
|
||||
}
|
||||
p class="text-sm text-neutral-500" {
|
||||
"Created by: " (key.created_by_user_id)
|
||||
}
|
||||
@if let Some(ref expires_at) = key.expires_at {
|
||||
p class="text-sm text-neutral-500" {
|
||||
"Expires: " (expires_at)
|
||||
"Expires: " (format_admin_timestamp(expires_at))
|
||||
}
|
||||
}
|
||||
@if !key.acls.is_empty() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
use crate::{
|
||||
api::types::Archive,
|
||||
config::AdminConfig,
|
||||
@@ -69,7 +70,7 @@ fn archive_table(archives: &[Archive], base: &str) -> Markup {
|
||||
span class="text-sm" { (archive.requested_by) }
|
||||
}))
|
||||
(table_cell(false, html! {
|
||||
span class="text-sm" { (archive.requested_at) }
|
||||
span class="text-sm" { (format_admin_timestamp(&archive.requested_at)) }
|
||||
}))
|
||||
(table_cell(false, html! {
|
||||
div class="flex flex-col gap-1" {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::plural::noun_for;
|
||||
use crate::{
|
||||
api::types::AuditLogsListResponse,
|
||||
config::AdminConfig,
|
||||
@@ -120,7 +121,12 @@ pub fn audit_logs_page(
|
||||
.current_page
|
||||
.checked_add(1)
|
||||
.filter(|page| u64::from(*page) < total_pages);
|
||||
let showing = format!("Showing {} of {} entries", entries.len(), total);
|
||||
let showing = format!(
|
||||
"Showing {} of {} {}",
|
||||
entries.len(),
|
||||
total,
|
||||
noun_for(total, "entry", "entries")
|
||||
);
|
||||
html! {
|
||||
(page_header_with_actions("Audit Logs", None, html! {
|
||||
span class="text-sm text-neutral-500" { (showing) }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
use crate::{
|
||||
api::types::{
|
||||
AuditLogChannelSummary, AuditLogEntry, AuditLogGuildSummary, AuditLogUserSummary,
|
||||
@@ -273,7 +274,7 @@ pub fn log_row(base: &str, entry: &AuditLogEntry, index: usize) -> Markup {
|
||||
(table_row(html! {
|
||||
(table_cell(true, html! {
|
||||
span class="text-sm whitespace-nowrap" {
|
||||
(entry.created_at)
|
||||
(format_admin_timestamp(&entry.created_at))
|
||||
}
|
||||
}))
|
||||
(table_cell(false, badge(&format_action(&entry.action), action_badge_variant(&entry.action))))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::plural::count_noun;
|
||||
use crate::{
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -35,7 +36,7 @@ pub fn ip_ban_duration_label(hours: u32) -> String {
|
||||
.iter()
|
||||
.find(|(value, _)| *value == hours)
|
||||
.map_or_else(
|
||||
|| format!("{hours} hours"),
|
||||
|| count_noun(u64::from(hours), "hour", "hours"),
|
||||
|(_, label)| (*label).to_owned(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,10 @@ const PATCHABLE_USER_FLAGS: &[UserFlag] = &[
|
||||
name: "SPAMMER",
|
||||
value: 1 << 6,
|
||||
},
|
||||
UserFlag {
|
||||
name: "PROFILE_HIDDEN",
|
||||
value: 1 << 7,
|
||||
},
|
||||
UserFlag {
|
||||
name: "HIGH_GLOBAL_RATE_LIMIT",
|
||||
value: 1 << 33,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use super::gateway_leaderboard;
|
||||
use super::gateway_nodes;
|
||||
|
||||
use crate::utils::plural::count_noun;
|
||||
use crate::{
|
||||
api::types::{GatewayVoiceStateCountsResponse, GuildMemoryStatsResponse, NodeStatsResponse},
|
||||
config::AdminConfig,
|
||||
@@ -43,7 +44,7 @@ pub fn gateway_page(config: &AdminConfig, auth: &AuthContext, p: &GatewayPagePar
|
||||
))
|
||||
@if let Some(count) = p.reload_result {
|
||||
div class="rounded-lg border p-4 bg-green-50 border-green-200 text-green-700" {
|
||||
"Successfully reloaded " (count) " guilds!"
|
||||
"Successfully reloaded " (count_noun(count, "guild", "guilds")) "!"
|
||||
}
|
||||
}
|
||||
div class="space-y-6" {
|
||||
|
||||
@@ -26,6 +26,7 @@ pub const GUILD_TABS: &[(&str, &str)] = &[
|
||||
("archives", "Archives"),
|
||||
("emojis", "Emojis"),
|
||||
("stickers", "Stickers"),
|
||||
("threads", "Threads"),
|
||||
("audit_logs", "Admin Audit Logs"),
|
||||
("audit_log", "Guild Audit Log"),
|
||||
("reports", "Reports"),
|
||||
@@ -208,6 +209,7 @@ fn guild_tab_visible(_config: &AdminConfig, tab_id: &str, admin_acls: &[String])
|
||||
"overview" | "members" | "settings" | "features" | "moderation" => true,
|
||||
"reports" => acl::has_permission(admin_acls, acl::REPORT_VIEW),
|
||||
"emojis" | "stickers" => acl::has_permission(admin_acls, acl::ASSET_PURGE),
|
||||
"threads" => acl::has_permission(admin_acls, acl::GUILD_LOOKUP),
|
||||
"audit_logs" => acl::has_permission(admin_acls, acl::AUDIT_LOG_VIEW),
|
||||
"audit_log" => acl::has_permission(admin_acls, acl::GUILD_AUDIT_LOG_VIEW),
|
||||
"archives" => acl::has_any_permission(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::plural::count_noun;
|
||||
use crate::utils::timestamps::format_admin_timestamp;
|
||||
use crate::{
|
||||
api::types::{GuildInfo, GuildMember, ListGuildMembersResponse},
|
||||
config::AdminConfig,
|
||||
@@ -149,11 +151,11 @@ fn member_card(
|
||||
"ID: " (member.user.id)
|
||||
}
|
||||
p class="text-sm text-neutral-500" {
|
||||
"Joined: " (member.joined_at)
|
||||
"Joined: " (format_admin_timestamp(&member.joined_at))
|
||||
}
|
||||
@if !member.roles.is_empty() {
|
||||
p class="text-sm text-neutral-500" {
|
||||
(member.roles.len()) " roles"
|
||||
(count_noun(member.roles.len() as u64, "role", "roles"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod overview;
|
||||
pub mod reports;
|
||||
pub mod settings;
|
||||
pub mod stickers;
|
||||
pub mod threads;
|
||||
|
||||
use crate::{api::types::GuildDetailInfo, utils::user_tag::user_tag};
|
||||
|
||||
|
||||
@@ -33,7 +33,11 @@ fn channel_type_label(channel_type: i32) -> &'static str {
|
||||
2 => "Voice",
|
||||
4 => "Category",
|
||||
5 => "Announcement",
|
||||
13 => "Link",
|
||||
11 => "Public thread",
|
||||
12 => "Private thread",
|
||||
15 => "Forum",
|
||||
16 => "Media",
|
||||
998 => "Link",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
@@ -179,7 +183,7 @@ pub fn overview_tab(config: &AdminConfig, guild: &GuildDetailInfo, csrf_token: &
|
||||
} @else {
|
||||
div class="flex flex-col gap-2" {
|
||||
@for channel in &sorted_channels {
|
||||
@let is_link = channel.channel_type == 13;
|
||||
@let is_link = channel.channel_type == 998;
|
||||
@let parent = channel.parent_id.as_deref()
|
||||
.and_then(|pid| channels_by_id.get(pid));
|
||||
@let parent_nsfw_override = parent
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
use crate::{
|
||||
api::types::{GuildInfo, ReportEntry},
|
||||
config::AdminConfig,
|
||||
templates::components::{page_container::card_with_header, table::data_table},
|
||||
utils::user_tag::user_tag,
|
||||
templates::{
|
||||
components::page_container::card_with_header,
|
||||
pages::user_detail_tabs::reports::{
|
||||
format_status, report_tab_table, reported_at_cell, reporter_label,
|
||||
type_and_category_cell,
|
||||
},
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
@@ -39,9 +44,8 @@ pub fn reports_tab(
|
||||
}
|
||||
}))
|
||||
} @else {
|
||||
(data_table(
|
||||
&["Reported At", "Report ID", "Type", "Category",
|
||||
"Reporter", "Status", "Actions"],
|
||||
(report_tab_table(
|
||||
&["Reported At", "Type / Category", "Reporter", "Status", "Actions"],
|
||||
html! {
|
||||
@for report in reports {
|
||||
(report_row(base, report))
|
||||
@@ -74,81 +78,28 @@ pub fn reports_tab(
|
||||
}
|
||||
}
|
||||
|
||||
fn format_report_type(report_type: i32) -> &'static str {
|
||||
match report_type {
|
||||
0 => "Message",
|
||||
1 => "User",
|
||||
2 => "Guild",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_status(status: i32) -> &'static str {
|
||||
match status {
|
||||
0 => "Pending",
|
||||
1 => "Resolved",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn format_reporter(report: &ReportEntry) -> String {
|
||||
if let Some(ref username) = report.reporter_username {
|
||||
let disc = report.reporter_discriminator.as_deref().unwrap_or("0000");
|
||||
let tag = user_tag(username, disc, false);
|
||||
if let Some(ref gn) = report.reporter_global_name {
|
||||
let trimmed = gn.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return format!("{trimmed} ({tag})");
|
||||
}
|
||||
}
|
||||
return tag;
|
||||
}
|
||||
if let Some(ref tag) = report.reporter_tag {
|
||||
return tag.clone();
|
||||
}
|
||||
if let Some(ref email) = report.reporter_email {
|
||||
return email.clone();
|
||||
}
|
||||
report
|
||||
.reporter_id
|
||||
.as_deref()
|
||||
.unwrap_or("Unknown reporter")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn report_row(base: &str, report: &ReportEntry) -> Markup {
|
||||
let reporter_display = format_reporter(report);
|
||||
html! {
|
||||
tr class="hover:bg-neutral-50 transition-colors" {
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(report.reported_at)
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
a href={(base) "/reports/" (report.report_id)}
|
||||
class="hover:underline" {
|
||||
(report.report_id)
|
||||
}
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(format_report_type(report.report_type))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
(report.category.as_deref().unwrap_or(""))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
(reported_at_cell(base, report))
|
||||
(type_and_category_cell(report))
|
||||
td class="px-2 py-3 text-sm [overflow-wrap:anywhere] [&_.whitespace-nowrap]:whitespace-normal xl:px-4 xl:[&_.whitespace-nowrap]:whitespace-nowrap" {
|
||||
@if let Some(ref rid) = report.reporter_id {
|
||||
a href={(base) "/users/" (rid)}
|
||||
class="hover:underline" {
|
||||
(reporter_display)
|
||||
(reporter_label(report))
|
||||
}
|
||||
} @else {
|
||||
span { (reporter_display) }
|
||||
span { (reporter_label(report)) }
|
||||
}
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm text-neutral-900" {
|
||||
td class="hidden whitespace-nowrap px-4 py-3 text-sm text-neutral-900 xl:table-cell" {
|
||||
(format_status(report.status))
|
||||
}
|
||||
td class="whitespace-nowrap px-4 py-3 text-sm" {
|
||||
td class="whitespace-nowrap px-2 py-3 text-sm xl:px-4" {
|
||||
div class="mb-1 text-neutral-900 xl:hidden" data-status-compact=(report.report_id) {
|
||||
(format_status(report.status))
|
||||
}
|
||||
a href={(base) "/reports/" (report.report_id)}
|
||||
class="inline-flex items-center rounded-md border border-neutral-300 \
|
||||
bg-white px-3 py-1.5 text-sm font-medium text-neutral-700 \
|
||||
@@ -159,3 +110,49 @@ fn report_row(base: &str, report: &ReportEntry) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn guild_report_rows_format_the_date_and_wrap_long_cells() {
|
||||
let report: ReportEntry = serde_json::from_value(json!({
|
||||
"report_id": "1800000000000000006",
|
||||
"reporter_id": "1500000000000000000",
|
||||
"reporter_username": "reporter_0423a7212d56",
|
||||
"reporter_discriminator": "8650",
|
||||
"reporter_global_name": "Avery Reporter",
|
||||
"reported_at": "2026-10-05T23:23:28.067Z",
|
||||
"status": 0,
|
||||
"report_type": 0,
|
||||
"category": "spam",
|
||||
"reason": "spam",
|
||||
"reason_label": "Spam",
|
||||
"reported_guild_id": "1700000000000000800"
|
||||
}))
|
||||
.expect("valid report");
|
||||
let markup = report_row("/admin", &report).into_string();
|
||||
assert!(markup.contains("Oct 5, 2026, 11:23 PM UTC"), "{markup}");
|
||||
assert!(!markup.contains("2026-10-05T23:23:28.067Z"), "{markup}");
|
||||
assert!(
|
||||
markup
|
||||
.contains(r#"<span class="whitespace-nowrap text-neutral-500 text-xs">(reporter_0423a7212d56#8650)</span>"#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains("Avery Reporter "), "{markup}");
|
||||
assert!(
|
||||
markup.contains(r#"<td class="hidden whitespace-nowrap px-4 py-3 text-sm text-neutral-900 xl:table-cell">Pending</td>"#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(
|
||||
markup.contains(r#"<div class="mb-1 text-neutral-900 xl:hidden" data-status-compact="1800000000000000006">Pending</div>"#),
|
||||
"{markup}"
|
||||
);
|
||||
assert!(markup.contains(">Pending<"), "{markup}");
|
||||
assert!(markup.contains(">View<"), "{markup}");
|
||||
assert_eq!(markup.matches(">Spam<").count(), 2, "{markup}");
|
||||
assert!(markup.contains(r#"data-report-reason="spam""#), "{markup}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::{GuildInfo, GuildThreadItem},
|
||||
config::AdminConfig,
|
||||
templates::components::{
|
||||
badge::{BadgeVariant, badge},
|
||||
form::{csrf_input, danger_button, form_actions, submit_button},
|
||||
page_container::card_with_header,
|
||||
table::{data_table, table_cell, table_row},
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
pub fn threads_tab(
|
||||
config: &AdminConfig,
|
||||
guild: &GuildInfo,
|
||||
threads: &[GuildThreadItem],
|
||||
can_delete: bool,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
html! {
|
||||
(card_with_header("Thread search index", html! {
|
||||
form method="post"
|
||||
action={(base) "/guilds/" (guild.id) "?tab=threads&action=refresh_search_index"}
|
||||
class="w-full" {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="index_type" value="threads";
|
||||
(form_actions(html! {
|
||||
(submit_button("Refresh threads"))
|
||||
}))
|
||||
}
|
||||
}))
|
||||
(card_with_header(
|
||||
&format!("Threads ({})", threads.len()),
|
||||
html! {
|
||||
@if threads.is_empty() {
|
||||
p class="text-sm text-neutral-500" { "No threads found for this guild." }
|
||||
} @else {
|
||||
(data_table(
|
||||
&["Thread", "Parent", "State", "Members", "Messages", ""],
|
||||
html! {
|
||||
@for thread in threads {
|
||||
(thread_row(base, &guild.id, thread, can_delete, csrf_token))
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_kind(channel_type: i32) -> &'static str {
|
||||
match channel_type {
|
||||
10 => "Announcement",
|
||||
12 => "Private",
|
||||
_ => "Public",
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_state(thread: &GuildThreadItem) -> Markup {
|
||||
let metadata = thread.thread_metadata.as_ref();
|
||||
let archived = metadata.is_some_and(|metadata| metadata.archived);
|
||||
let locked = metadata.is_some_and(|metadata| metadata.locked);
|
||||
html! {
|
||||
div class="flex flex-wrap gap-1" {
|
||||
(badge(thread_kind(thread.channel_type), BadgeVariant::Default))
|
||||
@if archived { (badge("Archived", BadgeVariant::Default)) }
|
||||
@if locked { (badge("Locked", BadgeVariant::Default)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn thread_row(
|
||||
base: &str,
|
||||
guild_id: &str,
|
||||
thread: &GuildThreadItem,
|
||||
can_delete: bool,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
table_row(html! {
|
||||
(table_cell(false, html! {
|
||||
div class="font-medium" { (thread.name.as_deref().unwrap_or("")) }
|
||||
div class="text-xs text-neutral-500" { "ID: " (thread.id) }
|
||||
}))
|
||||
(table_cell(true, html! { (thread.parent_id.as_deref().unwrap_or("")) }))
|
||||
(table_cell(false, thread_state(thread)))
|
||||
(table_cell(true, html! { (thread.member_count.unwrap_or(0)) }))
|
||||
(table_cell(true, html! { (thread.message_count.unwrap_or(0)) }))
|
||||
(table_cell(false, html! {
|
||||
@if can_delete {
|
||||
form method="post"
|
||||
action={(base) "/guilds/" (guild_id) "?tab=threads&action=delete_thread"} {
|
||||
(csrf_input(csrf_token))
|
||||
input type="hidden" name="thread_id" value=(thread.id);
|
||||
(danger_button("Delete thread"))
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::utils::plural::count_noun;
|
||||
use crate::{
|
||||
api::types::GuildInfo,
|
||||
config::AdminConfig,
|
||||
@@ -20,7 +21,6 @@ use maud::{Markup, html};
|
||||
|
||||
pub struct GuildsListParams {
|
||||
pub q: String,
|
||||
pub ids: String,
|
||||
pub requested_ids: Vec<String>,
|
||||
pub limit: u32,
|
||||
pub page: u32,
|
||||
@@ -37,7 +37,6 @@ impl GuildsListParams {
|
||||
Self {
|
||||
q: q.unwrap_or_default().trim().to_owned(),
|
||||
requested_ids: parse_comma_separated(&ids),
|
||||
ids: ids.trim().to_owned(),
|
||||
limit: match limit.unwrap_or(50) {
|
||||
25 => 25,
|
||||
100 => 100,
|
||||
@@ -97,7 +96,7 @@ pub fn guilds_list_page(
|
||||
} @else if let Some(count) = result_state.total {
|
||||
(page_header_with_actions("Guilds", None, html! {
|
||||
p class="text-sm font-normal text-neutral-500" {
|
||||
"Found " (count) " results (showing "
|
||||
"Found " (count_noun(count, "result", "results")) " (showing "
|
||||
(result_state.guilds.map(|guilds| guilds.len()).unwrap_or_default())
|
||||
")"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user