mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 20:22:11 +09:00
Compare commits
267
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd4117fa0a | ||
|
|
f004685424 | ||
|
|
b268320406 | ||
|
|
7a33b3198a | ||
|
|
66791d3ca2 | ||
|
|
a0d4a33fd4 | ||
|
|
fdd12194b1 | ||
|
|
bf11445299 | ||
|
|
61bf8f6ad3 | ||
|
|
52282bfccf | ||
|
|
cf3a31ac42 | ||
|
|
f56ccf5ef5 | ||
|
|
51e2eee15a | ||
|
|
de97bf908d | ||
|
|
099fb80da2 | ||
|
|
022ccc794d | ||
|
|
987768e8dc | ||
|
|
a3ffe963c3 | ||
|
|
b51562d0e3 | ||
|
|
38e86acffe | ||
|
|
38a299d852 | ||
|
|
55b25c919d | ||
|
|
2af4cc98fd | ||
|
|
e68b5b8b5a | ||
|
|
317b4e26c0 | ||
|
|
af5bee9149 | ||
|
|
26939eccce | ||
|
|
54360708d9 | ||
|
|
e441c7229c | ||
|
|
54e5fe6ef9 | ||
|
|
6fc0f1ccd7 | ||
|
|
6cd30d893a | ||
|
|
dceb9061d9 | ||
|
|
21438b2d8f | ||
|
|
793e853eb3 | ||
|
|
9cbe0efbbb | ||
|
|
9219b886fe | ||
|
|
2377a4c9d0 | ||
|
|
986c586683 | ||
|
|
7be52fa9fc | ||
|
|
32d7ae3eef | ||
|
|
ef6fb4903f | ||
|
|
0fe3f7523d | ||
|
|
9991534c83 | ||
|
|
455681b24c | ||
|
|
14e63085dd | ||
|
|
e8cb1cefbd | ||
|
|
919e890011 | ||
|
|
299172c8aa | ||
|
|
6cac93ef39 | ||
|
|
e5c8ef7d60 | ||
|
|
d0b4688a6c | ||
|
|
e0464ee5be | ||
|
|
cbcd299bd9 | ||
|
|
1300e5a690 | ||
|
|
fbd653d8e0 | ||
|
|
eb4f41e80c | ||
|
|
589a01a2da | ||
|
|
aae6b99761 | ||
|
|
5d35047734 | ||
|
|
98d10215d6 | ||
|
|
6656628bba | ||
|
|
37f46fc62d | ||
|
|
9efd2b0a73 | ||
|
|
b1fb2c14a1 | ||
|
|
c5d910425e | ||
|
|
0a9e64d36a | ||
|
|
7d02b7959f | ||
|
|
0670380360 | ||
|
|
904987795a | ||
|
|
4ee1b2294a | ||
|
|
97eb84fd46 | ||
|
|
5eb7822691 | ||
|
|
79cf57abe4 | ||
|
|
075bdb5128 | ||
|
|
65698051ac | ||
|
|
95559f17d8 | ||
|
|
aabc13e3cb | ||
|
|
b71ced9b2e | ||
|
|
bb34c73069 | ||
|
|
94bbbd1021 | ||
|
|
670a3a970e | ||
|
|
7a938c85f6 | ||
|
|
3a6bc4bc7b | ||
|
|
c54d7bcc88 | ||
|
|
b81bfc80fd | ||
|
|
d8bad50eec | ||
|
|
3fe6217809 | ||
|
|
50a947a722 | ||
|
|
7fe5aad16e | ||
|
|
97d559f749 | ||
|
|
188f783fae | ||
|
|
3a064dd728 | ||
|
|
202856c0f7 | ||
|
|
406340fa65 | ||
|
|
735ecc1cca | ||
|
|
7b646f891e | ||
|
|
19264d7f81 | ||
|
|
76ce060d13 | ||
|
|
9b3dc19037 | ||
|
|
5821a68816 | ||
|
|
e21544eac2 | ||
|
|
1f0f7d1222 | ||
|
|
69e0086144 | ||
|
|
61ce8729de | ||
|
|
44869b0ce4 | ||
|
|
213dd53ee8 | ||
|
|
844952db51 | ||
|
|
eda29c0e34 | ||
|
|
5834e32aa5 | ||
|
|
a59f3d0d0c | ||
|
|
5b8a46d087 | ||
|
|
677e6e5d6e | ||
|
|
62f40116be | ||
|
|
d2d199e136 | ||
|
|
39e2c89d5c | ||
|
|
15f4417c68 | ||
|
|
943b948de7 | ||
|
|
b7f75e25ad | ||
|
|
2af0405317 | ||
|
|
a2099fb0e2 | ||
|
|
52781cfa2d | ||
|
|
af7469fa1f | ||
|
|
4c14ba0a5e | ||
|
|
b49cf349a8 | ||
|
|
02406925d7 | ||
|
|
aad7e1a551 | ||
|
|
a98a9fe6a9 | ||
|
|
ac6fcc8c40 | ||
|
|
b0e882645e | ||
|
|
8c431c7562 | ||
|
|
3e267b8104 | ||
|
|
7c5cab2144 | ||
|
|
5cb893245f | ||
|
|
aa1c636cc2 | ||
|
|
c285854b71 | ||
|
|
01a29d629b | ||
|
|
bd381ccc74 | ||
|
|
c3e747aaa4 | ||
|
|
480d56125d | ||
|
|
7ec155eac1 | ||
|
|
c8ff177f85 | ||
|
|
f276bb1cd2 | ||
|
|
208632e648 | ||
|
|
8cfac127f5 | ||
|
|
ac76bee5a3 | ||
|
|
171dc7e5b7 | ||
|
|
051985b767 | ||
|
|
1eb85410bf | ||
|
|
6697db7cf8 | ||
|
|
56f6009390 | ||
|
|
d339b82f8e | ||
|
|
82eb87bc47 | ||
|
|
991be1c5a2 | ||
|
|
0d96a4574a | ||
|
|
61b4511ae4 | ||
|
|
137edc7cfb | ||
|
|
14e772a751 | ||
|
|
32afbf12d6 | ||
|
|
ffaf5119d8 | ||
|
|
10bc8c1efa | ||
|
|
85a03a9e39 | ||
|
|
51ee6567b4 | ||
|
|
090220a29d | ||
|
|
e7973b8be0 | ||
|
|
b5324c9223 | ||
|
|
edb8d80077 | ||
|
|
ddee116339 | ||
|
|
bdacaea4a8 | ||
|
|
9e28e02b5d | ||
|
|
3527dc95a2 | ||
|
|
27c7b2722d | ||
|
|
ba96f52ed6 | ||
|
|
2c8b3ff45c | ||
|
|
631bc2307a | ||
|
|
8f4f9a8601 | ||
|
|
1c920f966e | ||
|
|
2b1de38949 | ||
|
|
d5daf61dbd | ||
|
|
06c42ce02f | ||
|
|
4f21430880 | ||
|
|
9731bac40f | ||
|
|
b144e650f2 | ||
|
|
ef6536644c | ||
|
|
17eab43245 | ||
|
|
fbd7f1e3b8 | ||
|
|
25af7516a4 | ||
|
|
c020eded31 | ||
|
|
5331c0216a | ||
|
|
528777926c | ||
|
|
47b5c3d4f0 | ||
|
|
d9bfca66d6 | ||
|
|
ded51b65d3 | ||
|
|
ddc8837d4f | ||
|
|
df16957c95 | ||
|
|
f38b19d4bd | ||
|
|
f7cd4f2c74 | ||
|
|
a078392888 | ||
|
|
3060f23f8c | ||
|
|
6a5f0d4d29 | ||
|
|
91d989dc7c | ||
|
|
7c82804df6 | ||
|
|
b7de83f7fc | ||
|
|
97bd022bee | ||
|
|
62324df039 | ||
|
|
9f78b3d9a6 | ||
|
|
362a89f2b2 | ||
|
|
ce41960fcd | ||
|
|
2083eaddd6 | ||
|
|
d398ebc44b | ||
|
|
59887ad404 | ||
|
|
5aa283e8e0 | ||
|
|
d9ed256a4b | ||
|
|
a297f89b83 | ||
|
|
4a16921242 | ||
|
|
a6f83c4fb1 | ||
|
|
7b5c82c6cf | ||
|
|
30a61ce90f | ||
|
|
22bc2cab74 | ||
|
|
53df9a0d6d | ||
|
|
f9b7ec4d0b | ||
|
|
569abf57b7 | ||
|
|
ae8e32d809 | ||
|
|
a81b1abec7 | ||
|
|
8836565c32 | ||
|
|
a1b595d52f | ||
|
|
abb71ed558 | ||
|
|
c006d413ac | ||
|
|
fa11acae15 | ||
|
|
300f467ad0 | ||
|
|
698469fa96 | ||
|
|
591e9fe2ba | ||
|
|
d148b4e5b7 | ||
|
|
324f333bb5 | ||
|
|
9b0b703c9d | ||
|
|
5660972c71 | ||
|
|
b4a5eb77a8 | ||
|
|
4bbfee4cec | ||
|
|
9e89539f0a | ||
|
|
fa71eb8682 | ||
|
|
49b7127bc7 | ||
|
|
9cb8812be0 | ||
|
|
7d82d188a0 | ||
|
|
5ce5669f17 | ||
|
|
9ea750152e | ||
|
|
e87e2fe7bf | ||
|
|
871b5116dc | ||
|
|
d7b2e67c35 | ||
|
|
7e1ca9ed6a | ||
|
|
1922d56188 | ||
|
|
cc105883a5 | ||
|
|
41c7acdd8f | ||
|
|
c35d29ea0b | ||
|
|
97c29bf1fb | ||
|
|
3ff7189566 | ||
|
|
3fa766c39c | ||
|
|
10ae4bfe1e | ||
|
|
d271f3112c | ||
|
|
5a13172b46 | ||
|
|
2269e1899e | ||
|
|
c61fd96df6 | ||
|
|
6c68202222 | ||
|
|
e0bb10d5b7 | ||
|
|
96643ab20d | ||
|
|
40da982f57 | ||
|
|
8a14281b87 | ||
|
|
be69157811 |
@@ -25,7 +25,7 @@ Closes #456
|
||||
|
||||
You must understand every line you submit and be able to explain why the change is correct.
|
||||
|
||||
The [LLM usage policy](LLM_USAGE_POLICY.md) defines the authorship requirements for contributors who do not have write access.
|
||||
The [LLM usage policy](https://github.com/fluxerapp/fluxer/blob/main/.github/LLM_USAGE_POLICY.md) defines the authorship requirements for contributors who do not have write access.
|
||||
|
||||
Each contribution must contain one coherent change. Do not include unrelated fixes, refactoring or formatting changes.
|
||||
|
||||
@@ -80,15 +80,15 @@ Complete every section of the pull request template. Clearly describe:
|
||||
|
||||
Use the [bug report form](https://github.com/fluxerapp/fluxer/issues/new?template=bug-report.yaml) to report reproducible defects.
|
||||
|
||||
Report security vulnerabilities privately through the channels specified in the [security policy](SECURITY.md). Do not report vulnerabilities in public issues or discussions.
|
||||
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.
|
||||
|
||||
Use [discussions](https://github.com/orgs/fluxerapp/discussions) for feature proposals and self-hosting questions.
|
||||
|
||||
Submit translations through [Weblate](https://weblate.fluxer.tools), not through pull requests.
|
||||
|
||||
All repository activity is governed by the [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
All repository activity is governed by the [Code of Conduct](https://github.com/fluxerapp/fluxer/blob/main/.github/CODE_OF_CONDUCT.md).
|
||||
|
||||
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](../LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
|
||||
Fluxer is distributed under the [GNU Affero General Public License, version 3.0 or later](https://github.com/fluxerapp/fluxer/blob/main/LICENSE). By adding a DCO sign-off, you certify that you have the right to submit the contribution under that licence.
|
||||
|
||||
## Private marketing project
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ External contributions do not grant voting rights, commit access, employment or
|
||||
|
||||
## Licence and contributor rights
|
||||
|
||||
Source code owned by Fluxer Platform AB in this repository is distributed under the [GNU Affero General Public License, version 3.0 or later](../LICENSE). The licence permits its use, modification and redistribution subject to its terms.
|
||||
Source code owned by Fluxer Platform AB in this repository is distributed under the [GNU Affero General Public License, version 3.0 or later](https://github.com/fluxerapp/fluxer/blob/main/LICENSE). The licence permits its use, modification and redistribution subject to its terms.
|
||||
|
||||
Fluxer Platform AB does not require contributors to sign a contributor licence agreement or assign their copyright. Contributors retain the copyright in their work.
|
||||
|
||||
Every commit made by a contributor must include the [Developer Certificate of Origin](https://developercertificate.org) sign-off required by the [contributing guidelines](CONTRIBUTING.md). Pull requests opened by Fluxer repository automation are exempt from this requirement.
|
||||
Every commit made by a contributor must include the [Developer Certificate of Origin](https://developercertificate.org) sign-off required by the [contributing guidelines](https://github.com/fluxerapp/fluxer/blob/main/.github/CONTRIBUTING.md). Pull requests opened by Fluxer repository automation are exempt from this requirement.
|
||||
|
||||
## Name and marks
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ Deliberately submitting a fabricated security report MAY result in an immediate
|
||||
|
||||
Maintainers are not required to investigate possible LLM use proactively. Writing style alone is not evidence of a violation.
|
||||
|
||||
A person MUST NOT publicly accuse or harass a contributor because of suspected LLM use. All discussion, review and enforcement under this policy MUST comply with the [Code of Conduct](CODE_OF_CONDUCT.md).
|
||||
A person MUST NOT publicly accuse or harass a contributor because of suspected LLM use. All discussion, review and enforcement under this policy MUST comply with the [Code of Conduct](https://github.com/fluxerapp/fluxer/blob/main/.github/CODE_OF_CONDUCT.md).
|
||||
|
||||
## 11. Normative References
|
||||
|
||||
|
||||
@@ -65,10 +65,19 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- 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 variables
|
||||
id: vars
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
FLUXER_BUILD_VERSION: ${{ inputs['build-version'] }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- resolve-calver
|
||||
@@ -151,9 +160,18 @@ jobs:
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
|
||||
- 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 release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-admin
|
||||
dockerfile: fluxer_admin/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-api
|
||||
dockerfile: fluxer_api/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
build:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-app-proxy-self-hosted
|
||||
dockerfile: fluxer_app_proxy/Dockerfile
|
||||
|
||||
@@ -106,10 +106,10 @@ jobs:
|
||||
|
||||
- name: upload assets to S3 static bucket
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_ENDPOINT: https://ewr1.vultrobjects.com
|
||||
STATIC_BUCKET: fluxer-static
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.STATIC_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.STATIC_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_ENDPOINT: ${{ vars.STATIC_S3_ENDPOINT }}
|
||||
STATIC_BUCKET: ${{ vars.STATIC_S3_BUCKET }}
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-app-proxy
|
||||
--step upload_assets
|
||||
@@ -187,9 +187,18 @@ jobs:
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
docker buildx imagetools inspect "${IMAGE}:${VERSION}"
|
||||
|
||||
- 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 release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
SOURCE_SHA: ${{ github.sha }}
|
||||
VERSION: ${{ needs.meta.outputs.build_version }}
|
||||
RELEASE_BASELINE_SHA: ${{ vars.RELEASE_BASELINE_SHA }}
|
||||
|
||||
@@ -67,10 +67,19 @@ jobs:
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
|
||||
- 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: ${{ github.token }}
|
||||
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
|
||||
@@ -130,10 +139,10 @@ jobs:
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: https://ewr1.vultrobjects.com
|
||||
S3_BUCKET: fluxer-downloads
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
DESKTOP_PLATFORM: ${{ matrix.platform }}
|
||||
DESKTOP_ARCH: ${{ matrix.arch }}
|
||||
DESKTOP_VARIANT: ${{ matrix.desktop_variant }}
|
||||
@@ -515,11 +524,11 @@ jobs:
|
||||
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: https://ewr1.vultrobjects.com
|
||||
S3_BUCKET: fluxer-downloads
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DOWNLOADS_AWS_ACCESS_KEY_ID || secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
@@ -581,9 +590,18 @@ jobs:
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: "1.93.0"
|
||||
- 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 desktop release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
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 }}
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-docs
|
||||
dockerfile: fluxer_docs/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-gateway
|
||||
dockerfile: fluxer_gateway/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-gifs
|
||||
dockerfile: fluxer_gifs/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-media-proxy
|
||||
dockerfile: fluxer_media_proxy/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-messages
|
||||
dockerfile: fluxer_messages/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-snowflakes
|
||||
dockerfile: fluxer_snowflakes/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-static
|
||||
dockerfile: fluxer_static/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-unfurl
|
||||
dockerfile: fluxer_unfurl/Dockerfile
|
||||
|
||||
@@ -29,6 +29,7 @@ jobs:
|
||||
image:
|
||||
needs: approve
|
||||
uses: ./.github/workflows/_build-image.yaml
|
||||
secrets: inherit
|
||||
with:
|
||||
image: fluxer-users
|
||||
dockerfile: fluxer_users/Dockerfile
|
||||
|
||||
@@ -17,6 +17,7 @@ concurrency:
|
||||
jobs:
|
||||
dispatch:
|
||||
name: Dispatch regeneration
|
||||
if: github.repository == 'fluxerapp/fluxer'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create token
|
||||
|
||||
@@ -31,6 +31,7 @@ concurrency:
|
||||
jobs:
|
||||
metadata:
|
||||
name: resolve exact private build metadata
|
||||
if: github.repository == 'fluxerapp/fluxer'
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
@@ -39,11 +40,20 @@ jobs:
|
||||
build_version: ${{ steps.inputs.outputs.build_version }}
|
||||
correlation_id: ${{ steps.inputs.outputs.correlation_id }}
|
||||
steps:
|
||||
- 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: Resolve trusted build inputs
|
||||
id: inputs
|
||||
env:
|
||||
EVENT_AFTER: ${{ github.event.after }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GH_TOKEN: ${{ steps.create-token.outputs.token }}
|
||||
PARENT_SHA: ${{ github.sha }}
|
||||
PUBLIC_REPOSITORY: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.run_id }}
|
||||
|
||||
@@ -5,6 +5,7 @@ permissions: {}
|
||||
jobs:
|
||||
label:
|
||||
name: Label
|
||||
if: github.repository == 'fluxerapp/fluxer'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create token
|
||||
|
||||
@@ -41,7 +41,7 @@ concurrency:
|
||||
jobs:
|
||||
automatic:
|
||||
name: Lock closed conversation
|
||||
if: github.event_name != 'workflow_dispatch' && github.event_name != 'schedule'
|
||||
if: github.repository == 'fluxerapp/fluxer' && github.event_name != 'workflow_dispatch' && github.event_name != 'schedule'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create token
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
|
||||
retroactive:
|
||||
name: Lock closed conversations retroactively
|
||||
if: github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
|
||||
if: github.repository == 'fluxerapp/fluxer' && (github.event_name == 'workflow_dispatch' || github.event_name == 'schedule')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Create token
|
||||
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: stable
|
||||
toolchain: "1.93.0"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Install pnpm
|
||||
@@ -286,7 +286,7 @@ jobs:
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
|
||||
with:
|
||||
toolchain: stable
|
||||
toolchain: "1.93.0"
|
||||
components: rustfmt
|
||||
|
||||
- name: Sync ci helper dependencies
|
||||
|
||||
Generated
+10
@@ -1777,6 +1777,7 @@ dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
"clap",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"hyper 1.10.1",
|
||||
"hyper-util",
|
||||
@@ -1800,6 +1801,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"moka",
|
||||
"reqwest",
|
||||
@@ -1836,6 +1838,7 @@ dependencies = [
|
||||
"cc",
|
||||
"clap",
|
||||
"criterion",
|
||||
"fluxer_common",
|
||||
"hex",
|
||||
"hmac 0.13.0",
|
||||
"http 1.4.2",
|
||||
@@ -1875,6 +1878,7 @@ dependencies = [
|
||||
"chrono",
|
||||
"criterion",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"fluxer_markdown_parser",
|
||||
"futures",
|
||||
"hmac 0.13.0",
|
||||
@@ -1943,6 +1947,7 @@ dependencies = [
|
||||
"encoding_rs",
|
||||
"entities",
|
||||
"fluxer-svc",
|
||||
"fluxer_common",
|
||||
"hmac 0.13.0",
|
||||
"infer",
|
||||
"moka",
|
||||
@@ -2038,12 +2043,17 @@ dependencies = [
|
||||
"aws-credential-types",
|
||||
"aws-sigv4",
|
||||
"axum",
|
||||
"base64",
|
||||
"hmac 0.13.0",
|
||||
"maxminddb",
|
||||
"moka",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"sha2 0.11.0",
|
||||
"thiserror",
|
||||
"time",
|
||||
"tracing",
|
||||
"url",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
|
||||
@@ -31,5 +31,5 @@
|
||||
Fluxer is a free and open source instant messaging and VoIP chat app built for friends, groups, and communities.
|
||||
|
||||
<p align="center">
|
||||
<img src="./fluxer_static/marketing/screenshots/desktop-1920w.png" alt="Fluxer app showcase" width="900">
|
||||
<img src="./fluxer_static/marketing/screenshots/desktop-readme-1920w.png" alt="Fluxer app showcase" width="900">
|
||||
</p>
|
||||
|
||||
+8
-1
@@ -145,7 +145,14 @@
|
||||
"!fluxer_static",
|
||||
"!packages/fonts",
|
||||
"!fluxer_admin/static/htmx.min.js",
|
||||
"!fluxer_api/src/api/openapi/openapi.json"
|
||||
"!fluxer_api/src/api/openapi/openapi.json",
|
||||
"!fluxer_api/pkgs/email/src/email_i18n/locales",
|
||||
"!fluxer_api/pkgs/email/src/email_i18n/weblate",
|
||||
"!fluxer_api/src/api/content_i18n/locales",
|
||||
"!fluxer_api/src/api/content_i18n/weblate",
|
||||
"!packages/errors/src/i18n/locales",
|
||||
"!packages/errors/src/i18n/weblate",
|
||||
"!**/auto-i18n-reviewed-unchanged.json"
|
||||
],
|
||||
"ignoreUnknown": true
|
||||
}
|
||||
|
||||
@@ -12543,7 +12543,6 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"single_community_enabled": {"type": "boolean"},
|
||||
"single_community_locked": {"type": "boolean"},
|
||||
"single_community_guild_id": {"nullable": true, "type": "string"},
|
||||
"direct_messages_disabled": {"type": "boolean"},
|
||||
"direct_messages_locked": {"type": "boolean"},
|
||||
@@ -12574,18 +12573,27 @@
|
||||
"bluesky": {"type": "boolean"}
|
||||
},
|
||||
"required": ["gif", "youtube", "bluesky"]
|
||||
},
|
||||
"deferred_phone_gate": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"window_hours": {"type": "number"},
|
||||
"member_threshold": {"type": "number"}
|
||||
},
|
||||
"required": ["enabled", "window_hours", "member_threshold"]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"single_community_enabled",
|
||||
"single_community_locked",
|
||||
"single_community_guild_id",
|
||||
"direct_messages_disabled",
|
||||
"direct_messages_locked",
|
||||
"premium_mode",
|
||||
"services",
|
||||
"services_resolved",
|
||||
"services_available"
|
||||
"services_available",
|
||||
"deferred_phone_gate"
|
||||
]
|
||||
},
|
||||
"integrations": {
|
||||
@@ -13112,6 +13120,21 @@
|
||||
"youtube_enabled": {"nullable": true, "type": "boolean"},
|
||||
"bluesky_enabled": {"nullable": true, "type": "boolean"}
|
||||
}
|
||||
},
|
||||
"deferred_phone_gate": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean"},
|
||||
"window_hours": {"type": "number", "maximum": 8760, "minimum": 0, "exclusiveMinimum": true},
|
||||
"member_threshold": {
|
||||
"type": "integer",
|
||||
"maximum": 1000000,
|
||||
"format": "int32",
|
||||
"minimum": 0,
|
||||
"exclusiveMinimum": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13646,7 +13669,7 @@
|
||||
"required": ["participants"]
|
||||
},
|
||||
"referenced_message": {
|
||||
"description": "The message that this message is replying to or forwarding",
|
||||
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message carries no default reference. Clients must tell null apart from absent by key presence.",
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -14881,12 +14904,16 @@
|
||||
"premium_grace_ends_at": {"nullable": true, "type": "string"},
|
||||
"premium_lifetime_sequence": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
|
||||
"suspicious_activity_flags": {"$ref": "#/components/schemas/SuspiciousActivityFlags"},
|
||||
"phone_verification_deferred": {
|
||||
"type": "boolean",
|
||||
"description": "Whether a stored phone requirement is deferred until the user joins a discoverable or large community"
|
||||
},
|
||||
"temp_banned_until": {"nullable": true, "type": "string"},
|
||||
"pending_deletion_at": {"nullable": true, "type": "string"},
|
||||
"pending_bulk_message_deletion_at": {"nullable": true, "type": "string"},
|
||||
"deletion_reason_code": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]},
|
||||
"deletion_public_reason": {"nullable": true, "type": "string"},
|
||||
"acls": {"type": "array", "items": {"type": "string"}, "maxItems": 100},
|
||||
"acls": {"type": "array", "items": {"type": "string"}, "maxItems": 115},
|
||||
"traits": {"type": "array", "items": {"type": "string"}, "maxItems": 100},
|
||||
"has_totp": {"type": "boolean"},
|
||||
"authenticator_types": {"type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}, "maxItems": 10},
|
||||
@@ -14921,6 +14948,7 @@
|
||||
"premium_grace_ends_at",
|
||||
"premium_lifetime_sequence",
|
||||
"suspicious_activity_flags",
|
||||
"phone_verification_deferred",
|
||||
"temp_banned_until",
|
||||
"pending_deletion_at",
|
||||
"pending_bulk_message_deletion_at",
|
||||
@@ -15453,7 +15481,7 @@
|
||||
"acls": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"maxItems": 100,
|
||||
"maxItems": 115,
|
||||
"description": "List of access control permissions to assign"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -69,6 +69,7 @@ mod tests {
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0,
|
||||
"phone_verification_deferred": false,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
|
||||
@@ -102,6 +102,8 @@ pub struct AdminUser {
|
||||
#[serde(default)]
|
||||
pub suspicious_activity_flags: i32,
|
||||
#[serde(default)]
|
||||
pub phone_verification_deferred: bool,
|
||||
#[serde(default)]
|
||||
pub has_totp: bool,
|
||||
#[serde(default)]
|
||||
pub authenticator_types: Vec<i32>,
|
||||
|
||||
@@ -24,8 +24,6 @@ pub struct InstanceConfigResponse {
|
||||
pub struct InstancePolicyResponse {
|
||||
#[serde(default)]
|
||||
pub single_community_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub single_community_locked: bool,
|
||||
pub single_community_guild_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub direct_messages_disabled: bool,
|
||||
@@ -39,13 +37,34 @@ pub struct InstancePolicyResponse {
|
||||
pub services_resolved: InstanceServicesResolved,
|
||||
#[serde(default)]
|
||||
pub services_available: InstanceServicesAvailable,
|
||||
#[serde(default)]
|
||||
pub deferred_phone_gate: DeferredPhoneGateResponse,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize)]
|
||||
pub struct DeferredPhoneGateResponse {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub window_hours: f64,
|
||||
#[serde(default)]
|
||||
pub member_threshold: i64,
|
||||
}
|
||||
|
||||
impl Default for DeferredPhoneGateResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
window_hours: 6.0,
|
||||
member_threshold: 50,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InstancePolicyResponse {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
single_community_enabled: false,
|
||||
single_community_locked: false,
|
||||
single_community_guild_id: None,
|
||||
direct_messages_disabled: false,
|
||||
direct_messages_locked: false,
|
||||
@@ -53,6 +72,7 @@ impl Default for InstancePolicyResponse {
|
||||
services: InstanceServicesOverrides::default(),
|
||||
services_resolved: InstanceServicesResolved::default(),
|
||||
services_available: InstanceServicesAvailable::default(),
|
||||
deferred_phone_gate: DeferredPhoneGateResponse::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -519,6 +539,18 @@ pub struct InstancePolicyUpdateRequest {
|
||||
pub premium_mode: Option<PremiumMode>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub services: Option<InstanceServicesUpdateRequest>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub deferred_phone_gate: Option<DeferredPhoneGateUpdateRequest>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct DeferredPhoneGateUpdateRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enabled: Option<bool>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub window_hours: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub member_threshold: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
|
||||
@@ -112,6 +112,7 @@ fn is_urlencoded_form(request: &Request) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
async fn extract_csrf_from_form_body(
|
||||
request: Request,
|
||||
) -> Result<(Request, Option<String>), Response> {
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::{
|
||||
AppBrandingConfigUpdateRequest, AppLegalConfigUpdateRequest,
|
||||
AppPublicConfigUpdateRequest, AppRegistrationConfigUpdateRequest,
|
||||
AppSetupConfigUpdateRequest, CreateRegistrationUrlRequest,
|
||||
GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
DeferredPhoneGateUpdateRequest, GatewayRolloutConfigUpdateRequest, GatewayRolloutMode,
|
||||
InstanceAttachmentDecayUpdateRequest, InstanceBlueskyIntegrationUpdateRequest,
|
||||
InstanceBlueskyKeyIntegrationUpdateRequest, InstanceCaptchaIntegrationUpdateRequest,
|
||||
InstanceConfigUpdateRequest, InstanceEmailIntegrationUpdateRequest,
|
||||
@@ -216,7 +216,11 @@ pub async fn instance_config_post(
|
||||
Err(message) => FlashData::error(message),
|
||||
},
|
||||
"disable_single_community" => {
|
||||
let update = build_disable_single_community_update();
|
||||
let update = build_single_community_update(false);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
}
|
||||
"enable_single_community" => {
|
||||
let update = build_single_community_update(true);
|
||||
instance_config_result(client.update_instance_config(&update).await)
|
||||
}
|
||||
"create_registration_url" => match build_create_registration_url_request(&form) {
|
||||
@@ -543,6 +547,7 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
_ => None,
|
||||
};
|
||||
let services = build_services_update(form);
|
||||
let deferred_phone_gate = build_deferred_phone_gate_update(form);
|
||||
InstanceConfigUpdateRequest {
|
||||
gateway_rollout: None,
|
||||
registration: None,
|
||||
@@ -554,12 +559,37 @@ fn build_policy_update(form: &MultiValueForm) -> InstanceConfigUpdateRequest {
|
||||
direct_messages_disabled,
|
||||
premium_mode,
|
||||
services,
|
||||
deferred_phone_gate,
|
||||
}),
|
||||
integrations: None,
|
||||
media: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_deferred_phone_gate_update(
|
||||
form: &MultiValueForm,
|
||||
) -> Option<DeferredPhoneGateUpdateRequest> {
|
||||
let enabled = form
|
||||
.first("policy_deferred_phone_gate_enabled")
|
||||
.map(|value| value == "true");
|
||||
let window_hours = form
|
||||
.first("policy_deferred_phone_gate_window_hours")
|
||||
.and_then(|value| value.parse::<f64>().ok())
|
||||
.filter(|value| *value > 0.0);
|
||||
let member_threshold = form
|
||||
.first("policy_deferred_phone_gate_member_threshold")
|
||||
.and_then(|value| value.parse::<i64>().ok())
|
||||
.filter(|value| *value > 0);
|
||||
if enabled.is_none() && window_hours.is_none() && member_threshold.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(DeferredPhoneGateUpdateRequest {
|
||||
enabled,
|
||||
window_hours,
|
||||
member_threshold,
|
||||
})
|
||||
}
|
||||
|
||||
fn build_services_update(form: &MultiValueForm) -> Option<InstanceServicesUpdateRequest> {
|
||||
let parse_tristate = |key: &str| match form.first(key) {
|
||||
Some("inherit") => Some(None),
|
||||
@@ -696,18 +726,19 @@ fn build_smtp_test_request(form: &MultiValueForm) -> Result<InstanceEmailSmtpTes
|
||||
})
|
||||
}
|
||||
|
||||
fn build_disable_single_community_update() -> InstanceConfigUpdateRequest {
|
||||
fn build_single_community_update(enabled: bool) -> InstanceConfigUpdateRequest {
|
||||
InstanceConfigUpdateRequest {
|
||||
gateway_rollout: None,
|
||||
registration: None,
|
||||
sso: None,
|
||||
app_public: None,
|
||||
policy: Some(InstancePolicyUpdateRequest {
|
||||
single_community_enabled: Some(false),
|
||||
single_community_enabled: Some(enabled),
|
||||
single_community_name: None,
|
||||
direct_messages_disabled: None,
|
||||
premium_mode: None,
|
||||
services: None,
|
||||
deferred_phone_gate: None,
|
||||
}),
|
||||
integrations: None,
|
||||
media: None,
|
||||
|
||||
@@ -103,6 +103,7 @@ pub fn instance_config_page(
|
||||
instance_config.self_hosted,
|
||||
))
|
||||
(sso_config_section(base, csrf_token, &instance_config.sso))
|
||||
(deferred_phone_gate_form(base, csrf_token, &instance_config.policy))
|
||||
},
|
||||
))
|
||||
@if instance_config.self_hosted {
|
||||
@@ -208,18 +209,14 @@ fn single_community_form(base: &str, csrf_token: &str, policy: &InstancePolicyRe
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Single community" }
|
||||
(badge(status.0, status.1))
|
||||
@if policy.single_community_locked {
|
||||
(badge("Locked", BadgeVariant::Warning))
|
||||
}
|
||||
}
|
||||
@if let Some(guild_id) = policy.single_community_guild_id.as_deref() {
|
||||
p class="break-all text-xs text-neutral-500" { "Community guild ID: " (guild_id) }
|
||||
}
|
||||
@if policy.single_community_enabled && !policy.single_community_locked {
|
||||
@if policy.single_community_enabled {
|
||||
p class="text-sm text-neutral-500" {
|
||||
"This instance funnels every member into a single community. Disabling it is \
|
||||
permanent: single-community mode can only be enabled again from the \
|
||||
self-host setup wizard, never from this panel."
|
||||
"This instance funnels every member into a single community. You can turn this \
|
||||
off and on again from here. The community itself is kept either way."
|
||||
}
|
||||
form method="post" action={(base) "/instance-config?action=disable_single_community"} {
|
||||
(csrf_input(csrf_token))
|
||||
@@ -227,15 +224,21 @@ fn single_community_form(base: &str, csrf_token: &str, policy: &InstancePolicyRe
|
||||
(danger_button("Disable single-community mode"))
|
||||
}))
|
||||
}
|
||||
} @else if policy.single_community_enabled {
|
||||
} @else if policy.single_community_guild_id.is_some() {
|
||||
p class="text-sm text-neutral-500" {
|
||||
"Single-community mode is enabled and locked for this instance. It cannot be \
|
||||
changed from the admin panel."
|
||||
"Single-community mode is off. Turning it on again reuses the community above \
|
||||
if it still exists, otherwise a new one is created."
|
||||
}
|
||||
form method="post" action={(base) "/instance-config?action=enable_single_community"} {
|
||||
(csrf_input(csrf_token))
|
||||
(form_actions(html! {
|
||||
(submit_button("Enable single-community mode"))
|
||||
}))
|
||||
}
|
||||
} @else {
|
||||
p class="text-sm text-neutral-500" {
|
||||
"Single-community mode is off. It can only be turned on from the self-host \
|
||||
setup wizard, not from this panel."
|
||||
"Single-community mode is off. It can only be turned on for the first time \
|
||||
from the self-host setup wizard."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,6 +284,57 @@ fn direct_messages_form(base: &str, csrf_token: &str, policy: &InstancePolicyRes
|
||||
}
|
||||
}
|
||||
|
||||
fn deferred_phone_gate_form(
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
policy: &InstancePolicyResponse,
|
||||
) -> Markup {
|
||||
let gate = &policy.deferred_phone_gate;
|
||||
let status = if gate.enabled {
|
||||
("Enabled", BadgeVariant::Success)
|
||||
} else {
|
||||
("Disabled", BadgeVariant::Default)
|
||||
};
|
||||
html! {
|
||||
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||
div class="flex flex-wrap items-center gap-2" {
|
||||
h3 class="text-sm font-semibold text-neutral-900" { "Deferred phone verification" }
|
||||
(badge(status.0, status.1))
|
||||
}
|
||||
p class="text-sm text-neutral-500" {
|
||||
"When enabled, a phone requirement raised at registration is held back and only \
|
||||
applied if the account joins a discoverable community, or one above the member \
|
||||
threshold, within the window. Accounts that wait out the window are not challenged. \
|
||||
Inbound-SMS requirements are never deferred."
|
||||
}
|
||||
form method="post" action={(base) "/instance-config?action=update_policy"} {
|
||||
(csrf_input(csrf_token))
|
||||
div class="space-y-4" {
|
||||
(select_input("policy_deferred_phone_gate_enabled", "Deferred phone verification", &[
|
||||
("true", "Enabled"),
|
||||
("false", "Disabled"),
|
||||
], if gate.enabled { "true" } else { "false" }))
|
||||
(text_input(
|
||||
"policy_deferred_phone_gate_window_hours",
|
||||
"Window (hours)",
|
||||
&gate.window_hours.to_string(),
|
||||
"6",
|
||||
))
|
||||
(text_input(
|
||||
"policy_deferred_phone_gate_member_threshold",
|
||||
"Member threshold",
|
||||
&gate.member_threshold.to_string(),
|
||||
"50",
|
||||
))
|
||||
(form_actions(html! {
|
||||
(submit_button("Save deferred phone verification"))
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn premium_mode_form(base: &str, csrf_token: &str, policy: &InstancePolicyResponse) -> Markup {
|
||||
html! {
|
||||
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
||||
|
||||
@@ -291,6 +291,11 @@ fn flags_card(
|
||||
can_update_suspicious,
|
||||
Some(acl::USER_UPDATE_SUSPICIOUS_ACTIVITY),
|
||||
))
|
||||
@if user.phone_verification_deferred {
|
||||
p class="text-sm text-amber-700 dark:text-amber-400" {
|
||||
"Phone verification is deferred: the requirement above is stored but not enforced until this user joins a discoverable or large community within the deferral window."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -434,41 +434,6 @@ async fn mutating_admin_pages_render_usable_csrf_tokens() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosted_instance_config_hides_self_host_setup_controls() {
|
||||
let app = setup().await;
|
||||
let body = get(&app, "/instance-config", &[]).await;
|
||||
|
||||
assert_full_layout(&body);
|
||||
assert!(body.contains("Registration Controls"), "{body}");
|
||||
assert!(body.contains("Runtime Integrations"), "{body}");
|
||||
assert!(body.contains("Gateway Rollout Configuration"), "{body}");
|
||||
assert!(!body.contains("Public App Identity"), "{body}");
|
||||
assert!(!body.contains("Setup complete"), "{body}");
|
||||
assert!(!body.contains("Community & Policy"), "{body}");
|
||||
assert!(!body.contains("Single community"), "{body}");
|
||||
assert!(!body.contains("Direct messages & friends"), "{body}");
|
||||
assert!(!body.contains("Premium model"), "{body}");
|
||||
assert!(!body.contains("Optional services"), "{body}");
|
||||
assert!(!body.contains("Registration Fields"), "{body}");
|
||||
assert!(
|
||||
!body.contains("Collect date of birth during registration"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("/instance-config?action=update_app_public"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("/instance-config?action=update_app_registration"),
|
||||
"{body}"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("/instance-config?action=update_policy"),
|
||||
"{body}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn instance_config_registration_tables_show_copyable_urls_and_compact_pending_actions() {
|
||||
let app = setup().await;
|
||||
@@ -898,6 +863,7 @@ fn user(id: &str, username: &str) -> Value {
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0,
|
||||
"phone_verification_deferred": false,
|
||||
"has_totp": false,
|
||||
"authenticator_types": [],
|
||||
"has_verified_phone": false,
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0,
|
||||
"phone_verification_deferred": false,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0,
|
||||
"phone_verification_deferred": false,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"premium_grace_ends_at": null,
|
||||
"premium_lifetime_sequence": null,
|
||||
"suspicious_activity_flags": 0,
|
||||
"phone_verification_deferred": false,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {ElasticsearchIndexDefinition} from '../ElasticsearchIndexDefinition
|
||||
const ELASTICSEARCH_MAX_RESULT_WINDOW = 10000;
|
||||
const DEEP_PAGINATION_BATCH_SIZE = 1000;
|
||||
const MAX_SEARCH_LIMIT = 1000;
|
||||
const FACET_TERM_LIMIT = 200;
|
||||
|
||||
interface ElasticsearchSearchHit<TResult> {
|
||||
_source?: TResult;
|
||||
@@ -17,6 +18,10 @@ interface ElasticsearchSearchHit<TResult> {
|
||||
sort?: SortResults;
|
||||
}
|
||||
|
||||
interface ElasticsearchTermsAggregation {
|
||||
buckets?: Array<{key: string | number; doc_count: number}>;
|
||||
}
|
||||
|
||||
interface ElasticsearchSearchResponse<TResult> {
|
||||
hits: {
|
||||
total?:
|
||||
@@ -26,6 +31,7 @@ interface ElasticsearchSearchResponse<TResult> {
|
||||
};
|
||||
hits: Array<ElasticsearchSearchHit<TResult>>;
|
||||
};
|
||||
aggregations?: Record<string, ElasticsearchTermsAggregation>;
|
||||
}
|
||||
|
||||
type ElasticsearchBaseSearchRequest = Omit<SearchRequest, 'from' | 'search_after' | 'size' | 'track_total_hits'>;
|
||||
@@ -265,6 +271,7 @@ export class ElasticsearchIndexAdapter<
|
||||
},
|
||||
]
|
||||
: [{match_all: {}}];
|
||||
const facets = options?.facets;
|
||||
const searchParams: ElasticsearchBaseSearchRequest = {
|
||||
index: this.indexDefinition.indexName,
|
||||
query: {
|
||||
@@ -273,6 +280,11 @@ export class ElasticsearchIndexAdapter<
|
||||
filter: filterClauses.length > 0 ? filterClauses : undefined,
|
||||
},
|
||||
},
|
||||
...(facets && facets.length > 0
|
||||
? {
|
||||
aggs: Object.fromEntries(facets.map((facet) => [facet, {terms: {field: facet, size: FACET_TERM_LIMIT}}])),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const defaultSort: SortCombinations = {id: {order: 'desc'}};
|
||||
const effectiveSort: NonNullable<SearchRequest['sort']> =
|
||||
@@ -357,13 +369,30 @@ export class ElasticsearchIndexAdapter<
|
||||
}
|
||||
|
||||
private toSearchResult(result: ElasticsearchSearchResponse<TResult>): SearchResult<TResult> {
|
||||
const facetCounts = this.toFacetCounts(result.aggregations);
|
||||
return {
|
||||
hits: this.mapHits(result.hits.hits),
|
||||
total: this.getTotalHits(result),
|
||||
cursor: result.hits.hits.at(-1)?.sort?.map((value) => String(value)),
|
||||
...(facetCounts ? {facetCounts} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private toFacetCounts(
|
||||
aggregations: Record<string, ElasticsearchTermsAggregation> | undefined,
|
||||
): Record<string, Record<string, number>> | undefined {
|
||||
if (!aggregations) {
|
||||
return undefined;
|
||||
}
|
||||
const counts: Record<string, Record<string, number>> = {};
|
||||
for (const [facet, aggregation] of Object.entries(aggregations)) {
|
||||
counts[facet] = Object.fromEntries(
|
||||
(aggregation.buckets ?? []).map((bucket) => [String(bucket.key), bucket.doc_count]),
|
||||
);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
private mapHits(hits: Array<ElasticsearchSearchHit<TResult>>): Array<TResult> {
|
||||
return hits.map((hit) => ({...hit._source!, id: hit._id!}));
|
||||
}
|
||||
|
||||
@@ -29,12 +29,16 @@
|
||||
"main": "./src/MediaProxyUtils.ts",
|
||||
"types": "./src/MediaProxyUtils.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "catalog:"
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"vitest": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
buildExternalMediaProxyPath,
|
||||
buildOpaqueExternalMediaProxyPath,
|
||||
reconstructOriginalUrl,
|
||||
} from '@pkgs/media_proxy_utils/src/ExternalMediaProxyPathCodec';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const ROUND_TRIP_URLS = [
|
||||
'https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp',
|
||||
'https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here',
|
||||
'https://example.com:8443/a.png',
|
||||
'https://avatars.githubusercontent.com/u/241303489?v=4',
|
||||
'http://example.com/plain.gif',
|
||||
'https://example.com/deep/nested/path/to/file.jpeg',
|
||||
'https://example.com/file.png?a=1&b=2&c=3',
|
||||
'https://example.com/spaced%20name.png',
|
||||
'https://example.com/unicode/%C3%A5%C3%A4%C3%B6.png',
|
||||
'https://example.com/file.png?redirect=https%3A%2F%2Fother.example%2Fx.png',
|
||||
'https://sub.domain.example.co.uk/a/b.webp',
|
||||
];
|
||||
|
||||
describe('buildExternalMediaProxyPath', () => {
|
||||
it('emits the plain path shape with the extension last', () => {
|
||||
expect(buildExternalMediaProxyPath('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp')).toBe(
|
||||
'https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp',
|
||||
);
|
||||
});
|
||||
|
||||
it('puts an encoded query, leading question mark included, ahead of the protocol', () => {
|
||||
expect(buildExternalMediaProxyPath('https://static.klipy.com/ii/HkAKKCzZ.webp?v=query_param&goes=here')).toBe(
|
||||
'%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/HkAKKCzZ.webp',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a non default port on the host segment', () => {
|
||||
expect(buildExternalMediaProxyPath('https://example.com:8443/a.png')).toBe('https/example.com:8443/a.png');
|
||||
});
|
||||
|
||||
it('preserves the http scheme', () => {
|
||||
expect(buildExternalMediaProxyPath('http://example.com/a.gif')).toBe('http/example.com/a.gif');
|
||||
});
|
||||
|
||||
it('handles a root url with no path', () => {
|
||||
expect(buildExternalMediaProxyPath('https://example.com/')).toBe('https/example.com');
|
||||
});
|
||||
|
||||
it('never emits the v2 prefix any more', () => {
|
||||
for (const url of ROUND_TRIP_URLS) {
|
||||
expect(buildExternalMediaProxyPath(url).startsWith('v2/')).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('ends in the source file extension so extension based cdn caching applies', () => {
|
||||
for (const [url, ext] of [
|
||||
['https://example.com/a.webp', '.webp'],
|
||||
['https://example.com/a.png?x=1', '.png'],
|
||||
['https://example.com/a/b/c.jpeg', '.jpeg'],
|
||||
] as const) {
|
||||
expect(buildExternalMediaProxyPath(url).endsWith(ext)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a url it cannot parse', () => {
|
||||
expect(() => buildExternalMediaProxyPath('not a url')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconstructOriginalUrl', () => {
|
||||
it('round trips every supported shape', () => {
|
||||
for (const url of ROUND_TRIP_URLS) {
|
||||
expect(reconstructOriginalUrl(buildExternalMediaProxyPath(url))).toBe(url);
|
||||
}
|
||||
});
|
||||
|
||||
it('decodes an externally produced path verbatim', () => {
|
||||
expect(
|
||||
reconstructOriginalUrl('%3Fv%3Dquery_param%26goes%3Dhere/https/static.klipy.com/ii/c8/28/HkAKKCzZ.webp'),
|
||||
).toBe('https://static.klipy.com/ii/c8/28/HkAKKCzZ.webp?v=query_param&goes=here');
|
||||
});
|
||||
|
||||
it('does not double the question mark when the query segment carries one', () => {
|
||||
const decoded = reconstructOriginalUrl('%3Fa%3D1/https/example.com/x.png');
|
||||
expect(decoded).toBe('https://example.com/x.png?a=1');
|
||||
expect(decoded).not.toContain('??');
|
||||
});
|
||||
|
||||
it('still accepts a query segment without a leading question mark', () => {
|
||||
expect(reconstructOriginalUrl('a%3D1/https/example.com/x.png')).toBe('https://example.com/x.png?a=1');
|
||||
});
|
||||
|
||||
it('still decodes v2 paths so links already sent keep working', () => {
|
||||
expect(reconstructOriginalUrl(buildOpaqueExternalMediaProxyPath('https://example.com/a.png?x=1'))).toBe(
|
||||
'https://example.com/a.png?x=1',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a path that has no host after the protocol', () => {
|
||||
expect(() => reconstructOriginalUrl('https')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects an empty v2 payload', () => {
|
||||
expect(() => reconstructOriginalUrl('v2/')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -2,21 +2,21 @@
|
||||
|
||||
const BASE64_URL_PADDING_REGEX = /=*$/;
|
||||
const LEGACY_PROTOCOL_REGEX = /^[A-Za-z][A-Za-z0-9+.-]*$/;
|
||||
const V2_PATH_PREFIX = 'v2/';
|
||||
const OPAQUE_PATH_PREFIX = 'v2/';
|
||||
|
||||
function encodeV2PathComponent(value: string): string {
|
||||
function encodeOpaquePathComponent(value: string): string {
|
||||
return Buffer.from(value, 'utf8').toString('base64url').replace(BASE64_URL_PADDING_REGEX, '');
|
||||
}
|
||||
|
||||
function decodeV2PathComponent(value: string): string {
|
||||
function decodeOpaquePathComponent(value: string): string {
|
||||
return Buffer.from(value, 'base64url').toString('utf8');
|
||||
}
|
||||
|
||||
function decodeLegacyComponent(component: string): string {
|
||||
function decodeSegmentedComponent(component: string): string {
|
||||
return decodeURIComponent(component);
|
||||
}
|
||||
|
||||
function getLegacyProtocolIndex(parts: Array<string>): number {
|
||||
function getSegmentedProtocolIndex(parts: Array<string>): number {
|
||||
const firstPart = parts[0];
|
||||
if (firstPart && LEGACY_PROTOCOL_REGEX.test(firstPart)) {
|
||||
return 0;
|
||||
@@ -33,15 +33,15 @@ function getLegacyProtocolIndex(parts: Array<string>): number {
|
||||
throw new Error('Protocol is missing in the proxy URL path.');
|
||||
}
|
||||
|
||||
interface LegacyHostAndPort {
|
||||
interface SegmentedHostAndPort {
|
||||
hostname: string;
|
||||
port: string;
|
||||
}
|
||||
|
||||
function decodeLegacyHostAndPort(hostPart: string): LegacyHostAndPort {
|
||||
function decodeSegmentedHostAndPort(hostPart: string): SegmentedHostAndPort {
|
||||
const separatorIndex = hostPart.lastIndexOf(':');
|
||||
if (separatorIndex === -1) {
|
||||
const hostname = decodeLegacyComponent(hostPart);
|
||||
const hostname = decodeSegmentedComponent(hostPart);
|
||||
if (!hostname) {
|
||||
throw new Error('Hostname is invalid in the proxy URL path.');
|
||||
}
|
||||
@@ -53,14 +53,14 @@ function decodeLegacyHostAndPort(hostPart: string): LegacyHostAndPort {
|
||||
throw new Error('Hostname is invalid in the proxy URL path.');
|
||||
}
|
||||
return {
|
||||
hostname: decodeLegacyComponent(encodedHostname),
|
||||
port: encodedPort ? decodeLegacyComponent(encodedPort) : '',
|
||||
hostname: decodeSegmentedComponent(encodedHostname),
|
||||
port: encodedPort ? decodeSegmentedComponent(encodedPort) : '',
|
||||
};
|
||||
}
|
||||
|
||||
function reconstructLegacyOriginalUrl(proxyUrlPath: string): string {
|
||||
function reconstructSegmentedOriginalUrl(proxyUrlPath: string): string {
|
||||
const parts = proxyUrlPath.split('/');
|
||||
const protocolIndex = getLegacyProtocolIndex(parts);
|
||||
const protocolIndex = getSegmentedProtocolIndex(parts);
|
||||
const protocol = parts[protocolIndex];
|
||||
if (!protocol) {
|
||||
throw new Error('Protocol is missing in the proxy URL path.');
|
||||
@@ -71,29 +71,47 @@ function reconstructLegacyOriginalUrl(proxyUrlPath: string): string {
|
||||
}
|
||||
const encodedQuery = parts.slice(0, protocolIndex).join('/');
|
||||
const encodedPath = parts.slice(protocolIndex + 2).join('/');
|
||||
const query = encodedQuery ? decodeLegacyComponent(encodedQuery) : '';
|
||||
const path = decodeLegacyComponent(encodedPath);
|
||||
const {hostname, port} = decodeLegacyHostAndPort(hostPart);
|
||||
return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${query ? `?${query}` : ''}`;
|
||||
const query = encodedQuery ? decodeSegmentedComponent(encodedQuery) : '';
|
||||
const path = decodeSegmentedComponent(encodedPath);
|
||||
const {hostname, port} = decodeSegmentedHostAndPort(hostPart);
|
||||
const normalizedQuery = query.startsWith('?') ? query.slice(1) : query;
|
||||
return `${protocol}://${hostname}${port ? `:${port}` : ''}/${path}${normalizedQuery ? `?${normalizedQuery}` : ''}`;
|
||||
}
|
||||
|
||||
function reconstructV2OriginalUrl(proxyUrlPath: string): string {
|
||||
const encodedOriginalUrl = proxyUrlPath.slice(V2_PATH_PREFIX.length);
|
||||
function reconstructOpaqueOriginalUrl(proxyUrlPath: string): string {
|
||||
const encodedOriginalUrl = proxyUrlPath.slice(OPAQUE_PATH_PREFIX.length);
|
||||
if (!encodedOriginalUrl) {
|
||||
throw new Error('Encoded URL is missing in the proxy URL path.');
|
||||
}
|
||||
return decodeV2PathComponent(encodedOriginalUrl);
|
||||
return decodeOpaquePathComponent(encodedOriginalUrl);
|
||||
}
|
||||
|
||||
export function buildOpaqueExternalMediaProxyPath(inputUrl: string): string {
|
||||
const parsedUrl = new URL(inputUrl);
|
||||
return `${OPAQUE_PATH_PREFIX}${encodeOpaquePathComponent(parsedUrl.toString())}`;
|
||||
}
|
||||
|
||||
export function buildExternalMediaProxyPath(inputUrl: string): string {
|
||||
const parsedUrl = new URL(inputUrl);
|
||||
return `${V2_PATH_PREFIX}${encodeV2PathComponent(parsedUrl.toString())}`;
|
||||
const protocol = parsedUrl.protocol.replace(/:$/u, '');
|
||||
const host = parsedUrl.port ? `${parsedUrl.hostname}:${parsedUrl.port}` : parsedUrl.hostname;
|
||||
const path = parsedUrl.pathname
|
||||
.replace(/^\//u, '')
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment))
|
||||
.join('/');
|
||||
const segments = parsedUrl.search ? [encodeURIComponent(parsedUrl.search)] : [];
|
||||
segments.push(protocol, host);
|
||||
if (path) {
|
||||
segments.push(path);
|
||||
}
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
export function reconstructOriginalUrl(proxyUrlPath: string): string {
|
||||
const reconstructedUrl = proxyUrlPath.startsWith(V2_PATH_PREFIX)
|
||||
? reconstructV2OriginalUrl(proxyUrlPath)
|
||||
: reconstructLegacyOriginalUrl(proxyUrlPath);
|
||||
const reconstructedUrl = proxyUrlPath.startsWith(OPAQUE_PATH_PREFIX)
|
||||
? reconstructOpaqueOriginalUrl(proxyUrlPath)
|
||||
: reconstructSegmentedOriginalUrl(proxyUrlPath);
|
||||
new URL(reconstructedUrl);
|
||||
return reconstructedUrl;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../tsconfigs/package.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@fluxer/*": ["../../../packages/*", "../../../packages/*/src/index.ts"],
|
||||
"@pkgs/*": ["../*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import path from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
root: path.resolve(__dirname, '../..'),
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['**/*.{test,spec}.{ts,tsx}'],
|
||||
exclude: ['node_modules', 'dist'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {MasterConfig} from '@fluxer/config/src/MasterConfig';
|
||||
import {resolveDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
|
||||
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {parseGeoipSourceConfig, resolveGeoipRuntimeSourceConfig} from '@pkgs/geoip/src/GeoipStartup';
|
||||
import type {APIConfig, BlueskyOAuthConfig} from './config/APIConfig';
|
||||
@@ -237,6 +238,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
cacheMinTtlSeconds: master.services.api.embeds.cache_min_ttl_seconds,
|
||||
cacheRespectRemoteTtl: master.services.api.embeds.cache_respect_remote_ttl,
|
||||
},
|
||||
s3Downloads: resolveDownloadsProvider(master),
|
||||
s3: {
|
||||
endpoint: s3Config.endpoint,
|
||||
presignedUrlBase: s3Config.presigned_url_base,
|
||||
@@ -429,6 +431,8 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
testHarnessToken: master.dev.test_harness_token,
|
||||
},
|
||||
presignedAttachmentUploadsEnabled: master.services.api.presigned_attachment_uploads_enabled ?? false,
|
||||
presignedDownloadsEnabled: master.services.api.presigned_downloads_enabled ?? false,
|
||||
presignedHarvestDownloadsEnabled: master.services.api.presigned_harvest_downloads_enabled ?? true,
|
||||
attachmentDecayEnabled: master.attachment_decay_enabled,
|
||||
deletionGracePeriodHours: master.dev.test_mode_enabled ? 0.01 : master.deletion_grace_period_hours,
|
||||
inactivityDeletionThresholdDays: master.inactivity_deletion_threshold_days,
|
||||
@@ -460,6 +464,14 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
taskName: apiWorkerConfig?.task as WorkerTaskName | undefined,
|
||||
enableCronScheduler: apiWorkerConfig?.enable_cron_scheduler,
|
||||
enableVoiceReconciliation: apiWorkerConfig?.enable_voice_reconciliation ?? true,
|
||||
voiceReconciliation: {
|
||||
intervalMs: apiWorkerConfig?.voice_reconciliation?.interval_ms,
|
||||
staggerDelayMs: apiWorkerConfig?.voice_reconciliation?.stagger_delay_ms,
|
||||
lockTtlSeconds: apiWorkerConfig?.voice_reconciliation?.lock_ttl_seconds,
|
||||
cadenceTtlSeconds: apiWorkerConfig?.voice_reconciliation?.cadence_ttl_seconds,
|
||||
gatewayOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.gateway_only_grace_ms,
|
||||
liveKitOnlyGraceMs: apiWorkerConfig?.voice_reconciliation?.livekit_only_grace_ms,
|
||||
},
|
||||
laneConcurrencyOverrides: {
|
||||
realtime: apiWorkerConfig?.lane_concurrency_overrides?.realtime,
|
||||
unfurl: apiWorkerConfig?.lane_concurrency_overrides?.unfurl,
|
||||
|
||||
@@ -742,6 +742,9 @@ class BillingAdminControllerService {
|
||||
refundTarget,
|
||||
subscription,
|
||||
});
|
||||
if (refundDecision.amountCents !== null && !refundTarget) {
|
||||
throw new StripeError('No paid Stripe invoice with a refundable payment was found for this subscription');
|
||||
}
|
||||
const intentId = await this.billingRepository.actionIntents.create({
|
||||
userId: BigInt(syncedTargetUser.id),
|
||||
actorAdminId: BigInt(params.adminUserId),
|
||||
@@ -758,10 +761,7 @@ class BillingAdminControllerService {
|
||||
await this.billingRepository.actionIntents.markStage(intentId, 'sub_canceled', {
|
||||
sub_canceled_at: new Date(),
|
||||
});
|
||||
if (refundDecision.amountCents !== null) {
|
||||
if (!refundTarget) {
|
||||
throw new StripeError('No paid Stripe invoice with a refundable payment was found for this subscription');
|
||||
}
|
||||
if (refundDecision.amountCents !== null && refundTarget) {
|
||||
refund = await this.stripe.refunds.create(
|
||||
{
|
||||
...(refundTarget.paymentIntentId
|
||||
|
||||
@@ -91,7 +91,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
app_public: appPublic,
|
||||
policy: {
|
||||
single_community_enabled: policy.single_community_enabled,
|
||||
single_community_locked: policy.single_community_locked,
|
||||
single_community_guild_id: policy.single_community_guild_id,
|
||||
direct_messages_disabled: policy.direct_messages_disabled,
|
||||
direct_messages_locked: policy.direct_messages_locked,
|
||||
@@ -101,6 +100,11 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
youtube_enabled: policy.youtube_enabled,
|
||||
bluesky_enabled: policy.bluesky_enabled,
|
||||
},
|
||||
deferred_phone_gate: {
|
||||
enabled: policy.deferred_phone_gate_enabled,
|
||||
window_hours: policy.deferred_phone_gate_window_hours,
|
||||
member_threshold: policy.deferred_phone_gate_member_threshold,
|
||||
},
|
||||
services_resolved: resolvedServices,
|
||||
services_available: {
|
||||
gif: integrations.gif.effective_available,
|
||||
@@ -551,20 +555,19 @@ async function applyInstancePolicyUpdate(
|
||||
policy.single_community_enabled !== current.single_community_enabled
|
||||
) {
|
||||
if (policy.single_community_enabled) {
|
||||
if (appPublic.setup.configured || current.single_community_locked) {
|
||||
if (appPublic.setup.configured && current.single_community_guild_id == null) {
|
||||
throw new InstancePolicyTransitionNotAllowedError();
|
||||
}
|
||||
const adminUser = await ctx.get('userRepository').findUnique(ctx.get('adminUserId'));
|
||||
if (!adminUser) {
|
||||
throw new InstancePolicyTransitionNotAllowedError();
|
||||
}
|
||||
await ctx.get('singleCommunityService').createStockCommunity({
|
||||
await ctx.get('singleCommunityService').ensureStockCommunity({
|
||||
owner: adminUser,
|
||||
name: policy.single_community_name?.trim() || appPublic.branding.product_name,
|
||||
});
|
||||
} else {
|
||||
patch.single_community_enabled = false;
|
||||
patch.single_community_locked = true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
@@ -593,6 +596,17 @@ async function applyInstancePolicyUpdate(
|
||||
patch.bluesky_enabled = policy.services.bluesky_enabled ?? null;
|
||||
}
|
||||
}
|
||||
if (policy.deferred_phone_gate) {
|
||||
if (policy.deferred_phone_gate.enabled !== undefined) {
|
||||
patch.deferred_phone_gate_enabled = policy.deferred_phone_gate.enabled;
|
||||
}
|
||||
if (policy.deferred_phone_gate.window_hours !== undefined) {
|
||||
patch.deferred_phone_gate_window_hours = policy.deferred_phone_gate.window_hours;
|
||||
}
|
||||
if (policy.deferred_phone_gate.member_threshold !== undefined) {
|
||||
patch.deferred_phone_gate_member_threshold = policy.deferred_phone_gate.member_threshold;
|
||||
}
|
||||
}
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.setInstancePolicyConfig(patch);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import dns from 'node:dns';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {DEFERRED_PHONE_ON_COMMUNITY_JOIN} from '@fluxer/constants/src/UserConstants';
|
||||
import type {UserAdminResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import {formatGeoipLocation} from '@pkgs/geoip/src/GeoipLookup';
|
||||
@@ -82,7 +83,9 @@ export async function mapUserToAdminResponse(
|
||||
premium_grace_ends_at: user.premiumGraceEndsAt?.toISOString() ?? null,
|
||||
premium_lifetime_sequence: user.premiumLifetimeSequence ?? null,
|
||||
suspicious_activity_flags: user.suspiciousActivityFlags,
|
||||
temp_banned_until: user.tempBannedUntil?.toISOString() ?? null,
|
||||
phone_verification_deferred: ((user.suspiciousActivityFlags ?? 0) & DEFERRED_PHONE_ON_COMMUNITY_JOIN) !== 0,
|
||||
temp_banned_until:
|
||||
user.tempBannedUntil && user.tempBannedUntil.getTime() > Date.now() ? user.tempBannedUntil.toISOString() : null,
|
||||
pending_deletion_at: user.pendingDeletionAt?.toISOString() ?? null,
|
||||
pending_bulk_message_deletion_at: user.pendingBulkMessageDeletionAt?.toISOString() ?? null,
|
||||
deletion_reason_code: user.deletionReasonCode,
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {SuspiciousActivityFlags, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {
|
||||
ADMIN_PHONE_TOGGLE_CLEARABLE_FLAGS,
|
||||
ALL_SUSPICIOUS_ACTIVITY_FLAGS,
|
||||
DEFERRABLE_PHONE_FLAGS,
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
imposePhoneRequirements,
|
||||
SuspiciousActivityFlags,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {AccessDeniedError} from '@fluxer/errors/src/domains/core/AccessDeniedError';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
@@ -31,11 +39,13 @@ import * as AuthMfa from '../../auth/AuthMfa';
|
||||
import * as AuthSession from '../../auth/AuthSession';
|
||||
import * as AuthUtility from '../../auth/AuthUtility';
|
||||
import {createPasswordResetToken, createUserID, type UserID} from '../../BrandedTypes';
|
||||
import type {UserRow} from '../../database/types/UserTypes';
|
||||
import {Logger} from '../../Logger';
|
||||
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
|
||||
import type {IRiskHistoryRepository} from '../../risk/HistoricalOutcomeRepository';
|
||||
import type {HistoricalOutcomeCode} from '../../risk/RiskHistoryTypes';
|
||||
import {getIpAddressReverse, getLocationLabelFromIp} from '../../utils/IpUtils';
|
||||
import {resolveSessionClientInfo} from '../../utils/UserAgentUtils';
|
||||
import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity';
|
||||
import {mapUserToAdminResponse} from '../models/UserTypes';
|
||||
import type {AdminAuditService} from './AdminAuditService';
|
||||
import type {AdminUserUpdatePropagator} from './AdminUserUpdatePropagator';
|
||||
@@ -406,11 +416,14 @@ export class AdminUserSecurityService {
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{has_verified_phone: data.has_verified_phone},
|
||||
user.toRow(),
|
||||
);
|
||||
const phonePatch: Partial<UserRow> = {has_verified_phone: data.has_verified_phone};
|
||||
if (data.has_verified_phone) {
|
||||
const clearedFlags = (user.suspiciousActivityFlags ?? 0) & ~ADMIN_PHONE_TOGGLE_CLEARABLE_FLAGS;
|
||||
if (clearedFlags !== (user.suspiciousActivityFlags ?? 0)) {
|
||||
phonePatch.suspicious_activity_flags = clearedFlags;
|
||||
}
|
||||
}
|
||||
const updatedUser = await userRepository.patchUpsert(userId, phonePatch, user.toRow());
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser});
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
@@ -418,7 +431,15 @@ export class AdminUserSecurityService {
|
||||
targetId: BigInt(userId),
|
||||
action: 'update_has_verified_phone',
|
||||
auditLogReason,
|
||||
metadata: new Map([['has_verified_phone', String(data.has_verified_phone)]]),
|
||||
metadata: new Map(
|
||||
phonePatch.suspicious_activity_flags === undefined
|
||||
? [['has_verified_phone', String(data.has_verified_phone)]]
|
||||
: [
|
||||
['has_verified_phone', String(data.has_verified_phone)],
|
||||
['suspicious_activity_flags_before', String(user.suspiciousActivityFlags ?? 0)],
|
||||
['suspicious_activity_flags_after', String(phonePatch.suspicious_activity_flags)],
|
||||
],
|
||||
),
|
||||
});
|
||||
return {
|
||||
user: await mapUserToAdminResponse(updatedUser, cacheService, acls),
|
||||
@@ -438,15 +459,24 @@ export class AdminUserSecurityService {
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const currentFlags = user.suspiciousActivityFlags ?? 0;
|
||||
const keepsDeferral =
|
||||
(currentFlags & DEFERRED_PHONE_ON_COMMUNITY_JOIN) !== 0 &&
|
||||
(data.flags & DEFERRABLE_PHONE_FLAGS) !== 0 &&
|
||||
(data.flags & DEFERRABLE_PHONE_FLAGS) === (currentFlags & DEFERRABLE_PHONE_FLAGS);
|
||||
const newFlags = keepsDeferral ? data.flags | DEFERRED_PHONE_ON_COMMUNITY_JOIN : data.flags;
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
suspicious_activity_flags: data.flags,
|
||||
suspicious_activity_flags: newFlags,
|
||||
},
|
||||
user.toRow(),
|
||||
);
|
||||
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
|
||||
if ((user.suspiciousActivityFlags ?? 0) !== data.flags && data.flags !== 0) {
|
||||
if (
|
||||
(currentFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) !== (newFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) &&
|
||||
(newFlags & ALL_SUSPICIOUS_ACTIVITY_FLAGS) !== 0
|
||||
) {
|
||||
await this.recordRiskOutcomes(userId, ['challenged'], 'admin_update_suspicious_activity_flags');
|
||||
}
|
||||
await auditService.createAuditLog({
|
||||
@@ -600,7 +630,7 @@ export class AdminUserSecurityService {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const currentFlags = user.suspiciousActivityFlags ?? 0;
|
||||
const newFlags = (currentFlags | addMask) & ~removeMask;
|
||||
const newFlags = imposePhoneRequirements(currentFlags, addMask) & ~removeMask;
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{suspicious_activity_flags: newFlags},
|
||||
@@ -720,7 +750,7 @@ export class AdminUserSecurityService {
|
||||
approximateLastUsedAt: Date;
|
||||
clientIp: string;
|
||||
clientUserAgent: string | null;
|
||||
clientIsDesktop: boolean | null;
|
||||
clientOs: string | null;
|
||||
deletedAt: Date | null;
|
||||
}> = [
|
||||
...activeSessions.map((s) => ({
|
||||
@@ -729,7 +759,7 @@ export class AdminUserSecurityService {
|
||||
approximateLastUsedAt: s.approximateLastUsedAt,
|
||||
clientIp: s.clientIp,
|
||||
clientUserAgent: s.clientUserAgent,
|
||||
clientIsDesktop: s.clientIsDesktop,
|
||||
clientOs: s.clientOs ?? null,
|
||||
deletedAt: null as Date | null,
|
||||
})),
|
||||
...tombstones.map((t) => ({
|
||||
@@ -738,7 +768,7 @@ export class AdminUserSecurityService {
|
||||
approximateLastUsedAt: t.approximateLastUsedAt,
|
||||
clientIp: t.clientIp,
|
||||
clientUserAgent: t.clientUserAgent,
|
||||
clientIsDesktop: t.clientIsDesktop,
|
||||
clientOs: t.clientOs ?? null,
|
||||
deletedAt: t.deletedAt,
|
||||
})),
|
||||
];
|
||||
@@ -747,6 +777,8 @@ export class AdminUserSecurityService {
|
||||
if (a.deletedAt !== null && b.deletedAt === null) return 1;
|
||||
return b.createdAt.getTime() - a.createdAt.getTime();
|
||||
});
|
||||
const {branding} = await getInstanceConfigRepository().getAppPublicConfig();
|
||||
const productName = branding.product_name;
|
||||
const canViewIp = acls.has(AdminACLs.USER_VIEW_IP) || acls.has(AdminACLs.WILDCARD);
|
||||
if (!canViewIp) {
|
||||
await auditService.createAuditLog({
|
||||
@@ -759,9 +791,10 @@ export class AdminUserSecurityService {
|
||||
});
|
||||
return {
|
||||
sessions: entries.map((entry) => {
|
||||
const {clientOs, clientPlatform} = resolveSessionClientInfo({
|
||||
const clientInfo = resolveSessionClientInfo({
|
||||
userAgent: entry.clientUserAgent,
|
||||
isDesktopClient: entry.clientIsDesktop,
|
||||
reportedOs: entry.clientOs,
|
||||
productName,
|
||||
});
|
||||
return {
|
||||
session_id_hash: entry.sessionIdHash.toString('base64url'),
|
||||
@@ -769,8 +802,8 @@ export class AdminUserSecurityService {
|
||||
approx_last_used_at: entry.approximateLastUsedAt.toISOString(),
|
||||
client_ip: '[redacted]',
|
||||
client_ip_reverse: null,
|
||||
client_os: clientOs,
|
||||
client_platform: clientPlatform,
|
||||
client_os: clientInfo.os,
|
||||
client_platform: clientInfo.platform,
|
||||
client_location: null,
|
||||
deleted_at: entry.deletedAt?.toISOString() ?? null,
|
||||
};
|
||||
@@ -807,9 +840,10 @@ export class AdminUserSecurityService {
|
||||
const clientLocation = locationResult.status === 'fulfilled' ? locationResult.value : null;
|
||||
const reverseDnsResult = reverseDnsResults[index];
|
||||
const clientIpReverse = reverseDnsResult?.status === 'fulfilled' ? reverseDnsResult.value : null;
|
||||
const {clientOs, clientPlatform} = resolveSessionClientInfo({
|
||||
const clientInfo = resolveSessionClientInfo({
|
||||
userAgent: entry.clientUserAgent,
|
||||
isDesktopClient: entry.clientIsDesktop,
|
||||
reportedOs: entry.clientOs,
|
||||
productName,
|
||||
});
|
||||
return {
|
||||
session_id_hash: entry.sessionIdHash.toString('base64url'),
|
||||
@@ -817,8 +851,8 @@ export class AdminUserSecurityService {
|
||||
approx_last_used_at: entry.approximateLastUsedAt.toISOString(),
|
||||
client_ip: entry.clientIp,
|
||||
client_ip_reverse: clientIpReverse,
|
||||
client_os: clientOs,
|
||||
client_platform: clientPlatform,
|
||||
client_os: clientInfo.os,
|
||||
client_platform: clientInfo.platform,
|
||||
client_location: clientLocation,
|
||||
deleted_at: entry.deletedAt?.toISOString() ?? null,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {MEDIA_PROXY_ICON_SIZE_DEFAULT} from '@fluxer/constants/src/MediaProxyAssetSizes';
|
||||
import type {MediaProxyImageSize} from '@fluxer/constants/src/MediaProxyImageSizes';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import type {
|
||||
ListGuildMembersRequest,
|
||||
@@ -18,6 +19,8 @@ import type {IGatewayService} from '../../../infrastructure/IGatewayService';
|
||||
import type {IUserRepository} from '../../../user/IUserRepository';
|
||||
import {mapGuildsToAdminResponse} from '../../models/GuildTypes';
|
||||
|
||||
const ADMIN_STICKER_MEDIA_RUNG: MediaProxyImageSize = 320;
|
||||
|
||||
interface AdminGuildLookupServiceDeps {
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
userRepository: IUserRepository;
|
||||
@@ -201,6 +204,6 @@ export class AdminGuildLookupService {
|
||||
}
|
||||
|
||||
private buildStickerMediaUrl(id: string, animated: boolean): string {
|
||||
return `${Config.endpoints.media}/stickers/${id}.webp?size=${MEDIA_PROXY_ICON_SIZE_DEFAULT}${animated ? '&animated=true' : ''}`;
|
||||
return `${Config.endpoints.media}/stickers/${id}.webp?size=${ADMIN_STICKER_MEDIA_RUNG}${animated ? '&animated=true' : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export class AdminGuildMembershipService {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
await guildService.members.addUserToGuild({
|
||||
skipRiskGate: true,
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: true,
|
||||
@@ -83,6 +84,7 @@ export class AdminGuildMembershipService {
|
||||
try {
|
||||
const userId = createUserID(userIdBigInt);
|
||||
await guildService.members.addUserToGuild({
|
||||
skipRiskGate: true,
|
||||
userId,
|
||||
guildId,
|
||||
sendJoinMessage: false,
|
||||
|
||||
@@ -283,7 +283,7 @@ describe('Admin billing overview', () => {
|
||||
invoices: {
|
||||
[params.invoiceId]: {
|
||||
customer: params.stripeCustomerId,
|
||||
subscription: params.stripeSubscriptionId,
|
||||
subscriptionId: params.stripeSubscriptionId,
|
||||
amount_due: params.amountPaidCents,
|
||||
amount_paid: params.amountPaidCents,
|
||||
billing_reason: 'subscription_cycle',
|
||||
@@ -359,7 +359,7 @@ describe('Admin billing overview', () => {
|
||||
invoices: {
|
||||
in_local_checkout_1: {
|
||||
customer: stripeCustomerId,
|
||||
subscription: 'sub_billing_target',
|
||||
subscriptionId: 'sub_billing_target',
|
||||
amount_due: 499,
|
||||
amount_paid: 499,
|
||||
billing_reason: 'subscription_create',
|
||||
@@ -396,7 +396,7 @@ describe('Admin billing overview', () => {
|
||||
},
|
||||
in_renewal_1: {
|
||||
customer: stripeCustomerId,
|
||||
subscription: 'sub_billing_target',
|
||||
subscriptionId: 'sub_billing_target',
|
||||
amount_due: 499,
|
||||
amount_paid: 499,
|
||||
billing_reason: 'subscription_cycle',
|
||||
@@ -545,7 +545,7 @@ describe('Admin billing overview', () => {
|
||||
invoices: {
|
||||
[invoiceId]: {
|
||||
customer: stripeCustomerId,
|
||||
subscription: stripeSubscriptionId,
|
||||
subscriptionId: stripeSubscriptionId,
|
||||
amount_due: 499,
|
||||
amount_paid: 499,
|
||||
billing_reason: 'subscription_create',
|
||||
|
||||
@@ -551,18 +551,7 @@ export function AuthController(app: HonoApp) {
|
||||
'Start a handoff session to transfer authentication between devices. Returns a handoff code for device linking.',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const clientIp = requireClientIp(ctx.req.raw, {
|
||||
trustClientIpHeader: Config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: Config.proxy.client_ip_header,
|
||||
});
|
||||
const clientPlatform = ctx.req.header('x-fluxer-platform')?.trim().toLowerCase() ?? undefined;
|
||||
return ctx.json(
|
||||
await ctx.get('authRequestService').initiateHandoff({
|
||||
userAgent: ctx.req.header('User-Agent'),
|
||||
clientIp,
|
||||
clientPlatform,
|
||||
}),
|
||||
);
|
||||
return ctx.json(await ctx.get('authRequestService').initiateHandoff({request: ctx.req.raw}));
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
@@ -611,7 +600,6 @@ export function AuthController(app: HonoApp) {
|
||||
});
|
||||
await ctx.get('authRequestService').completeHandoff({
|
||||
data: ctx.req.valid('json'),
|
||||
request: ctx.req.raw,
|
||||
clientIp,
|
||||
authToken: ctx.get('authToken') ?? undefined,
|
||||
});
|
||||
|
||||
@@ -100,7 +100,10 @@ export async function revertEmailChange(
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(updatedUser),
|
||||
});
|
||||
const [authToken] = await AuthSession.createAuthSession(ctx, {user: updatedUser, request});
|
||||
const [authToken] = await AuthSession.createAuthSession(ctx, {
|
||||
user: updatedUser,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
await contactChangeLog.recordDiff({
|
||||
oldUser: user,
|
||||
newUser: updatedUser,
|
||||
|
||||
@@ -105,9 +105,7 @@ export interface IpAuthorizationTicketCache {
|
||||
userId: string;
|
||||
email: string;
|
||||
username: string;
|
||||
clientIp: string;
|
||||
userAgent: string;
|
||||
platform: string | null;
|
||||
origin: AuthSession.SessionOrigin;
|
||||
authToken: string;
|
||||
clientLocation: string;
|
||||
inviteCode?: string | null;
|
||||
@@ -115,8 +113,8 @@ export interface IpAuthorizationTicketCache {
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
function getTicketCacheKey(ticket: string): string {
|
||||
return `ip-auth-ticket:${ticket}`;
|
||||
export function getTicketCacheKey(ticket: string): string {
|
||||
return `ip-auth-ticket-v2:${ticket}`;
|
||||
}
|
||||
|
||||
function getTokenCacheKey(token: string): string {
|
||||
@@ -148,7 +146,7 @@ export async function resendIpAuthorization(
|
||||
payload.email,
|
||||
payload.username,
|
||||
payload.authToken,
|
||||
payload.clientIp,
|
||||
payload.origin.ip,
|
||||
payload.clientLocation,
|
||||
null,
|
||||
);
|
||||
@@ -172,7 +170,7 @@ export async function completeIpAuthorization(
|
||||
user_id: string;
|
||||
ticket: string;
|
||||
}> {
|
||||
const {users, cache, config} = ctx.services;
|
||||
const {users, cache} = ctx.services;
|
||||
const tokenMapping = await cache.get<{
|
||||
ticket: string;
|
||||
}>(getTokenCacheKey(token));
|
||||
@@ -193,19 +191,8 @@ export async function completeIpAuthorization(
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
AuthUtility.assertNonBotUser(ctx, user);
|
||||
await users.createAuthorizedIp(user.id, payload.clientIp);
|
||||
const headers: Record<string, string> = {
|
||||
[config.proxy.client_ip_header]: payload.clientIp,
|
||||
'user-agent': payload.userAgent,
|
||||
};
|
||||
if (payload.platform) {
|
||||
headers['x-fluxer-platform'] = payload.platform;
|
||||
}
|
||||
const syntheticRequest = new Request('https://api.fluxer.app/auth/ip-authorization', {
|
||||
headers,
|
||||
method: 'POST',
|
||||
});
|
||||
const [sessionToken] = await AuthSession.createAuthSession(ctx, {user, request: syntheticRequest});
|
||||
await users.createAuthorizedIp(user.id, payload.origin.ip);
|
||||
const [sessionToken] = await AuthSession.createAuthSession(ctx, {user, origin: payload.origin});
|
||||
await cache.delete(cacheKey);
|
||||
await cache.delete(getTokenCacheKey(token));
|
||||
return {token: sessionToken, user_id: user.id.toString(), ticket: tokenMapping.ticket};
|
||||
@@ -313,15 +300,11 @@ export async function login(
|
||||
const authToken = createIpAuthorizationToken(await AuthUtility.generateSecureToken(ctx));
|
||||
const geoipResult = await lookupGeoip(clientIp);
|
||||
const clientLocation = formatGeoipLocation(geoipResult) ?? UNKNOWN_LOCATION;
|
||||
const userAgent = request.headers.get('user-agent') || '';
|
||||
const platform = request.headers.get('x-fluxer-platform');
|
||||
const cachePayload: IpAuthorizationTicketCache = {
|
||||
userId: currentUser.id.toString(),
|
||||
email: currentUser.email!,
|
||||
username: currentUser.username,
|
||||
clientIp,
|
||||
userAgent,
|
||||
platform: platform ?? null,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
authToken,
|
||||
clientLocation,
|
||||
inviteCode: data.invite_code ?? null,
|
||||
@@ -329,7 +312,7 @@ export async function login(
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
const ttlSeconds = seconds('15 minutes');
|
||||
await cache.set<IpAuthorizationTicketCache>(`ip-auth-ticket:${ticket}`, cachePayload, ttlSeconds);
|
||||
await cache.set<IpAuthorizationTicketCache>(getTicketCacheKey(ticket), cachePayload, ttlSeconds);
|
||||
await cache.set<{
|
||||
ticket: string;
|
||||
}>(`ip-auth-token:${authToken}`, {ticket}, ttlSeconds);
|
||||
@@ -364,7 +347,10 @@ export async function login(
|
||||
Logger.warn({inviteCode: data.invite_code, error}, 'Failed to auto-join invite on login');
|
||||
}
|
||||
}
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {user: currentUser, request});
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user: currentUser,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
return {
|
||||
user_id: currentUser.id.toString(),
|
||||
token,
|
||||
@@ -418,7 +404,10 @@ export async function loginMfaTotp(
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
await cache.delete(attemptsKey);
|
||||
await cache.delete(userAttemptsKey);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
@@ -438,7 +427,10 @@ export async function loginMfaWebAuthn(
|
||||
AuthUtility.assertNonBotUser(ctx, user);
|
||||
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
|
||||
await cache.delete(`mfa-ticket:${ticket}`);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
return {user_id: user.id.toString(), token};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ import type {AuthSessionResponse} from '@fluxer/schema/src/domains/auth/AuthSche
|
||||
import {uint8ArrayToBase64} from 'uint8array-extras';
|
||||
import {Config} from '../Config';
|
||||
import {Logger} from '../Logger';
|
||||
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
|
||||
import type {AuthSession} from '../models/AuthSession';
|
||||
import {getLocationLabelFromIp} from '../utils/IpUtils';
|
||||
import {resolveSessionClientInfo} from '../utils/UserAgentUtils';
|
||||
import {resolveSessionClientInfo} from '../utils/SessionClientIdentity';
|
||||
|
||||
const DEV_FALLBACK_AUTH_SESSION_LOCATION = 'Stockholm, Stockholm County, Sweden';
|
||||
|
||||
@@ -40,30 +41,24 @@ export async function mapAuthSessionsToResponse({
|
||||
const locationResults = await Promise.allSettled(
|
||||
sortedSessions.map((session) => resolveAuthSessionLocation(session)),
|
||||
);
|
||||
const {branding} = await getInstanceConfigRepository().getAppPublicConfig();
|
||||
return sortedSessions.map((authSession, index): AuthSessionResponse => {
|
||||
const locationResult = locationResults[index];
|
||||
const clientLocation = locationResult?.status === 'fulfilled' ? locationResult.value : null;
|
||||
let clientOs: string;
|
||||
let clientPlatform: string;
|
||||
if (authSession.clientUserAgent) {
|
||||
const parsed = resolveSessionClientInfo({
|
||||
userAgent: authSession.clientUserAgent,
|
||||
isDesktopClient: authSession.clientIsDesktop,
|
||||
});
|
||||
clientOs = parsed.clientOs;
|
||||
clientPlatform = parsed.clientPlatform;
|
||||
} else {
|
||||
clientOs = authSession.clientOs || 'Unknown';
|
||||
clientPlatform = authSession.clientPlatform || 'Unknown';
|
||||
}
|
||||
const clientInfo = resolveSessionClientInfo({
|
||||
userAgent: authSession.clientUserAgent,
|
||||
reportedOs: authSession.clientOs ?? null,
|
||||
productName: branding.product_name,
|
||||
});
|
||||
const idHash = uint8ArrayToBase64(authSession.sessionIdHash, {urlSafe: true});
|
||||
const isCurrent = currentSessionId ? Buffer.compare(authSession.sessionIdHash, currentSessionId) === 0 : false;
|
||||
return {
|
||||
id_hash: idHash,
|
||||
client_info: {
|
||||
platform: clientPlatform,
|
||||
os: clientOs,
|
||||
browser: undefined,
|
||||
platform: clientInfo.platform,
|
||||
os: clientInfo.os,
|
||||
browser: clientInfo.browser,
|
||||
device: clientInfo.device,
|
||||
location: clientLocation
|
||||
? {
|
||||
city: clientLocation.split(',').at(0)?.trim() || null,
|
||||
|
||||
@@ -275,7 +275,10 @@ export async function resetPassword(
|
||||
if (hasMfa) {
|
||||
return await createMfaTicketResponse(ctx, updatedUser);
|
||||
}
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {user: updatedUser, request});
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user: updatedUser,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
return {user_id: updatedUser.id.toString(), token};
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
normalizePolicyContactDomain,
|
||||
} from '../risk/AccountPolicyEvaluator';
|
||||
import type {IRegistrationEventsRepository} from '../risk/adapters/VelocityAdapter';
|
||||
import {deferPhoneFlagsUntilCommunityJoin} from '../risk/DeferredPhoneGate';
|
||||
import type {IRiskHistoryRepository} from '../risk/HistoricalOutcomeRepository';
|
||||
import type {IRiskAssessmentRepository} from '../risk/RiskAssessmentRepository';
|
||||
import {deriveLatestRiskContext} from '../risk/RiskHistoryContext';
|
||||
@@ -334,7 +335,7 @@ export async function register(
|
||||
action: riskResult.recommendedAction,
|
||||
},
|
||||
});
|
||||
const combinedFlags = policyDecision.flagBits;
|
||||
const combinedFlags = await deferPhoneFlagsUntilCommunityJoin(policyDecision.flagBits);
|
||||
const createdAt = new Date();
|
||||
const riskContext = deriveLatestRiskContext({
|
||||
userId: userId.toString(),
|
||||
@@ -443,7 +444,10 @@ export async function register(
|
||||
);
|
||||
}
|
||||
await singleCommunityService.joinStockCommunity(userId, requestCache);
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {user, request});
|
||||
const [token] = await AuthSession.createAuthSession(ctx, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(ctx, request),
|
||||
});
|
||||
if (grantBootstrapAdmin) {
|
||||
await instanceConfigRepository.markAdminBootstrapped();
|
||||
}
|
||||
|
||||
@@ -33,11 +33,12 @@ import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResp
|
||||
import type {ApiContext} from '../ApiContext';
|
||||
import {createUserID, type UserID} from '../BrandedTypes';
|
||||
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
|
||||
import {getInstanceConfigRepository} from '../middleware/ServiceSingletons';
|
||||
import type {User} from '../models/User';
|
||||
import {mapUserToPartialResponse} from '../user/UserMappers';
|
||||
import {lookupGeoip} from '../utils/IpUtils';
|
||||
import {parseJsonRecord} from '../utils/JsonBoundaryUtils';
|
||||
import {resolveSessionClientInfo} from '../utils/UserAgentUtils';
|
||||
import {resolveSessionClientInfo} from '../utils/SessionClientIdentity';
|
||||
import {generateUsernameSuggestions} from '../utils/UsernameSuggestionUtils';
|
||||
import * as AuthEmail from './AuthEmail';
|
||||
import * as AuthEmailRevert from './AuthEmailRevert';
|
||||
@@ -89,7 +90,6 @@ interface AuthLogoutRequest {
|
||||
|
||||
interface AuthHandoffCompleteRequest {
|
||||
data: HandoffCompleteRequest;
|
||||
request: Request;
|
||||
clientIp: string;
|
||||
authToken?: string;
|
||||
}
|
||||
@@ -122,9 +122,7 @@ interface AuthLogoutAuthSessionsRequest {
|
||||
}
|
||||
|
||||
interface AuthHandoffInitiateRequest {
|
||||
userAgent?: string;
|
||||
clientIp: string;
|
||||
clientPlatform?: string;
|
||||
request: Request;
|
||||
}
|
||||
|
||||
interface AuthHandoffInfoRequest {
|
||||
@@ -263,7 +261,7 @@ export class AuthRequestService {
|
||||
user: await this.getUserPartial(parsed.user_id),
|
||||
};
|
||||
}
|
||||
const ticketPayload = await cache.get(`ip-auth-ticket:${ticket}`);
|
||||
const ticketPayload = await cache.get(AuthLogin.getTicketCacheKey(ticket));
|
||||
if (!ticketPayload) {
|
||||
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.INVALID_OR_EXPIRED_AUTHORIZATION_TICKET);
|
||||
}
|
||||
@@ -276,7 +274,10 @@ export class AuthRequestService {
|
||||
|
||||
async authenticateWebAuthnDiscoverable({data, request}: AuthWebAuthnAuthenticateRequest) {
|
||||
const user = await AuthMfa.verifyWebAuthnAuthenticationDiscoverable(this.apiContext, data.response, data.challenge);
|
||||
const [token] = await AuthSession.createAuthSession(this.apiContext, {user, request});
|
||||
const [token] = await AuthSession.createAuthSession(this.apiContext, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(this.apiContext, request),
|
||||
});
|
||||
return {token, user_id: user.id.toString(), user: mapUserToPartialResponse(user)};
|
||||
}
|
||||
|
||||
@@ -298,12 +299,9 @@ export class AuthRequestService {
|
||||
return {suggestions: generateUsernameSuggestions(globalName)};
|
||||
}
|
||||
|
||||
async initiateHandoff({
|
||||
userAgent,
|
||||
clientIp,
|
||||
clientPlatform,
|
||||
}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
|
||||
const result = await this.desktopHandoffService.initiateHandoff({userAgent, clientIp, clientPlatform});
|
||||
async initiateHandoff({request}: AuthHandoffInitiateRequest): Promise<HandoffInitiateResponse> {
|
||||
const origin = AuthSession.resolveSessionOrigin(this.apiContext, request);
|
||||
const result = await this.desktopHandoffService.initiateHandoff({origin});
|
||||
return {
|
||||
code: result.code,
|
||||
expires_at: result.expiresAt.toISOString(),
|
||||
@@ -312,19 +310,22 @@ export class AuthRequestService {
|
||||
|
||||
async getHandoffInfo({code, clientIp}: AuthHandoffInfoRequest): Promise<HandoffInfoResponse> {
|
||||
const info = await this.desktopHandoffService.getHandoffInfo(code, clientIp);
|
||||
if (info.status === 'expired' || !info.clientIp) {
|
||||
if (info.status === 'expired' || !info.origin) {
|
||||
return {status: info.status, client_info: null};
|
||||
}
|
||||
const geo = await lookupGeoip(info.clientIp);
|
||||
const {clientOs, clientPlatform} = resolveSessionClientInfo({
|
||||
userAgent: info.userAgent ?? null,
|
||||
isDesktopClient: info.clientPlatform === 'desktop',
|
||||
const geo = await lookupGeoip(info.origin.ip);
|
||||
const {branding} = await getInstanceConfigRepository().getAppPublicConfig();
|
||||
const resolved = resolveSessionClientInfo({
|
||||
userAgent: info.origin.userAgent,
|
||||
reportedOs: info.origin.clientOs,
|
||||
productName: branding.product_name,
|
||||
});
|
||||
return {
|
||||
status: 'pending',
|
||||
client_info: {
|
||||
platform: clientPlatform,
|
||||
os: clientOs,
|
||||
platform: resolved.platform,
|
||||
os: resolved.os,
|
||||
device: resolved.device,
|
||||
location: {
|
||||
city: geo.city,
|
||||
region: geo.region,
|
||||
@@ -334,18 +335,18 @@ export class AuthRequestService {
|
||||
};
|
||||
}
|
||||
|
||||
async completeHandoff({data, request, clientIp, authToken}: AuthHandoffCompleteRequest): Promise<void> {
|
||||
async completeHandoff({data, clientIp, authToken}: AuthHandoffCompleteRequest): Promise<void> {
|
||||
const sessionToken = data.token ?? authToken;
|
||||
if (!sessionToken) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
await this.desktopHandoffService.completeHandoff(
|
||||
data.code,
|
||||
() =>
|
||||
(origin) =>
|
||||
AuthSession.createAdditionalAuthSessionFromToken(this.apiContext, {
|
||||
token: sessionToken,
|
||||
expectedUserId: data.user_id,
|
||||
request,
|
||||
origin,
|
||||
}),
|
||||
clientIp,
|
||||
);
|
||||
|
||||
@@ -15,12 +15,19 @@ import {Logger} from '../Logger';
|
||||
import type {AuthSession} from '../models/AuthSession';
|
||||
import type {User} from '../models/User';
|
||||
import {lookupGeoip} from '../utils/IpUtils';
|
||||
import {isFluxerNativeUserAgent, parseReportedClientOs} from '../utils/SessionClientIdentity';
|
||||
import {mapAuthSessionsToResponse} from './AuthModel';
|
||||
import * as AuthUtility from './AuthUtility';
|
||||
|
||||
export interface SessionOrigin {
|
||||
ip: string;
|
||||
userAgent: string | null;
|
||||
clientOs: string | null;
|
||||
}
|
||||
|
||||
interface CreateAuthSessionParams {
|
||||
user: User;
|
||||
request: Request;
|
||||
origin: SessionOrigin;
|
||||
}
|
||||
|
||||
interface LogoutAuthSessionsParams {
|
||||
@@ -60,29 +67,35 @@ interface ReplaceCurrentAuthSessionResult {
|
||||
interface CreateAdditionalAuthSessionFromTokenParams {
|
||||
token: string;
|
||||
expectedUserId?: string;
|
||||
request: Request;
|
||||
origin: SessionOrigin;
|
||||
}
|
||||
|
||||
export function resolveSessionOrigin(ctx: ApiContext, request: Request): SessionOrigin {
|
||||
const {config} = ctx.services;
|
||||
const ip = requireClientIp(request, {
|
||||
trustClientIpHeader: config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: config.proxy.client_ip_header,
|
||||
});
|
||||
const userAgent = request.headers.get('user-agent')?.trim() || null;
|
||||
const clientOs = isFluxerNativeUserAgent(userAgent)
|
||||
? parseReportedClientOs(request.headers.get('x-fluxer-client-properties'))
|
||||
: null;
|
||||
return {ip, userAgent, clientOs};
|
||||
}
|
||||
|
||||
export async function createAuthSession(
|
||||
ctx: ApiContext,
|
||||
{user, request}: CreateAuthSessionParams,
|
||||
{user, origin}: CreateAuthSessionParams,
|
||||
): Promise<[token: string, AuthSession]> {
|
||||
const {users, config} = ctx.services;
|
||||
const {users} = ctx.services;
|
||||
if (user.isBot) throw new BotUserAuthSessionCreationDeniedError();
|
||||
if (user.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) throw new RegistrationPendingApprovalError();
|
||||
if (user.traits.has(REGISTRATION_REJECTED_TRAIT)) throw new RegistrationRejectedError();
|
||||
const now = new Date();
|
||||
const token = await AuthUtility.generateAuthToken(ctx);
|
||||
const ip = requireClientIp(request, {
|
||||
trustClientIpHeader: config.proxy.trust_client_ip_header,
|
||||
clientIpHeaderName: config.proxy.client_ip_header,
|
||||
});
|
||||
const platformHeader = request.headers.get('x-fluxer-platform')?.trim().toLowerCase() ?? null;
|
||||
const uaRaw = request.headers.get('user-agent') ?? '';
|
||||
const isDesktopClient = platformHeader === 'desktop';
|
||||
let clientCountry: string | null = null;
|
||||
try {
|
||||
const geoip = await lookupGeoip(ip);
|
||||
const geoip = await lookupGeoip(origin.ip);
|
||||
clientCountry = geoip.countryCode ? geoip.countryCode.toUpperCase() : null;
|
||||
} catch (error) {
|
||||
Logger.warn({userId: user.id.toString(), error}, 'GeoIP lookup failed at session creation');
|
||||
@@ -92,11 +105,9 @@ export async function createAuthSession(
|
||||
session_id_hash: Buffer.from(AuthUtility.getTokenIdHash(ctx, token)),
|
||||
created_at: now,
|
||||
approx_last_used_at: now,
|
||||
client_ip: ip,
|
||||
client_user_agent: uaRaw || null,
|
||||
client_is_desktop: isDesktopClient,
|
||||
client_os: null,
|
||||
client_platform: null,
|
||||
client_ip: origin.ip,
|
||||
client_user_agent: origin.userAgent,
|
||||
client_os: origin.clientOs,
|
||||
client_country: clientCountry,
|
||||
version: 1,
|
||||
});
|
||||
@@ -105,7 +116,7 @@ export async function createAuthSession(
|
||||
|
||||
export async function createAdditionalAuthSessionFromToken(
|
||||
ctx: ApiContext,
|
||||
{token, expectedUserId, request}: CreateAdditionalAuthSessionFromTokenParams,
|
||||
{token, expectedUserId, origin}: CreateAdditionalAuthSessionFromTokenParams,
|
||||
): Promise<{
|
||||
token: string;
|
||||
userId: string;
|
||||
@@ -122,7 +133,7 @@ export async function createAdditionalAuthSessionFromToken(
|
||||
if (expectedUserId && user.id.toString() !== expectedUserId) {
|
||||
throw new SessionTokenMismatchError();
|
||||
}
|
||||
const [newToken] = await createAuthSession(ctx, {user, request});
|
||||
const [newToken] = await createAuthSession(ctx, {user, origin});
|
||||
return {token: newToken, userId: user.id.toString()};
|
||||
}
|
||||
|
||||
@@ -205,7 +216,7 @@ export async function replaceCurrentAuthSession(
|
||||
(authSession) => !authSession.sessionIdHash.equals(currentAuthSession.sessionIdHash),
|
||||
);
|
||||
await deleteAndTerminateAuthSessions(ctx, user.id, otherAuthSessions);
|
||||
const [newToken, newAuthSession] = await createAuthSession(ctx, {user, request});
|
||||
const [newToken, newAuthSession] = await createAuthSession(ctx, {user, origin: resolveSessionOrigin(ctx, request)});
|
||||
const newAuthSessionIdHash = encodeSessionIdHash(newAuthSession.sessionIdHash);
|
||||
await dispatchAuthSessionChange(ctx, {
|
||||
userId: user.id,
|
||||
|
||||
@@ -5,8 +5,9 @@ import {HandoffCodeExpiredError} from '@fluxer/errors/src/domains/auth/HandoffCo
|
||||
import {InvalidHandoffCodeError} from '@fluxer/errors/src/domains/auth/InvalidHandoffCodeError';
|
||||
import {ms, seconds} from 'itty-time';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import type {SessionOrigin} from '../AuthSession';
|
||||
|
||||
const HANDOFF_CODE_PREFIX = 'desktop-handoff:';
|
||||
const HANDOFF_CODE_PREFIX = 'desktop-handoff-v2:';
|
||||
const HANDOFF_TOKEN_PREFIX = 'desktop-handoff-token:';
|
||||
const CODE_CHARACTERS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
|
||||
const CODE_LENGTH = 12;
|
||||
@@ -19,9 +20,7 @@ const MAX_INFO_LOOKUPS = 3;
|
||||
|
||||
interface HandoffData {
|
||||
createdAt: number;
|
||||
userAgent?: string;
|
||||
clientIp: string;
|
||||
clientPlatform?: string;
|
||||
origin: SessionOrigin;
|
||||
infoLookupCount: number;
|
||||
}
|
||||
|
||||
@@ -61,7 +60,7 @@ function assertValidHandoffCode(code: string): void {
|
||||
export class DesktopHandoffService {
|
||||
constructor(private readonly apiContext: ApiContext) {}
|
||||
|
||||
async initiateHandoff(args: {userAgent?: string; clientIp: string; clientPlatform?: string}): Promise<{
|
||||
async initiateHandoff(args: {origin: SessionOrigin}): Promise<{
|
||||
code: string;
|
||||
expiresAt: Date;
|
||||
}> {
|
||||
@@ -70,9 +69,7 @@ export class DesktopHandoffService {
|
||||
const normalizedCode = normalizeHandoffCode(code);
|
||||
const handoffData: HandoffData = {
|
||||
createdAt: Date.now(),
|
||||
userAgent: args.userAgent,
|
||||
clientIp: args.clientIp,
|
||||
clientPlatform: args.clientPlatform,
|
||||
origin: args.origin,
|
||||
infoLookupCount: 0,
|
||||
};
|
||||
const expirySeconds = seconds('5 minutes');
|
||||
@@ -83,7 +80,7 @@ export class DesktopHandoffService {
|
||||
|
||||
async completeHandoff(
|
||||
code: string,
|
||||
createTokenData: () => Promise<{token: string; userId: string}>,
|
||||
createTokenData: (origin: SessionOrigin) => Promise<{token: string; userId: string}>,
|
||||
approverIp: string,
|
||||
): Promise<void> {
|
||||
const {cache} = this.apiContext.services;
|
||||
@@ -107,7 +104,7 @@ export class DesktopHandoffService {
|
||||
if (remainingSeconds <= 0) {
|
||||
throw new HandoffCodeExpiredError();
|
||||
}
|
||||
const {token, userId} = await createTokenData();
|
||||
const {token, userId} = await createTokenData(handoffData.origin);
|
||||
const tokenData: HandoffTokenData = {
|
||||
token,
|
||||
userId,
|
||||
@@ -122,9 +119,7 @@ export class DesktopHandoffService {
|
||||
approverIp: string,
|
||||
): Promise<{
|
||||
status: 'pending' | 'expired';
|
||||
userAgent?: string;
|
||||
clientIp?: string;
|
||||
clientPlatform?: string;
|
||||
origin?: SessionOrigin;
|
||||
}> {
|
||||
const {cache} = this.apiContext.services;
|
||||
const normalizedCode = normalizeHandoffCode(code);
|
||||
@@ -149,12 +144,7 @@ export class DesktopHandoffService {
|
||||
{approvedAt: Date.now()},
|
||||
remainingTtl > 0 ? remainingTtl : seconds('5 minutes'),
|
||||
);
|
||||
return {
|
||||
status: 'pending',
|
||||
userAgent: handoffData.userAgent,
|
||||
clientIp: handoffData.clientIp,
|
||||
clientPlatform: handoffData.clientPlatform,
|
||||
};
|
||||
return {status: 'pending', origin: handoffData.origin};
|
||||
}
|
||||
|
||||
async getHandoffStatus(
|
||||
|
||||
@@ -332,7 +332,10 @@ export class SsoService {
|
||||
});
|
||||
const claims = await this.resolveClaims(tokenResponse, config, statePayload.nonce);
|
||||
const user = await this.resolveUserFromClaims(claims, config);
|
||||
const [token] = await AuthSession.createAuthSession(this.apiContext, {user, request});
|
||||
const [token] = await AuthSession.createAuthSession(this.apiContext, {
|
||||
user,
|
||||
origin: AuthSession.resolveSessionOrigin(this.apiContext, request),
|
||||
});
|
||||
return {token, user_id: user.id.toString(), redirect_to: statePayload.redirectTo ?? ''};
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,16 @@ export function userHasMfa(user: {authenticatorTypes?: Set<number> | null}): boo
|
||||
);
|
||||
}
|
||||
|
||||
export function hasNoVerifiableCredential(
|
||||
user: {passwordHash: string | null; isBot: boolean},
|
||||
hasMfa: boolean,
|
||||
): boolean {
|
||||
if (user.isBot || hasMfa) {
|
||||
return false;
|
||||
}
|
||||
return user.passwordHash === null;
|
||||
}
|
||||
|
||||
export function deriveSudoMethods(user: {
|
||||
totpSecret?: string | null;
|
||||
authenticatorTypes?: Set<number> | null;
|
||||
@@ -86,8 +96,7 @@ async function verifySudoMode(
|
||||
const sudoToken = issueSudoToken ? await sudoModeService.generateSudoToken(user.id) : undefined;
|
||||
return {verified: true, sudoToken, method: 'mfa'};
|
||||
}
|
||||
const isUnclaimedAccount = user.isUnclaimedAccount();
|
||||
if (isUnclaimedAccount && !hasMfa) {
|
||||
if (hasNoVerifiableCredential(user, hasMfa)) {
|
||||
return {verified: true, method: 'password'};
|
||||
}
|
||||
if (body.password && !hasMfa) {
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DEFERRED_PHONE_ON_COMMUNITY_JOIN, SuspiciousActivityFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {setInjectedRegistrationRiskEvaluator} from '../../middleware/ServiceMiddleware';
|
||||
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
|
||||
import {
|
||||
RecommendedAction,
|
||||
RiskConfidence,
|
||||
RiskDecisionMethod,
|
||||
RiskLevel,
|
||||
type RiskLevel as RiskLevelType,
|
||||
} from '../../risk/RiskTypes';
|
||||
import type {ApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
import type {IRegistrationRiskEvaluator} from '../services/IRegistrationRiskEvaluator';
|
||||
import {
|
||||
createAuthHarness,
|
||||
createTestAccount,
|
||||
createUniqueEmail,
|
||||
createUniqueUsername,
|
||||
loginAccount,
|
||||
registerUser,
|
||||
} from './AuthTestUtils';
|
||||
|
||||
function phoneRiskEvaluator(level: RiskLevelType, riskScore: number): IRegistrationRiskEvaluator {
|
||||
return {
|
||||
async evaluate() {
|
||||
return {
|
||||
level,
|
||||
recommendedAction: RecommendedAction.RequireOutboundPhone,
|
||||
assessment: {
|
||||
suspicious: true,
|
||||
level,
|
||||
confidence: RiskConfidence.High,
|
||||
riskScore,
|
||||
reasoning: 'deferred phone gate test',
|
||||
recommendedAction: RecommendedAction.RequireOutboundPhone,
|
||||
method: RiskDecisionMethod.Noop,
|
||||
modelUsed: 'test',
|
||||
rounds: 0,
|
||||
elapsedMs: 0,
|
||||
signals: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function createGuildWithInvite(harness: ApiTestHarness): Promise<{guildId: string; inviteCode: string}> {
|
||||
let owner = await createTestAccount(harness);
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/users/${owner.userId}/acls`)
|
||||
.body({acls: ['*']})
|
||||
.expect(200)
|
||||
.execute();
|
||||
owner = await loginAccount(harness, owner);
|
||||
const guild = await createBuilder<GuildResponse>(harness, owner.token)
|
||||
.post('/guilds')
|
||||
.body({name: `PhoneGate-${Date.now()}`})
|
||||
.execute();
|
||||
const invite = await createBuilder<{code: string}>(harness, owner.token)
|
||||
.post(`/channels/${guild.system_channel_id}`.concat('/invites'))
|
||||
.body({max_uses: 0, max_age: 0, unique: false, temporary: false})
|
||||
.execute();
|
||||
return {guildId: guild.id, inviteCode: invite.code};
|
||||
}
|
||||
|
||||
async function readFlags(userId: string): Promise<number> {
|
||||
const {UserRepository} = await import('../../user/repositories/UserRepository');
|
||||
const {createUserID} = await import('../../BrandedTypes');
|
||||
const user = await new UserRepository().findUnique(createUserID(BigInt(userId)));
|
||||
return user?.suspiciousActivityFlags ?? 0;
|
||||
}
|
||||
|
||||
describe('Deferred phone verification gate', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createAuthHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
await harness.reset();
|
||||
});
|
||||
afterAll(async () => {
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('applies the phone requirement immediately while the gate is off', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: false});
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-off'),
|
||||
username: createUniqueUsername('gate_off'),
|
||||
global_name: 'Gate Off',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
});
|
||||
|
||||
it('defers the phone requirement at registration while the gate is on', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-on'),
|
||||
username: createUniqueUsername('gate_on'),
|
||||
global_name: 'Gate On',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
|
||||
const me = await createBuilder<{required_actions: Array<string>}>(harness, registration.token)
|
||||
.get('/users/@me')
|
||||
.expect(200)
|
||||
.execute();
|
||||
expect(me.required_actions ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('lets a deferred account join a small guild without being challenged', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
|
||||
const {guildId, inviteCode} = await createGuildWithInvite(harness);
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-small'),
|
||||
username: createUniqueUsername('gate_small'),
|
||||
global_name: 'Gate Small',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
await createBuilder(harness, registration.token).post(`/invites/${inviteCode}`).expect(200).execute();
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
expect(guildId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not defer the inbound-SMS tier, which stays enforced from registration', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({deferred_phone_gate_enabled: true});
|
||||
setInjectedRegistrationRiskEvaluator({
|
||||
async evaluate() {
|
||||
return {
|
||||
level: RiskLevel.VeryHigh,
|
||||
recommendedAction: RecommendedAction.RequireInboundPhone,
|
||||
assessment: {
|
||||
suspicious: true,
|
||||
level: RiskLevel.VeryHigh,
|
||||
confidence: RiskConfidence.High,
|
||||
riskScore: 90,
|
||||
reasoning: 'inbound tier',
|
||||
recommendedAction: RecommendedAction.RequireInboundPhone,
|
||||
method: RiskDecisionMethod.Noop,
|
||||
modelUsed: 'test',
|
||||
rounds: 0,
|
||||
elapsedMs: 0,
|
||||
signals: {},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-inbound'),
|
||||
username: createUniqueUsername('gate_inbound'),
|
||||
global_name: 'Gate Inbound',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_INBOUND_PHONE_VERIFICATION).not.toBe(0);
|
||||
});
|
||||
|
||||
it('promotes the requirement and refuses the join on a qualifying guild inside the window', async () => {
|
||||
await getInstanceConfigRepository().setInstancePolicyConfig({
|
||||
deferred_phone_gate_enabled: true,
|
||||
deferred_phone_gate_member_threshold: 1,
|
||||
deferred_phone_gate_window_hours: 24,
|
||||
});
|
||||
const {inviteCode} = await createGuildWithInvite(harness);
|
||||
const filler = await createTestAccount(harness);
|
||||
await createBuilder(harness, filler.token).post(`/invites/${inviteCode}`).expect(200).execute();
|
||||
|
||||
setInjectedRegistrationRiskEvaluator(phoneRiskEvaluator(RiskLevel.High, 70));
|
||||
const registration = await registerUser(harness, {
|
||||
email: createUniqueEmail('gate-qualifying'),
|
||||
username: createUniqueUsername('gate_qualifying'),
|
||||
global_name: 'Gate Qualifying',
|
||||
password: 'StrongPassword!123',
|
||||
date_of_birth: '2000-01-01',
|
||||
consent: true,
|
||||
});
|
||||
setInjectedRegistrationRiskEvaluator(undefined);
|
||||
expect((await readFlags(registration.user_id)) & DEFERRED_PHONE_ON_COMMUNITY_JOIN).not.toBe(0);
|
||||
|
||||
await createBuilder(harness, registration.token).post(`/invites/${inviteCode}`).expect(403).execute();
|
||||
|
||||
const flags = await readFlags(registration.user_id);
|
||||
expect(flags & DEFERRED_PHONE_ON_COMMUNITY_JOIN).toBe(0);
|
||||
expect(flags & SuspiciousActivityFlags.REQUIRE_VERIFIED_PHONE).not.toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {maskIpForDisplay} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
import type {ApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '../../test/TestConstants';
|
||||
import {createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
|
||||
import {createAuthHarness, createTestAccount, fetchMe, loginAccount} from './AuthTestUtils';
|
||||
|
||||
interface HandoffInitiateResponse {
|
||||
@@ -17,6 +19,7 @@ interface HandoffInfoResponse {
|
||||
client_info?: {
|
||||
platform?: string | null;
|
||||
os?: string | null;
|
||||
device?: 'mobile' | 'desktop';
|
||||
location?: {
|
||||
city?: string | null;
|
||||
region?: string | null;
|
||||
@@ -25,6 +28,16 @@ interface HandoffInfoResponse {
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface AuthSessionsResponseItem {
|
||||
masked_ip?: string | null;
|
||||
client_info?: {
|
||||
platform?: string | null;
|
||||
os?: string | null;
|
||||
browser?: string | null;
|
||||
device?: 'mobile' | 'desktop';
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface HandoffStatusResponse {
|
||||
status: 'pending' | 'completed' | 'expired';
|
||||
token?: string;
|
||||
@@ -53,6 +66,46 @@ describe('Auth desktop handoff flow', () => {
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
it('attributes the handed-off session to the initiating desktop, not the approving browser', async () => {
|
||||
const DESKTOP_IP = '203.0.113.77';
|
||||
const desktopUserAgent =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) FluxerStable/1.4.0 Chrome/128.0.0.0 Electron/32.0.0 Safari/537.36';
|
||||
const browserUserAgent =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36';
|
||||
const account = await createTestAccount(harness);
|
||||
const login = await loginAccount(harness, account);
|
||||
const initResp = await createBuilderWithoutAuth<HandoffInitiateResponse>(harness)
|
||||
.post('/auth/handoff/initiate')
|
||||
.header('User-Agent', desktopUserAgent)
|
||||
.header('x-forwarded-for', DESKTOP_IP)
|
||||
.body(null)
|
||||
.execute();
|
||||
const info = await createBuilderWithoutAuth<HandoffInfoResponse>(harness)
|
||||
.get(`/auth/handoff/${initResp.code}/info`)
|
||||
.header('User-Agent', browserUserAgent)
|
||||
.execute();
|
||||
expect(info.client_info?.platform).toBe('Fluxer macOS');
|
||||
expect(info.client_info?.device).toBe('desktop');
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/auth/handoff/complete')
|
||||
.header('User-Agent', browserUserAgent)
|
||||
.body({code: initResp.code, token: login.token, user_id: login.userId})
|
||||
.expect(204)
|
||||
.execute();
|
||||
const completed = await createBuilderWithoutAuth<HandoffStatusResponse>(harness)
|
||||
.get(`/auth/handoff/${initResp.code}/status`)
|
||||
.execute();
|
||||
const sessions = await createBuilder<Array<AuthSessionsResponseItem>>(harness, completed.token!)
|
||||
.get('/auth/sessions')
|
||||
.execute();
|
||||
const handedOff = sessions.filter((session) => session.client_info?.platform === 'Fluxer macOS');
|
||||
expect(handedOff).toHaveLength(1);
|
||||
expect(handedOff[0]?.client_info?.os).toBe('macOS');
|
||||
expect(handedOff[0]?.client_info?.browser).toBeNull();
|
||||
expect(handedOff[0]?.client_info?.device).toBe('desktop');
|
||||
expect(sessions.some((session) => session.client_info?.browser === 'Chrome')).toBe(false);
|
||||
expect(handedOff[0]?.masked_ip).toBe(maskIpForDisplay(DESKTOP_IP));
|
||||
});
|
||||
it('completes full handoff flow: initiate → info → complete → status', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const login = await loginAccount(harness, account);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {EMPTY_USER_ROW, type UserRow} from '../../database/types/UserTypes';
|
||||
import {User} from '../../models/User';
|
||||
import {hasNoVerifiableCredential, userHasMfa} from '../services/SudoVerificationService';
|
||||
|
||||
function createUser(overrides: Partial<UserRow> = {}): User {
|
||||
return new User({
|
||||
...EMPTY_USER_ROW,
|
||||
user_id: createUserID(1n),
|
||||
username: 'test_user',
|
||||
discriminator: 1,
|
||||
bot: false,
|
||||
password_hash: 'hash',
|
||||
traits: new Set<string>(),
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('sudo verification credential capability', () => {
|
||||
it('lets an SSO provisioned account without a password satisfy sudo mode', () => {
|
||||
const user = createUser({password_hash: null, traits: new Set<string>(['sso'])});
|
||||
expect(user.isUnclaimedAccount()).toBe(false);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(true);
|
||||
});
|
||||
|
||||
it('still lets an unclaimed account satisfy sudo mode', () => {
|
||||
const user = createUser({password_hash: null});
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(true);
|
||||
});
|
||||
|
||||
it('still requires a password from accounts that have one', () => {
|
||||
const user = createUser({password_hash: 'hash', traits: new Set<string>(['sso'])});
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
|
||||
});
|
||||
|
||||
it('still requires MFA from an SSO account that enrolled a second factor', () => {
|
||||
const user = createUser({
|
||||
password_hash: null,
|
||||
traits: new Set<string>(['sso']),
|
||||
authenticator_types: new Set<number>([UserAuthenticatorTypes.TOTP]),
|
||||
});
|
||||
expect(userHasMfa(user)).toBe(true);
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
|
||||
});
|
||||
|
||||
it('never applies to bots', () => {
|
||||
const user = createUser({password_hash: null, bot: true});
|
||||
expect(hasNoVerifiableCredential(user, userHasMfa(user))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -324,7 +324,7 @@ describe('mapStripeInvoiceToRow', () => {
|
||||
const inv = stripeFixture<Stripe.Invoice>({
|
||||
id: 'in_1',
|
||||
customer: 'cus_1',
|
||||
subscription: 'sub_1',
|
||||
parent: {type: 'subscription_details', subscription_details: {subscription: 'sub_1'}},
|
||||
status: 'paid',
|
||||
number: 'INV-001',
|
||||
currency: 'usd',
|
||||
|
||||
@@ -94,28 +94,6 @@ export interface StripeSubscriptionPayload {
|
||||
trial_start?: number | null;
|
||||
}
|
||||
|
||||
interface StripeInvoiceCompatibility extends Stripe.Invoice {
|
||||
subscription?: StripeExpandableId;
|
||||
tax?: number | null;
|
||||
application_fee_amount?: number | null;
|
||||
}
|
||||
|
||||
interface StripePaymentIntentCompatibility extends Stripe.PaymentIntent {
|
||||
invoice?: StripeExpandableId;
|
||||
}
|
||||
|
||||
interface StripeChargeCompatibility extends Stripe.Charge {
|
||||
invoice?: StripeExpandableId;
|
||||
}
|
||||
|
||||
type StripeRefundCompatibility = Stripe.Refund & {
|
||||
livemode?: boolean | null;
|
||||
};
|
||||
|
||||
interface StripeCheckoutSessionCompatibility extends Stripe.Checkout.Session {
|
||||
completed_at?: number | null;
|
||||
}
|
||||
|
||||
function createBillingSubscriptionItemValue(
|
||||
itemId: string,
|
||||
priceId: string,
|
||||
@@ -280,6 +258,14 @@ export function computeStripeUpdatedAt(
|
||||
return new Date(max * 1000);
|
||||
}
|
||||
|
||||
function sumInvoiceTotalTaxes(inv: Stripe.Invoice): bigint | null {
|
||||
const taxes = inv.total_taxes ?? null;
|
||||
if (taxes === null) {
|
||||
return null;
|
||||
}
|
||||
return BigInt(taxes.reduce((total, tax) => total + (tax.amount ?? 0), 0));
|
||||
}
|
||||
|
||||
function idOf(
|
||||
ref:
|
||||
| string
|
||||
@@ -689,9 +675,7 @@ export function mapStripeInvoiceToRow(
|
||||
throw new BillingMappingError('Invoice is missing customer', inv.id);
|
||||
}
|
||||
const now = new Date();
|
||||
const invoice = inv as StripeInvoiceCompatibility;
|
||||
const subscriptionId =
|
||||
idOf(invoice.subscription ?? null) ?? idOf(invoice.parent?.subscription_details?.subscription ?? null);
|
||||
const subscriptionId = idOf(inv.parent?.subscription_details?.subscription ?? null);
|
||||
const userIdFromMetadata = parseUserIdFromMetadata(inv.metadata);
|
||||
const userId = hints?.knownUserId ?? userIdFromMetadata;
|
||||
const transitions = inv.status_transitions ?? null;
|
||||
@@ -718,12 +702,11 @@ export function mapStripeInvoiceToRow(
|
||||
amount_paid: typeof inv.amount_paid === 'number' ? BigInt(inv.amount_paid) : null,
|
||||
amount_remaining: typeof inv.amount_remaining === 'number' ? BigInt(inv.amount_remaining) : null,
|
||||
subtotal: typeof inv.subtotal === 'number' ? BigInt(inv.subtotal) : null,
|
||||
tax: typeof invoice.tax === 'number' ? BigInt(invoice.tax) : null,
|
||||
tax: sumInvoiceTotalTaxes(inv),
|
||||
total: typeof inv.total === 'number' ? BigInt(inv.total) : null,
|
||||
starting_balance: typeof inv.starting_balance === 'number' ? BigInt(inv.starting_balance) : null,
|
||||
ending_balance: typeof inv.ending_balance === 'number' ? BigInt(inv.ending_balance) : null,
|
||||
application_fee_amount:
|
||||
typeof invoice.application_fee_amount === 'number' ? BigInt(invoice.application_fee_amount) : null,
|
||||
application_fee_amount: null,
|
||||
attempt_count: inv.attempt_count ?? null,
|
||||
attempted: inv.attempted ?? null,
|
||||
auto_advance: inv.auto_advance ?? null,
|
||||
@@ -818,7 +801,6 @@ export function mapStripePaymentIntentToRow(pi: Stripe.PaymentIntent): {
|
||||
throw new BillingMappingError('PaymentIntent is missing id');
|
||||
}
|
||||
const now = new Date();
|
||||
const paymentIntent = pi as StripePaymentIntentCompatibility;
|
||||
const customerId = idOf(
|
||||
pi.customer as
|
||||
| string
|
||||
@@ -828,7 +810,7 @@ export function mapStripePaymentIntentToRow(pi: Stripe.PaymentIntent): {
|
||||
| null
|
||||
| undefined,
|
||||
);
|
||||
const invoiceId = idOf(paymentIntent.invoice ?? null);
|
||||
const invoiceId = null;
|
||||
const paymentMethodId = idOf(
|
||||
pi.payment_method as
|
||||
| string
|
||||
@@ -892,7 +874,6 @@ export function mapStripeChargeToRow(c: Stripe.Charge): {
|
||||
throw new BillingMappingError('Charge is missing id');
|
||||
}
|
||||
const now = new Date();
|
||||
const charge = c as StripeChargeCompatibility;
|
||||
const customerId = idOf(
|
||||
c.customer as
|
||||
| string
|
||||
@@ -911,7 +892,7 @@ export function mapStripeChargeToRow(c: Stripe.Charge): {
|
||||
| null
|
||||
| undefined,
|
||||
);
|
||||
const invoiceId = idOf(charge.invoice ?? null);
|
||||
const invoiceId = null;
|
||||
const paymentMethodId = c.payment_method ?? null;
|
||||
const card = c.payment_method_details?.card ?? null;
|
||||
const billingDetails = c.billing_details ?? null;
|
||||
@@ -974,6 +955,7 @@ export function mapStripeRefundToRow(
|
||||
invoiceId?: string;
|
||||
customerId?: string;
|
||||
userId?: bigint;
|
||||
livemode?: boolean;
|
||||
},
|
||||
): {
|
||||
primary: BillingRefundRow;
|
||||
@@ -990,7 +972,6 @@ export function mapStripeRefundToRow(
|
||||
const invoiceId = hints?.invoiceId ?? null;
|
||||
const customerId = hints?.customerId ?? null;
|
||||
const userId = hints?.userId ?? null;
|
||||
const refund = r as StripeRefundCompatibility;
|
||||
const primary: BillingRefundRow = {
|
||||
provider_id: r.id,
|
||||
charge_id: chargeId,
|
||||
@@ -1005,7 +986,7 @@ export function mapStripeRefundToRow(
|
||||
receipt_number: r.receipt_number ?? null,
|
||||
failure_reason: r.failure_reason ?? null,
|
||||
description: r.description ?? null,
|
||||
livemode: refund.livemode ?? null,
|
||||
livemode: hints?.livemode ?? null,
|
||||
metadata: safeMetadata(r.metadata),
|
||||
stripe_created_at: unixToDate(r.created),
|
||||
stripe_updated_at: computeStripeUpdatedAt({created: r.created}),
|
||||
@@ -1049,16 +1030,17 @@ export function mapStripeCheckoutSessionToRow(
|
||||
cs: Stripe.Checkout.Session,
|
||||
hints?: {
|
||||
knownUserId?: bigint;
|
||||
eventCreated?: number;
|
||||
},
|
||||
): {
|
||||
primary: BillingCheckoutSessionRow;
|
||||
byCustomer: BillingCheckoutSessionByCustomerRow | null;
|
||||
} {
|
||||
const completedAt = cs.status === 'complete' ? (hints?.eventCreated ?? null) : null;
|
||||
if (!cs.id) {
|
||||
throw new BillingMappingError('Checkout session is missing id');
|
||||
}
|
||||
const now = new Date();
|
||||
const checkoutSession = cs as StripeCheckoutSessionCompatibility;
|
||||
const customerId = idOf(
|
||||
cs.customer as
|
||||
| string
|
||||
@@ -1124,14 +1106,14 @@ export function mapStripeCheckoutSessionToRow(
|
||||
amount_total: typeof cs.amount_total === 'number' ? BigInt(cs.amount_total) : null,
|
||||
currency: cs.currency ?? null,
|
||||
expires_at: unixToDate(cs.expires_at),
|
||||
completed_at: unixToDate(checkoutSession.completed_at ?? null),
|
||||
completed_at: completedAt === null ? null : unixToDate(completedAt),
|
||||
livemode: cs.livemode ?? null,
|
||||
client_reference_id: cs.client_reference_id ?? null,
|
||||
metadata: safeMetadata(cs.metadata),
|
||||
stripe_created_at: unixToDate(cs.created),
|
||||
stripe_updated_at: computeStripeUpdatedAt({
|
||||
created: cs.created,
|
||||
completed_at: checkoutSession.completed_at ?? null,
|
||||
completed_at: completedAt,
|
||||
}),
|
||||
mirrored_at: now,
|
||||
} as BillingCheckoutSessionRow;
|
||||
|
||||
@@ -49,6 +49,7 @@ export class BillingCheckoutSessionRepository {
|
||||
cs: Stripe.Checkout.Session,
|
||||
hints?: {
|
||||
knownUserId?: bigint;
|
||||
eventCreated?: number;
|
||||
},
|
||||
): Promise<{
|
||||
changed: boolean;
|
||||
|
||||
@@ -71,6 +71,7 @@ export class BillingRefundRepository {
|
||||
invoiceId?: string;
|
||||
customerId?: string;
|
||||
userId?: bigint;
|
||||
livemode?: boolean;
|
||||
},
|
||||
): Promise<{
|
||||
changed: boolean;
|
||||
|
||||
@@ -93,19 +93,7 @@ export class MessageEditService {
|
||||
assertGuildMemberCanCommunicate(member);
|
||||
}
|
||||
if (data.message_snapshots !== undefined) {
|
||||
const isAuthor = message.authorId === userId;
|
||||
const canManage = isAuthor ? true : await hasPermission(Permissions.MANAGE_MESSAGES);
|
||||
if (!isAuthor && !canManage) {
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
const updatedMessage = await this.withMessageLock(channelId, messageId, () =>
|
||||
this.deps.persistenceService.updateSnapshotAttachments({
|
||||
message,
|
||||
snapshotEdits: data.message_snapshots ?? [],
|
||||
}),
|
||||
);
|
||||
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
|
||||
return updatedMessage;
|
||||
throw new MissingPermissionsError();
|
||||
}
|
||||
const user = await this.deps.userRepository.findUnique(userId);
|
||||
this.deps.validationService.validateMessageEditable(message);
|
||||
|
||||
@@ -929,9 +929,8 @@ export class MessageSendService {
|
||||
algorithm: 'leaky_bucket',
|
||||
});
|
||||
if (!slowmodeResult.allowed) {
|
||||
const retryAfter = Math.max(0, slowmodeResult.resetTime.getTime() - Date.now());
|
||||
throw new SlowmodeRateLimitError({
|
||||
retryAfter,
|
||||
retryAfter: slowmodeResult.retryAfter,
|
||||
retryAfterDecimal: slowmodeResult.retryAfterDecimal,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -160,12 +160,8 @@ export class MessageValidationService {
|
||||
}
|
||||
}
|
||||
|
||||
calculateMessageFlags(data: {flags?: number; favorite_meme_id?: bigint | null}): number {
|
||||
let flags = data.flags ? data.flags & SENDABLE_MESSAGE_FLAGS : 0;
|
||||
if (data.favorite_meme_id) {
|
||||
flags |= MessageFlags.COMPACT_ATTACHMENTS;
|
||||
}
|
||||
return flags;
|
||||
calculateMessageFlags(data: {flags?: number}): number {
|
||||
return data.flags ? data.flags & SENDABLE_MESSAGE_FLAGS : 0;
|
||||
}
|
||||
|
||||
validateTotalAttachmentSize(
|
||||
|
||||
@@ -52,4 +52,27 @@ describe('Slowmode Enforcement', () => {
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]?.id).toBe(firstMessage.id);
|
||||
});
|
||||
it('reports the slowmode retry window in seconds on the Retry-After header', async () => {
|
||||
const rateLimitPerUser = 5;
|
||||
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const member = members[0]!;
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
const channel = await createChannel(harness, owner.token, guild.id, 'slowmode-channel');
|
||||
await updateChannel(harness, owner.token, channel.id, {
|
||||
rate_limit_per_user: rateLimitPerUser,
|
||||
});
|
||||
await sendChannelMessage(harness, member.token, channel.id, 'first message');
|
||||
const {response, json} = await createBuilder<{code: string; retry_after: number}>(harness, member.token)
|
||||
.post(`/channels/${channel.id}/messages`)
|
||||
.body({content: 'second message'})
|
||||
.expect(400, APIErrorCodes.SLOWMODE_RATE_LIMITED)
|
||||
.executeWithResponse();
|
||||
const headerRetryAfter = Number(response.headers.get('Retry-After'));
|
||||
expect(Number.isInteger(headerRetryAfter)).toBe(true);
|
||||
expect(headerRetryAfter).toBeGreaterThan(0);
|
||||
expect(headerRetryAfter).toBeLessThanOrEqual(rateLimitPerUser);
|
||||
expect(json.retry_after).toBeGreaterThan(0);
|
||||
expect(json.retry_after).toBeLessThanOrEqual(rateLimitPerUser);
|
||||
expect(headerRetryAfter - json.retry_after).toBeLessThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ResolvedDownloadsProvider} from '@fluxer/config/src/S3DownloadsProvider';
|
||||
import type {WorkerTaskName} from '../worker/WorkerLaneConfig';
|
||||
|
||||
export type APIWorkerMode = 'all_lanes' | 'single_lane' | 'single_task';
|
||||
@@ -150,6 +151,7 @@ export interface APIConfig {
|
||||
static: string;
|
||||
};
|
||||
};
|
||||
s3Downloads: ResolvedDownloadsProvider;
|
||||
email: {
|
||||
enabled: boolean;
|
||||
provider: 'smtp' | 'none';
|
||||
@@ -327,6 +329,8 @@ export interface APIConfig {
|
||||
testHarnessToken?: string;
|
||||
};
|
||||
presignedAttachmentUploadsEnabled: boolean;
|
||||
presignedDownloadsEnabled: boolean;
|
||||
presignedHarvestDownloadsEnabled: boolean;
|
||||
attachmentDecayEnabled: boolean;
|
||||
deletionGracePeriodHours: number;
|
||||
inactivityDeletionThresholdDays?: number;
|
||||
@@ -358,6 +362,14 @@ export interface APIConfig {
|
||||
taskName?: WorkerTaskName;
|
||||
enableCronScheduler?: boolean;
|
||||
enableVoiceReconciliation: boolean;
|
||||
voiceReconciliation: {
|
||||
intervalMs: number | undefined;
|
||||
staggerDelayMs: number | undefined;
|
||||
lockTtlSeconds: number | undefined;
|
||||
cadenceTtlSeconds: number | undefined;
|
||||
gatewayOnlyGraceMs: number | undefined;
|
||||
liveKitOnlyGraceMs: number | undefined;
|
||||
};
|
||||
laneConcurrencyOverrides: {
|
||||
realtime?: number;
|
||||
unfurl?: number;
|
||||
|
||||
@@ -18,9 +18,7 @@ export interface AuthSessionRow {
|
||||
approx_last_used_at: Date;
|
||||
client_ip: string;
|
||||
client_user_agent: Nullish<string>;
|
||||
client_is_desktop: Nullish<boolean>;
|
||||
client_os?: Nullish<string>;
|
||||
client_platform?: Nullish<string>;
|
||||
client_os: Nullish<string>;
|
||||
client_country: Nullish<string>;
|
||||
version: number;
|
||||
}
|
||||
@@ -32,9 +30,7 @@ export interface AuthSessionTombstoneRow {
|
||||
approx_last_used_at: Date;
|
||||
client_ip: string;
|
||||
client_user_agent: Nullish<string>;
|
||||
client_is_desktop: Nullish<boolean>;
|
||||
client_os: Nullish<string>;
|
||||
client_platform: Nullish<string>;
|
||||
client_country: Nullish<string>;
|
||||
deleted_at: Date;
|
||||
version: number;
|
||||
@@ -140,9 +136,7 @@ export const AUTH_SESSION_COLUMNS = [
|
||||
'approx_last_used_at',
|
||||
'client_ip',
|
||||
'client_user_agent',
|
||||
'client_is_desktop',
|
||||
'client_os',
|
||||
'client_platform',
|
||||
'client_country',
|
||||
'version',
|
||||
] as const satisfies ReadonlyArray<keyof AuthSessionRow>;
|
||||
@@ -153,9 +147,7 @@ export const AUTH_SESSION_TOMBSTONE_COLUMNS = [
|
||||
'approx_last_used_at',
|
||||
'client_ip',
|
||||
'client_user_agent',
|
||||
'client_is_desktop',
|
||||
'client_os',
|
||||
'client_platform',
|
||||
'client_country',
|
||||
'deleted_at',
|
||||
'version',
|
||||
|
||||
@@ -18,7 +18,12 @@ import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
|
||||
import type {HonoEnv} from '../types/HonoEnv';
|
||||
import {Validator} from '../Validator';
|
||||
import type {DesktopChecksumFile, DownloadService, DownloadStreamResult} from './DownloadService';
|
||||
import {DESKTOP_REDIRECT_PREFIX, DOWNLOAD_PREFIX, UnsatisfiableRangeError} from './DownloadService';
|
||||
import {
|
||||
DESKTOP_REDIRECT_PREFIX,
|
||||
DOWNLOAD_PREFIX,
|
||||
downloadCacheControlForKey,
|
||||
UnsatisfiableRangeError,
|
||||
} from './DownloadService';
|
||||
|
||||
function artifactFilename(key: string, filenameOverride?: string): string {
|
||||
return filenameOverride ?? key.split('/').pop() ?? 'download';
|
||||
@@ -73,6 +78,8 @@ async function headArtifactResponse(
|
||||
return new Response(null, {status: 200, headers});
|
||||
}
|
||||
|
||||
const PRESIGNED_DOWNLOAD_TTL_SECONDS = 900;
|
||||
|
||||
async function streamArtifactResponse(
|
||||
ctx: Context<HonoEnv>,
|
||||
downloadService: DownloadService,
|
||||
@@ -83,6 +90,24 @@ async function streamArtifactResponse(
|
||||
if (ctx.req.method === 'HEAD') {
|
||||
return headArtifactResponse(ctx, downloadService, key, cacheControl, filenameOverride);
|
||||
}
|
||||
if (downloadService.isPresignedDownloadEnabled()) {
|
||||
const location = await downloadService.getPresignedDownloadRedirect({
|
||||
key,
|
||||
filename: artifactFilename(key, filenameOverride),
|
||||
expiresIn: PRESIGNED_DOWNLOAD_TTL_SECONDS,
|
||||
});
|
||||
if (!location) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: new Headers({
|
||||
Location: location,
|
||||
'Cache-Control': 'no-store',
|
||||
'Accept-Ranges': 'bytes',
|
||||
}),
|
||||
});
|
||||
}
|
||||
const range = ctx.req.header('range') ?? undefined;
|
||||
let result: DownloadStreamResult | null;
|
||||
try {
|
||||
@@ -270,7 +295,7 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
|
||||
if (!checksum) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
return checksumFileResponse(ctx, checksum, 'public, max-age=86400');
|
||||
return checksumFileResponse(ctx, checksum, downloadCacheControlForKey(checksum.key));
|
||||
},
|
||||
);
|
||||
routes.on(
|
||||
@@ -296,7 +321,7 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
|
||||
if (!key) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
return streamArtifactResponse(ctx, downloadService, key, 'public, max-age=86400');
|
||||
return streamArtifactResponse(ctx, downloadService, key, downloadCacheControlForKey(key));
|
||||
},
|
||||
);
|
||||
routes.on(
|
||||
@@ -320,7 +345,7 @@ export function DownloadController(routes: Hono<HonoEnv>): void {
|
||||
if (!key) {
|
||||
return ctx.text('Not Found', 404);
|
||||
}
|
||||
return streamArtifactResponse(ctx, downloadService, key, 'public, max-age=300');
|
||||
return streamArtifactResponse(ctx, downloadService, key, downloadCacheControlForKey(key));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,35 @@ function desktopBucketPrefix(test?: boolean): string {
|
||||
return test ? DESKTOP_TEST_BUCKET_PREFIX : DESKTOP_BUCKET_PREFIX;
|
||||
}
|
||||
|
||||
const MUTABLE_DOWNLOAD_CACHE_CONTROL = 'public, max-age=300';
|
||||
const VERSIONED_ARTIFACT_CACHE_CONTROL = 'public, max-age=31536000';
|
||||
|
||||
function isDesktopReleaseFeedFilename(filename: string): boolean {
|
||||
return (
|
||||
filename === 'manifest.json' ||
|
||||
filename.endsWith('.yml') ||
|
||||
filename.endsWith('.yaml') ||
|
||||
filename.startsWith('RELEASES') ||
|
||||
(filename.startsWith('releases') && filename.endsWith('.json')) ||
|
||||
(filename.startsWith('assets') && filename.endsWith('.json'))
|
||||
);
|
||||
}
|
||||
|
||||
function isVersionedDesktopArtifactKey(key: string): boolean {
|
||||
if (!key.startsWith(`${DESKTOP_BUCKET_PREFIX}/`)) {
|
||||
return false;
|
||||
}
|
||||
const filename = key.split('/').pop() ?? '';
|
||||
if (filename.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return !isDesktopReleaseFeedFilename(filename);
|
||||
}
|
||||
|
||||
export function downloadCacheControlForKey(key: string): string {
|
||||
return isVersionedDesktopArtifactKey(key) ? VERSIONED_ARTIFACT_CACHE_CONTROL : MUTABLE_DOWNLOAD_CACHE_CONTROL;
|
||||
}
|
||||
|
||||
function desktopArtifactPrefix(params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
@@ -108,6 +137,7 @@ type VersionInfo = {
|
||||
files: Record<string, VersionFile>;
|
||||
};
|
||||
export type DesktopChecksumFile = {
|
||||
key: string;
|
||||
filename: string;
|
||||
sha256: string;
|
||||
body: string;
|
||||
@@ -461,7 +491,7 @@ export class DownloadService {
|
||||
return null;
|
||||
}
|
||||
const filename = this.filenameFromKey(key);
|
||||
return this.buildDesktopChecksumFile(filename, file.sha256);
|
||||
return this.buildDesktopChecksumFile(key, filename, file.sha256);
|
||||
}
|
||||
|
||||
async resolveVersionedDesktopChecksumFile(params: {
|
||||
@@ -479,14 +509,14 @@ export class DownloadService {
|
||||
const filename = this.filenameFromKey(key);
|
||||
const objectSha256 = await this.readDesktopSha256ForArtifactKey(key);
|
||||
if (objectSha256) {
|
||||
return this.buildDesktopChecksumFile(filename, objectSha256);
|
||||
return this.buildDesktopChecksumFile(key, filename, objectSha256);
|
||||
}
|
||||
const latest = await this.getLatestDesktopVersion(params);
|
||||
const file = latest?.version === params.version ? latest.files[params.format] : undefined;
|
||||
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
|
||||
return null;
|
||||
}
|
||||
return this.buildDesktopChecksumFile(filename, file.sha256);
|
||||
return this.buildDesktopChecksumFile(key, filename, file.sha256);
|
||||
}
|
||||
|
||||
async resolveDownloadKey(params: {path: string; test?: boolean}): Promise<string | null> {
|
||||
@@ -533,6 +563,28 @@ export class DownloadService {
|
||||
}
|
||||
}
|
||||
|
||||
isPresignedDownloadEnabled(): boolean {
|
||||
return Config.presignedDownloadsEnabled;
|
||||
}
|
||||
|
||||
async getPresignedDownloadRedirect(params: {
|
||||
key: string;
|
||||
filename: string;
|
||||
expiresIn: number;
|
||||
}): Promise<string | null> {
|
||||
const metadata = await this.getDownloadMetadata({key: params.key});
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
return this.storageService.getPresignedDownloadURL({
|
||||
bucket: Config.s3.buckets.downloads,
|
||||
key: params.key,
|
||||
expiresIn: params.expiresIn,
|
||||
responseContentType: metadata.contentType ?? 'application/octet-stream',
|
||||
responseContentDisposition: `attachment; filename="${encodeURIComponent(params.filename)}"`,
|
||||
});
|
||||
}
|
||||
|
||||
async getDownloadMetadata(params: {key: string}): Promise<{
|
||||
contentLength: number;
|
||||
contentType?: string | null;
|
||||
@@ -1129,8 +1181,9 @@ export class DownloadService {
|
||||
return key.split('/').pop() ?? 'download';
|
||||
}
|
||||
|
||||
private buildDesktopChecksumFile(filename: string, sha256: string): DesktopChecksumFile {
|
||||
private buildDesktopChecksumFile(key: string, filename: string, sha256: string): DesktopChecksumFile {
|
||||
return {
|
||||
key,
|
||||
filename,
|
||||
sha256,
|
||||
body: `${sha256} ${filename}\n`,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {IStorageService} from '../../infrastructure/IStorageService';
|
||||
import {DownloadService} from '../DownloadService';
|
||||
|
||||
const OBJECT_METADATA = {
|
||||
contentLength: 285_567_850,
|
||||
contentType: 'application/x-apple-diskimage',
|
||||
etag: '"abc123"',
|
||||
lastModified: new Date('2026-08-17T00:00:00Z'),
|
||||
};
|
||||
|
||||
interface PresignCall {
|
||||
bucket: string;
|
||||
key: string;
|
||||
expiresIn?: number;
|
||||
responseContentType?: string;
|
||||
responseContentDisposition?: string;
|
||||
}
|
||||
|
||||
function createService(overrides: {metadata?: typeof OBJECT_METADATA | null} = {}) {
|
||||
const presignCalls: Array<PresignCall> = [];
|
||||
const storageService = {
|
||||
getObjectMetadata: async () => (overrides.metadata === undefined ? OBJECT_METADATA : overrides.metadata),
|
||||
getPresignedDownloadURL: async (params: PresignCall) => {
|
||||
presignCalls.push(params);
|
||||
return `https://storage.example.test/${params.key}?signed=1`;
|
||||
},
|
||||
} as unknown as IStorageService;
|
||||
return {service: new DownloadService(storageService), presignCalls};
|
||||
}
|
||||
|
||||
describe('presigned download redirects', () => {
|
||||
it('signs the requested object and returns its URL', async () => {
|
||||
const {service, presignCalls} = createService();
|
||||
const url = await service.getPresignedDownloadRedirect({
|
||||
key: 'desktop/canary/darwin/universal/Fluxer.dmg',
|
||||
filename: 'Fluxer.dmg',
|
||||
expiresIn: 900,
|
||||
});
|
||||
expect(url).toBe('https://storage.example.test/desktop/canary/darwin/universal/Fluxer.dmg?signed=1');
|
||||
expect(presignCalls).toHaveLength(1);
|
||||
expect(presignCalls[0]?.key).toBe('desktop/canary/darwin/universal/Fluxer.dmg');
|
||||
expect(presignCalls[0]?.expiresIn).toBe(900);
|
||||
});
|
||||
|
||||
it('preserves the download filename and content type through the redirect', async () => {
|
||||
const {service, presignCalls} = createService();
|
||||
await service.getPresignedDownloadRedirect({
|
||||
key: 'desktop/canary/darwin/universal/Fluxer.dmg',
|
||||
filename: 'Fluxer Canary.dmg',
|
||||
expiresIn: 900,
|
||||
});
|
||||
expect(presignCalls[0]?.responseContentType).toBe('application/x-apple-diskimage');
|
||||
expect(presignCalls[0]?.responseContentDisposition).toBe(
|
||||
`attachment; filename="${encodeURIComponent('Fluxer Canary.dmg')}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to a binary content type when storage reports none', async () => {
|
||||
const {service, presignCalls} = createService({
|
||||
metadata: {...OBJECT_METADATA, contentType: null} as unknown as typeof OBJECT_METADATA,
|
||||
});
|
||||
await service.getPresignedDownloadRedirect({key: 'desktop/x.bin', filename: 'x.bin', expiresIn: 900});
|
||||
expect(presignCalls[0]?.responseContentType).toBe('application/octet-stream');
|
||||
});
|
||||
|
||||
it('returns null for a missing object so the caller can answer 404 without signing', async () => {
|
||||
const {service, presignCalls} = createService({metadata: null});
|
||||
const url = await service.getPresignedDownloadRedirect({
|
||||
key: 'desktop/missing.dmg',
|
||||
filename: 'missing.dmg',
|
||||
expiresIn: 900,
|
||||
});
|
||||
expect(url).toBeNull();
|
||||
expect(presignCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {Logger} from '@fluxer/logger/src/Logger';
|
||||
import type {ResolvedGifEntrySchema} from '@fluxer/schema/src/domains/gif/FavoriteGifSchemas';
|
||||
import {inferFormatContentType, PREVIEW_FORMAT_PRIORITY} from '@fluxer/schema/src/domains/gif/GifMediaFormatKeys';
|
||||
import type {GifMediaFormat, GifResponse} from '@fluxer/schema/src/domains/gif/GifSchemas';
|
||||
import type {EmbedMediaResponse} from '@fluxer/schema/src/domains/message/EmbedSchemas';
|
||||
import {tryExtractGifProviderSlug} from '../gif/GifProviderUtils';
|
||||
@@ -10,17 +11,6 @@ import type {IGifProvider} from '../gif/IGifProvider';
|
||||
import type {IMediaService, MediaProxyMetadataResponse} from '../infrastructure/IMediaService';
|
||||
import type {IUnfurlerService} from '../infrastructure/IUnfurlerService';
|
||||
|
||||
const PREVIEW_FORMAT_PRIORITY = ['webm', 'mp4', 'tinywebm', 'tinymp4', 'webp', 'gif', 'tinygif', 'nanogif'] as const;
|
||||
const FORMAT_CONTENT_TYPES: Record<string, string> = {
|
||||
webm: 'video/webm',
|
||||
tinywebm: 'video/webm',
|
||||
mp4: 'video/mp4',
|
||||
tinymp4: 'video/mp4',
|
||||
webp: 'image/webp',
|
||||
gif: 'image/gif',
|
||||
tinygif: 'image/gif',
|
||||
nanogif: 'image/gif',
|
||||
};
|
||||
const logger = new Logger('FavoriteGifResolver');
|
||||
|
||||
function pickFavoriteGifPreviewFormat(
|
||||
@@ -221,10 +211,6 @@ function isUsableGifMediaFormat(format: GifMediaFormat | undefined): format is G
|
||||
return Boolean(format?.src && format.proxy_src && format.width > 0 && format.height > 0);
|
||||
}
|
||||
|
||||
function inferFormatContentType(formatKey: string): string {
|
||||
return FORMAT_CONTENT_TYPES[formatKey] ?? '';
|
||||
}
|
||||
|
||||
function isRenderableMediaType(contentType: string | null | undefined): boolean {
|
||||
if (!contentType) return false;
|
||||
return contentType.startsWith('image/') || contentType.startsWith('video/');
|
||||
|
||||
@@ -50,7 +50,9 @@ export function GuildBaseController(app: HonoApp) {
|
||||
if (policy.single_community_enabled) {
|
||||
throw new SingleCommunityCannotCreateGuildsError();
|
||||
}
|
||||
requireEmailVerified(user, 'guild_creation');
|
||||
if (!user.isUnclaimedAccount()) {
|
||||
requireEmailVerified(user, 'guild_creation');
|
||||
}
|
||||
const auditLogReason = ctx.get('auditLogReason') ?? null;
|
||||
const locale = ctx.get('requestLocale') ?? null;
|
||||
return ctx.json(await ctx.get('guildService').data.createGuild({user, data, locale}, auditLogReason));
|
||||
|
||||
@@ -104,6 +104,7 @@ export function GuildDiscoveryController(app: HonoApp) {
|
||||
app.post(
|
||||
'/discovery/guilds/:guild_id/join',
|
||||
RateLimitMiddleware(RateLimitConfigs.DISCOVERY_JOIN),
|
||||
LoginRequired,
|
||||
DefaultUserOnly,
|
||||
Validator('param', GuildIdParam),
|
||||
OpenAPI({
|
||||
|
||||
@@ -98,13 +98,20 @@ export abstract class IGuildDiscoveryService {
|
||||
}): Promise<{
|
||||
guilds: Array<DiscoveryGuildResult>;
|
||||
total: number;
|
||||
category_counts: Array<DiscoveryCategoryCount>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface DiscoveryCategoryCount {
|
||||
category_type: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
interface DiscoveryGuildResult {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string | null;
|
||||
banner: string | null;
|
||||
description: string | null;
|
||||
category_type: number;
|
||||
primary_language: string | null;
|
||||
@@ -115,6 +122,25 @@ interface DiscoveryGuildResult {
|
||||
verification_level: number;
|
||||
}
|
||||
|
||||
const DISCOVERY_CATEGORY_FACET = 'discoveryCategory';
|
||||
|
||||
function toDiscoveryCategoryCounts(
|
||||
counts: Readonly<Record<string, number>> | undefined,
|
||||
): Array<DiscoveryCategoryCount> {
|
||||
if (!counts) {
|
||||
return [];
|
||||
}
|
||||
const entries: Array<DiscoveryCategoryCount> = [];
|
||||
for (const [key, count] of Object.entries(counts)) {
|
||||
const categoryType = Number.parseInt(key, 10);
|
||||
if (!Number.isInteger(categoryType) || count <= 0) {
|
||||
continue;
|
||||
}
|
||||
entries.push({category_type: categoryType, count});
|
||||
}
|
||||
return entries.sort((left, right) => left.category_type - right.category_type);
|
||||
}
|
||||
|
||||
export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
constructor(
|
||||
private readonly discoveryRepository: IGuildDiscoveryRepository,
|
||||
@@ -377,6 +403,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
}): Promise<{
|
||||
guilds: Array<DiscoveryGuildResult>;
|
||||
total: number;
|
||||
category_counts: Array<DiscoveryCategoryCount>;
|
||||
}> {
|
||||
if (!this.guildSearchService) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
@@ -393,14 +420,23 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
sortBy,
|
||||
sortOrder: 'desc',
|
||||
};
|
||||
const results = await this.guildSearchService.searchGuilds(params.query ?? '', filters, {
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
});
|
||||
const categoryFacetFilters: GuildSearchFilters = {...filters, discoveryCategory: undefined};
|
||||
const [results, categoryFacets] = await Promise.all([
|
||||
this.guildSearchService.searchGuilds(params.query ?? '', filters, {
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
}),
|
||||
this.guildSearchService.searchGuilds(params.query ?? '', categoryFacetFilters, {
|
||||
limit: 0,
|
||||
facets: [DISCOVERY_CATEGORY_FACET],
|
||||
}),
|
||||
]);
|
||||
const categoryCounts = toDiscoveryCategoryCounts(categoryFacets.facetCounts?.[DISCOVERY_CATEGORY_FACET]);
|
||||
const guilds: Array<DiscoveryGuildResult> = results.hits.map((hit) => ({
|
||||
id: hit.id,
|
||||
name: hit.name,
|
||||
icon: hit.iconHash,
|
||||
banner: hit.bannerHash,
|
||||
description: hit.discoveryDescription,
|
||||
category_type: hit.discoveryCategory ?? 0,
|
||||
primary_language: hit.discoveryPrimaryLanguage ?? null,
|
||||
@@ -429,7 +465,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
);
|
||||
}
|
||||
}
|
||||
return {guilds, total};
|
||||
return {guilds, total, category_counts: categoryCounts};
|
||||
}
|
||||
|
||||
private async addDiscoverableFeature(guildId: GuildID): Promise<void> {
|
||||
|
||||
@@ -319,6 +319,7 @@ export class GuildMemberService {
|
||||
sendJoinMessage?: boolean;
|
||||
skipGuildLimitCheck?: boolean;
|
||||
skipBanCheck?: boolean;
|
||||
skipRiskGate?: boolean;
|
||||
isTemporary?: boolean;
|
||||
joinSourceType?: JoinSourceType;
|
||||
sourceInviteCode?: InviteCode;
|
||||
|
||||
@@ -2,10 +2,17 @@
|
||||
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {type JoinSourceType, JoinSourceTypes, SystemChannelFlags} from '@fluxer/constants/src/GuildConstants';
|
||||
import {
|
||||
GuildFeatures,
|
||||
type JoinSourceType,
|
||||
JoinSourceTypes,
|
||||
SystemChannelFlags,
|
||||
} from '@fluxer/constants/src/GuildConstants';
|
||||
import {
|
||||
DEFAULT_GUILD_FOLDER_ICON,
|
||||
DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
type MentionReplyPreference,
|
||||
PHONE_REQUIREMENT_FLAGS,
|
||||
UserNotificationSettings,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
@@ -17,10 +24,12 @@ import {MaxGuildsError} from '@fluxer/errors/src/domains/guild/MaxGuildsError';
|
||||
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
|
||||
import {UnknownGuildMemberError} from '@fluxer/errors/src/domains/guild/UnknownGuildMemberError';
|
||||
import {CommunicationDisabledError} from '@fluxer/errors/src/domains/moderation/CommunicationDisabledError';
|
||||
import {AccountSuspiciousActivityError} from '@fluxer/errors/src/domains/user/AccountSuspiciousActivityError';
|
||||
import {UserNotInVoiceError} from '@fluxer/errors/src/domains/user/UserNotInVoiceError';
|
||||
import {DEFAULT_STOCK_LIMITS} from '@fluxer/limits/src/LimitDefaults';
|
||||
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
|
||||
import type {GuildMemberUpdateRequest} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import type {IRateLimitService} from '@pkgs/rate_limit/src/IRateLimitService';
|
||||
import {ms} from 'itty-time';
|
||||
import {requireEmailVerified} from '../../../auth/EmailVerificationUtils';
|
||||
@@ -38,13 +47,24 @@ import {resolveLimitSafe} from '../../../limits/LimitConfigUtils';
|
||||
import {createLimitMatchContext} from '../../../limits/LimitMatchContextBuilder';
|
||||
import {profileSubstringBlocklistCache} from '../../../middleware/ProfileSubstringBlocklistCache';
|
||||
import type {RequestCache} from '../../../middleware/RequestCacheMiddleware';
|
||||
import type {Guild} from '../../../models/Guild';
|
||||
import type {GuildMember} from '../../../models/GuildMember';
|
||||
import type {User} from '../../../models/User';
|
||||
import type {UserGuildSettings} from '../../../models/UserGuildSettings';
|
||||
import type {UserSettings} from '../../../models/UserSettings';
|
||||
import {
|
||||
DEFAULT_PHONE_GATE_MEMBER_THRESHOLD,
|
||||
evaluateDeferredPhoneGate,
|
||||
getDeferredPhoneGateConfig,
|
||||
guildTriggersPhoneGate,
|
||||
} from '../../../risk/DeferredPhoneGate';
|
||||
import type {IUserRepository} from '../../../user/IUserRepository';
|
||||
import {isProfileSubstringExempt} from '../../../user/UserHelpers';
|
||||
import {mapUserGuildSettingsToResponse, mapUserSettingsToResponse} from '../../../user/UserMappers';
|
||||
import {getEffectiveSuspiciousFlags, isProfileSubstringExempt} from '../../../user/UserHelpers';
|
||||
import {
|
||||
mapUserGuildSettingsToResponse,
|
||||
mapUserSettingsToResponse,
|
||||
mapUserToPrivateResponse,
|
||||
} from '../../../user/UserMappers';
|
||||
import {addGuildToUncategorizedFolder, removeGuildFromUserFolders} from '../../../user/utils/GuildFolderUtils';
|
||||
import type {GuildAuditLogService} from '../../GuildAuditLogService';
|
||||
import type {GuildAuditLogChange} from '../../GuildAuditLogTypes';
|
||||
@@ -374,6 +394,68 @@ export class GuildMemberOperationsService {
|
||||
await this.gatewayService.leaveGuild({userId: targetId, guildId});
|
||||
}
|
||||
|
||||
private async applyDeferredPhoneGate(user: User, guild: Guild): Promise<void> {
|
||||
if (user.hasVerifiedPhone) {
|
||||
return;
|
||||
}
|
||||
const rawFlags = user.suspiciousActivityFlags ?? 0;
|
||||
if ((rawFlags & (DEFERRED_PHONE_ON_COMMUNITY_JOIN | PHONE_REQUIREMENT_FLAGS)) === 0) {
|
||||
return;
|
||||
}
|
||||
const {status, config} = await getDeferredPhoneGateConfig();
|
||||
const logContext = {
|
||||
userId: user.id.toString(),
|
||||
guildId: guild.id.toString(),
|
||||
discoverable: guild.features.has(GuildFeatures.DISCOVERABLE),
|
||||
memberCount: guild.memberCount,
|
||||
accountAgeMs: Date.now() - snowflakeToDate(BigInt(user.id)).getTime(),
|
||||
};
|
||||
if (status !== 'ok') {
|
||||
const undeferredFlags = getEffectiveSuspiciousFlags({
|
||||
...user,
|
||||
suspiciousActivityFlags: rawFlags & ~DEFERRED_PHONE_ON_COMMUNITY_JOIN,
|
||||
} as User);
|
||||
if (
|
||||
(undeferredFlags & PHONE_REQUIREMENT_FLAGS) === 0 ||
|
||||
!guildTriggersPhoneGate(guild, DEFAULT_PHONE_GATE_MEMBER_THRESHOLD)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
Logger.info(logContext, `deferred_phone_gate.enforced_while_${status}`);
|
||||
throw new AccountSuspiciousActivityError(undeferredFlags);
|
||||
}
|
||||
const liveFlags = getEffectiveSuspiciousFlags(user);
|
||||
if ((liveFlags & PHONE_REQUIREMENT_FLAGS) !== 0) {
|
||||
if (!guildTriggersPhoneGate(guild, config.memberThreshold)) {
|
||||
return;
|
||||
}
|
||||
Logger.info(logContext, 'deferred_phone_gate.blocked_unsatisfied_phone_requirement');
|
||||
throw new AccountSuspiciousActivityError(liveFlags);
|
||||
}
|
||||
const outcome = evaluateDeferredPhoneGate(user, guild, config, Date.now());
|
||||
if (!outcome.applies) {
|
||||
Logger.info(logContext, `deferred_phone_gate.skipped_${outcome.reason}`);
|
||||
return;
|
||||
}
|
||||
const promotedFlags = getEffectiveSuspiciousFlags({...user, suspiciousActivityFlags: outcome.flags} as User);
|
||||
if (promotedFlags === 0) {
|
||||
Logger.info(logContext, 'deferred_phone_gate.skipped_unenforceable');
|
||||
return;
|
||||
}
|
||||
const updatedUser = await this.userRepository.patchUpsert(
|
||||
user.id,
|
||||
{suspicious_activity_flags: outcome.flags},
|
||||
user.toRow(),
|
||||
);
|
||||
await this.gatewayService.dispatchPresence({
|
||||
userId: user.id,
|
||||
event: 'USER_UPDATE',
|
||||
data: mapUserToPrivateResponse(updatedUser),
|
||||
});
|
||||
Logger.info(logContext, 'deferred_phone_gate.applied');
|
||||
throw new AccountSuspiciousActivityError(promotedFlags);
|
||||
}
|
||||
|
||||
async addUserToGuild(
|
||||
params: {
|
||||
userId: UserID;
|
||||
@@ -381,6 +463,7 @@ export class GuildMemberOperationsService {
|
||||
sendJoinMessage?: boolean;
|
||||
skipGuildLimitCheck?: boolean;
|
||||
skipBanCheck?: boolean;
|
||||
skipRiskGate?: boolean;
|
||||
isTemporary?: boolean;
|
||||
joinSourceType?: JoinSourceType;
|
||||
sourceInviteCode?: InviteCode;
|
||||
@@ -398,6 +481,7 @@ export class GuildMemberOperationsService {
|
||||
sendJoinMessage = true,
|
||||
skipGuildLimitCheck = false,
|
||||
skipBanCheck = false,
|
||||
skipRiskGate = false,
|
||||
isTemporary = false,
|
||||
joinSourceType = JoinSourceTypes.INSTANT_INVITE,
|
||||
sourceInviteCode = null,
|
||||
@@ -418,6 +502,9 @@ export class GuildMemberOperationsService {
|
||||
if (!skipGuildLimitCheck) {
|
||||
await this.enforceGuildLimit(user, userGuildsCount);
|
||||
}
|
||||
if (!skipRiskGate && !user.isBot) {
|
||||
await this.applyDeferredPhoneGate(user, guild);
|
||||
}
|
||||
const maxGuildMembers = resolveMaxGuildMembersLimit({
|
||||
guildFeatures: guild.features,
|
||||
snapshot: this.limitConfigService.getConfigSnapshot(),
|
||||
|
||||
@@ -120,6 +120,69 @@ describe('Discovery Search and Join', () => {
|
||||
expect(found!.category_type).toBe(DiscoveryCategories.GAMING);
|
||||
expect(found!.verification_level).toBe(GuildVerificationLevel.LOW);
|
||||
});
|
||||
test('should report category counts that ignore the selected category filter', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
const gamingOwner = await createTestAccount(harness);
|
||||
const gamingGuild = await createGuild(harness, gamingOwner.token, 'Counted Gaming Guild');
|
||||
await setGuildMemberCount(harness, gamingGuild.id, 10);
|
||||
await applyAndApprove(
|
||||
harness,
|
||||
gamingOwner.token,
|
||||
admin.token,
|
||||
gamingGuild.id,
|
||||
'A gaming community for counting',
|
||||
DiscoveryCategories.GAMING,
|
||||
);
|
||||
const musicOwner = await createTestAccount(harness);
|
||||
const musicGuild = await createGuild(harness, musicOwner.token, 'Counted Music Guild');
|
||||
await setGuildMemberCount(harness, musicGuild.id, 10);
|
||||
await applyAndApprove(
|
||||
harness,
|
||||
musicOwner.token,
|
||||
admin.token,
|
||||
musicGuild.id,
|
||||
'A music community for counting',
|
||||
DiscoveryCategories.MUSIC,
|
||||
);
|
||||
|
||||
const searcher = await createTestAccount(harness);
|
||||
const filtered = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get(`/discovery/guilds?category=${DiscoveryCategories.GAMING}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(filtered.guilds.every((guild) => guild.category_type === DiscoveryCategories.GAMING)).toBe(true);
|
||||
const gamingCount = filtered.category_counts.find((entry) => entry.category_type === DiscoveryCategories.GAMING);
|
||||
const musicCount = filtered.category_counts.find((entry) => entry.category_type === DiscoveryCategories.MUSIC);
|
||||
expect(gamingCount?.count).toBeGreaterThanOrEqual(1);
|
||||
expect(musicCount?.count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('should expose the guild banner hash in search results', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Bannered Guild');
|
||||
await setGuildMemberCount(harness, guild.id, 10);
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
await applyAndApprove(
|
||||
harness,
|
||||
owner.token,
|
||||
admin.token,
|
||||
guild.id,
|
||||
'A community with a banner',
|
||||
DiscoveryCategories.GAMING,
|
||||
);
|
||||
const searcher = await createTestAccount(harness);
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const found = results.guilds.find((entry) => entry.id === guild.id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found).toHaveProperty('banner');
|
||||
});
|
||||
|
||||
test('should not return pending guilds in search results', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Pending Guild');
|
||||
|
||||
@@ -72,7 +72,13 @@ export interface IStorageService {
|
||||
destinationKey: string;
|
||||
newContentType?: string;
|
||||
}): Promise<void>;
|
||||
getPresignedDownloadURL(params: {bucket: string; key: string; expiresIn?: number}): Promise<string>;
|
||||
getPresignedDownloadURL(params: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
expiresIn?: number;
|
||||
responseContentType?: string;
|
||||
responseContentDisposition?: string;
|
||||
}): Promise<string>;
|
||||
getPresignedUploadURL(params: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
|
||||
@@ -187,3 +187,26 @@ describe('StorageService.copyObjectWithMetadataStripping', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('provider selection', () => {
|
||||
interface ClientProbe {
|
||||
client: {config: {region: () => Promise<string>; endpoint?: () => Promise<{hostname: string}>}};
|
||||
}
|
||||
|
||||
it('defaults to the shared S3 configuration', async () => {
|
||||
const service = new StorageService() as unknown as ClientProbe;
|
||||
expect(await service.client.config.region()).toBe(Config.s3.region);
|
||||
});
|
||||
|
||||
it('uses an explicitly supplied provider instead of the shared one', async () => {
|
||||
const service = new StorageService({
|
||||
endpoint: 'https://downloads.example.net',
|
||||
forcePathStyle: false,
|
||||
region: 'eu-central-9',
|
||||
accessKeyId: 'DL_KEY',
|
||||
secretAccessKey: 'DL_SECRET',
|
||||
}) as unknown as ClientProbe;
|
||||
expect(await service.client.config.region()).toBe('eu-central-9');
|
||||
expect(await service.client.config.region()).not.toBe(Config.s3.region);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '@aws-sdk/client-s3';
|
||||
import {Upload} from '@aws-sdk/lib-storage';
|
||||
import {getSignedUrl} from '@aws-sdk/s3-request-presigner';
|
||||
import type {S3ProviderSettings} from '@fluxer/config/src/S3DownloadsProvider';
|
||||
import {isSupportedMediaContentType} from '@pkgs/mime_utils/src/ContentTypeUtils';
|
||||
import {seconds} from 'itty-time';
|
||||
import {temporaryFile} from 'tempy';
|
||||
@@ -105,27 +106,36 @@ function extractStreamFromGet(out: GetObjectCommandOutput): Readable {
|
||||
export class StorageService implements IStorageService {
|
||||
private readonly client: S3Client;
|
||||
private readonly presignClient: S3Client;
|
||||
private readonly provider: S3ProviderSettings;
|
||||
|
||||
constructor() {
|
||||
this.client = buildPooledS3Client({
|
||||
constructor(provider?: S3ProviderSettings) {
|
||||
this.provider = provider ?? {
|
||||
endpoint: Config.s3.endpoint,
|
||||
presignedUrlBase: Config.s3.presignedUrlBase,
|
||||
forcePathStyle: Config.s3.forcePathStyle,
|
||||
region: Config.s3.region,
|
||||
accessKeyId: Config.s3.accessKeyId,
|
||||
secretAccessKey: Config.s3.secretAccessKey,
|
||||
};
|
||||
this.client = buildPooledS3Client({
|
||||
endpoint: this.provider.endpoint,
|
||||
region: this.provider.region,
|
||||
accessKeyId: this.provider.accessKeyId,
|
||||
secretAccessKey: this.provider.secretAccessKey,
|
||||
forcePathStyle: true,
|
||||
});
|
||||
this.presignClient = buildPooledS3Client({
|
||||
endpoint: this.resolvePresignEndpoint(),
|
||||
region: Config.s3.region,
|
||||
accessKeyId: Config.s3.accessKeyId,
|
||||
secretAccessKey: Config.s3.secretAccessKey,
|
||||
forcePathStyle: Config.s3.forcePathStyle,
|
||||
region: this.provider.region,
|
||||
accessKeyId: this.provider.accessKeyId,
|
||||
secretAccessKey: this.provider.secretAccessKey,
|
||||
forcePathStyle: this.provider.forcePathStyle,
|
||||
});
|
||||
}
|
||||
|
||||
private resolvePresignEndpoint(): string {
|
||||
const fallbackEndpoint = Config.s3.endpoint;
|
||||
const configuredEndpoint = Config.s3.presignedUrlBase;
|
||||
const fallbackEndpoint = this.provider.endpoint;
|
||||
const configuredEndpoint = this.provider.presignedUrlBase;
|
||||
if (!configuredEndpoint) {
|
||||
return fallbackEndpoint;
|
||||
}
|
||||
@@ -241,14 +251,20 @@ export class StorageService implements IStorageService {
|
||||
bucket,
|
||||
key,
|
||||
expiresIn = seconds('5 minutes'),
|
||||
responseContentType,
|
||||
responseContentDisposition,
|
||||
}: {
|
||||
bucket: string;
|
||||
key: string;
|
||||
expiresIn?: number;
|
||||
responseContentType?: string;
|
||||
responseContentDisposition?: string;
|
||||
}): Promise<string> {
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
ResponseContentType: responseContentType,
|
||||
ResponseContentDisposition: responseContentDisposition,
|
||||
});
|
||||
return getSignedUrl(this.presignClient, command, {expiresIn});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {Config} from '../Config';
|
||||
import {createDownloadsStorageService} from './StorageServiceFactory';
|
||||
|
||||
describe('createDownloadsStorageService', () => {
|
||||
it('returns null when no downloads override is configured', () => {
|
||||
expect(Config.s3Downloads.isOverridden).toBe(false);
|
||||
expect(createDownloadsStorageService()).toBeNull();
|
||||
});
|
||||
|
||||
it('resolves the downloads provider to the shared provider by default', () => {
|
||||
expect(Config.s3Downloads.settings.endpoint).toBe(Config.s3.endpoint);
|
||||
expect(Config.s3Downloads.settings.region).toBe(Config.s3.region);
|
||||
expect(Config.s3Downloads.settings.accessKeyId).toBe(Config.s3.accessKeyId);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '../Config';
|
||||
import type {IStorageService} from './IStorageService';
|
||||
import {StorageService} from './StorageService';
|
||||
|
||||
export function createStorageService(): IStorageService {
|
||||
return new StorageService();
|
||||
}
|
||||
|
||||
export function createDownloadsStorageService(): IStorageService | null {
|
||||
if (!Config.s3Downloads.isOverridden) {
|
||||
return null;
|
||||
}
|
||||
return new StorageService(Config.s3Downloads.settings);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {sanitizeLimitConfigForInstance} from '../constants/LimitConfig';
|
||||
import {fetchMany, fetchOne, upsertOne} from '../database/CassandraQueryExecution';
|
||||
import type {InstanceConfigurationRow} from '../database/types/InstanceConfigTypes';
|
||||
import {Logger} from '../Logger';
|
||||
import {resolveDeferredPhoneGateEnabled, setCachedDeferredPhoneGateEnabled} from '../risk/DeferredPhoneGateCache';
|
||||
import {InstanceConfiguration} from '../Tables';
|
||||
import {DEFAULT_DECAY_CONSTANTS, DEFAULT_RENEWAL_CONSTANTS} from '../utils/AttachmentDecay';
|
||||
import {isJsonRecord, parseJsonArray, parseJsonRecord} from '../utils/JsonBoundaryUtils';
|
||||
@@ -77,7 +78,6 @@ export type InstancePremiumMode = 'mirror' | 'everyone';
|
||||
|
||||
export interface InstancePolicyConfig {
|
||||
single_community_enabled: boolean;
|
||||
single_community_locked: boolean;
|
||||
single_community_guild_id: string | null;
|
||||
direct_messages_disabled: boolean;
|
||||
direct_messages_locked: boolean;
|
||||
@@ -85,6 +85,9 @@ export interface InstancePolicyConfig {
|
||||
gif_enabled: boolean | null;
|
||||
youtube_enabled: boolean | null;
|
||||
bluesky_enabled: boolean | null;
|
||||
deferred_phone_gate_enabled: boolean;
|
||||
deferred_phone_gate_window_hours: number;
|
||||
deferred_phone_gate_member_threshold: number;
|
||||
}
|
||||
|
||||
interface InstanceCommunityPublicConfig {
|
||||
@@ -396,7 +399,6 @@ function normalizeAppPublicConfig(value: unknown): InstanceAppPublicConfig {
|
||||
|
||||
const DEFAULT_INSTANCE_POLICY_CONFIG: InstancePolicyConfig = {
|
||||
single_community_enabled: false,
|
||||
single_community_locked: false,
|
||||
single_community_guild_id: null,
|
||||
direct_messages_disabled: false,
|
||||
direct_messages_locked: false,
|
||||
@@ -404,6 +406,9 @@ const DEFAULT_INSTANCE_POLICY_CONFIG: InstancePolicyConfig = {
|
||||
gif_enabled: null,
|
||||
youtube_enabled: null,
|
||||
bluesky_enabled: null,
|
||||
deferred_phone_gate_enabled: false,
|
||||
deferred_phone_gate_window_hours: 6,
|
||||
deferred_phone_gate_member_threshold: 50,
|
||||
};
|
||||
|
||||
function isPremiumMode(value: unknown): value is InstancePremiumMode {
|
||||
@@ -414,13 +419,19 @@ function normalizeNullableBoolean(value: unknown): boolean | null {
|
||||
return typeof value === 'boolean' ? value : null;
|
||||
}
|
||||
|
||||
function normalizePositiveNumber(value: unknown, fallback: number): number {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeInstancePolicyConfig(value: unknown): InstancePolicyConfig {
|
||||
if (!isJsonRecord(value)) {
|
||||
return {...DEFAULT_INSTANCE_POLICY_CONFIG};
|
||||
}
|
||||
return {
|
||||
single_community_enabled: value.single_community_enabled === true,
|
||||
single_community_locked: value.single_community_locked === true,
|
||||
single_community_guild_id: normalizeNullableString(value.single_community_guild_id),
|
||||
direct_messages_disabled: value.direct_messages_disabled === true,
|
||||
direct_messages_locked: value.direct_messages_locked === true,
|
||||
@@ -428,6 +439,15 @@ function normalizeInstancePolicyConfig(value: unknown): InstancePolicyConfig {
|
||||
gif_enabled: normalizeNullableBoolean(value.gif_enabled),
|
||||
youtube_enabled: normalizeNullableBoolean(value.youtube_enabled),
|
||||
bluesky_enabled: normalizeNullableBoolean(value.bluesky_enabled),
|
||||
deferred_phone_gate_enabled: value.deferred_phone_gate_enabled === true,
|
||||
deferred_phone_gate_window_hours: normalizePositiveNumber(
|
||||
value.deferred_phone_gate_window_hours,
|
||||
DEFAULT_INSTANCE_POLICY_CONFIG.deferred_phone_gate_window_hours,
|
||||
),
|
||||
deferred_phone_gate_member_threshold: normalizePositiveNumber(
|
||||
value.deferred_phone_gate_member_threshold,
|
||||
DEFAULT_INSTANCE_POLICY_CONFIG.deferred_phone_gate_member_threshold,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -916,12 +936,18 @@ export class InstanceConfigRepository {
|
||||
this.refreshRequested = false;
|
||||
this.configCache = await this.fetchAllConfigsFromDatabase();
|
||||
} while (this.refreshRequested);
|
||||
this.syncDeferredPhoneGateCache(this.configCache.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
|
||||
})().finally(() => {
|
||||
this.refreshPromise = null;
|
||||
});
|
||||
await this.refreshPromise;
|
||||
}
|
||||
|
||||
private syncDeferredPhoneGateCache(raw: string | null): void {
|
||||
const policy = raw ? normalizeInstancePolicyConfig(parseJsonRecord(raw)) : {...DEFAULT_INSTANCE_POLICY_CONFIG};
|
||||
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(policy));
|
||||
}
|
||||
|
||||
private updateCachedConfigs(entries: Array<[string, string]>): void {
|
||||
if (!this.configCache) {
|
||||
return;
|
||||
@@ -1082,16 +1108,16 @@ export class InstanceConfigRepository {
|
||||
|
||||
async getInstancePolicyConfig(): Promise<InstancePolicyConfig> {
|
||||
const raw = await this.getConfig(INSTANCE_POLICY_CONFIG_KEY);
|
||||
if (!raw) {
|
||||
return {...DEFAULT_INSTANCE_POLICY_CONFIG};
|
||||
}
|
||||
return normalizeInstancePolicyConfig(parseJsonRecord(raw));
|
||||
const policy = raw ? normalizeInstancePolicyConfig(parseJsonRecord(raw)) : {...DEFAULT_INSTANCE_POLICY_CONFIG};
|
||||
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(policy));
|
||||
return policy;
|
||||
}
|
||||
|
||||
async setInstancePolicyConfig(config: Partial<InstancePolicyConfig>): Promise<InstancePolicyConfig> {
|
||||
const current = await this.getInstancePolicyConfig();
|
||||
const next = normalizeInstancePolicyConfig({...current, ...config});
|
||||
await this.setConfig(INSTANCE_POLICY_CONFIG_KEY, JSON.stringify(next));
|
||||
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(next));
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export class SingleCommunityService {
|
||||
}
|
||||
try {
|
||||
await this.guildMemberService.addUserToGuild({
|
||||
skipRiskGate: true,
|
||||
userId,
|
||||
guildId,
|
||||
skipGuildLimitCheck: true,
|
||||
@@ -50,6 +51,37 @@ export class SingleCommunityService {
|
||||
}
|
||||
}
|
||||
|
||||
async ensureStockCommunity(params: {owner: User; name: string}): Promise<GuildID> {
|
||||
const policy = await this.instanceConfigRepository.getInstancePolicyConfig();
|
||||
const designatedGuildId = await this.findDesignatedGuild(policy.single_community_guild_id);
|
||||
if (designatedGuildId != null) {
|
||||
await this.instanceConfigRepository.setInstancePolicyConfig({
|
||||
single_community_enabled: true,
|
||||
single_community_guild_id: designatedGuildId.toString(),
|
||||
});
|
||||
return designatedGuildId;
|
||||
}
|
||||
return this.createStockCommunity(params);
|
||||
}
|
||||
|
||||
private async findDesignatedGuild(rawGuildId: string | null): Promise<GuildID | null> {
|
||||
if (!rawGuildId) {
|
||||
return null;
|
||||
}
|
||||
let guildId: GuildID;
|
||||
try {
|
||||
guildId = createGuildID(BigInt(rawGuildId));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await this.guildDataService.getGuildSystem(guildId);
|
||||
return guildId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createStockCommunity(params: {owner: User; name: string}): Promise<GuildID> {
|
||||
const guild = await this.guildDataService.createGuild({user: params.owner, data: {name: params.name}});
|
||||
const guildId = createGuildID(BigInt(guild.id));
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {User} from '../../models/User';
|
||||
import type {InstancePolicyConfig} from '../InstanceConfigRepository';
|
||||
import {SingleCommunityService} from '../SingleCommunityService';
|
||||
|
||||
const EXISTING_GUILD_ID = '1234567890123456789';
|
||||
const OWNER = {id: 42n} as unknown as User;
|
||||
|
||||
interface Harness {
|
||||
service: SingleCommunityService;
|
||||
written: Array<Partial<InstancePolicyConfig>>;
|
||||
createdNames: Array<string>;
|
||||
}
|
||||
|
||||
function createHarness(params: {designatedGuildId: string | null; designatedGuildExists: boolean}): Harness {
|
||||
const written: Array<Partial<InstancePolicyConfig>> = [];
|
||||
const createdNames: Array<string> = [];
|
||||
let nextCreatedGuildId = 999n;
|
||||
const instanceConfigRepository = {
|
||||
getInstancePolicyConfig: async () => ({
|
||||
single_community_enabled: false,
|
||||
single_community_guild_id: params.designatedGuildId,
|
||||
}),
|
||||
setInstancePolicyConfig: async (patch: Partial<InstancePolicyConfig>) => {
|
||||
written.push(patch);
|
||||
},
|
||||
};
|
||||
const guildDataService = {
|
||||
getGuildSystem: async () => {
|
||||
if (!params.designatedGuildExists) {
|
||||
throw new Error('unknown guild');
|
||||
}
|
||||
return {} as never;
|
||||
},
|
||||
createGuild: async ({data}: {data: {name: string}}) => {
|
||||
createdNames.push(data.name);
|
||||
nextCreatedGuildId += 1n;
|
||||
return {id: nextCreatedGuildId.toString()} as never;
|
||||
},
|
||||
};
|
||||
const service = new SingleCommunityService(
|
||||
instanceConfigRepository as never,
|
||||
guildDataService as never,
|
||||
null as never,
|
||||
);
|
||||
return {service, written, createdNames};
|
||||
}
|
||||
|
||||
describe('SingleCommunityService.ensureStockCommunity', () => {
|
||||
it('reuses the designated community when it still exists', async () => {
|
||||
const harness = createHarness({designatedGuildId: EXISTING_GUILD_ID, designatedGuildExists: true});
|
||||
const guildId = await harness.service.ensureStockCommunity({owner: OWNER, name: 'Fluxer'});
|
||||
expect(guildId.toString()).toBe(EXISTING_GUILD_ID);
|
||||
expect(harness.createdNames).toEqual([]);
|
||||
expect(harness.written).toEqual([{single_community_enabled: true, single_community_guild_id: EXISTING_GUILD_ID}]);
|
||||
});
|
||||
|
||||
it('creates a fresh community when the designated one was deleted', async () => {
|
||||
const harness = createHarness({designatedGuildId: EXISTING_GUILD_ID, designatedGuildExists: false});
|
||||
const guildId = await harness.service.ensureStockCommunity({owner: OWNER, name: 'Fluxer'});
|
||||
expect(guildId.toString()).not.toBe(EXISTING_GUILD_ID);
|
||||
expect(harness.createdNames).toEqual(['Fluxer']);
|
||||
});
|
||||
|
||||
it('creates a fresh community when the instance never designated one', async () => {
|
||||
const harness = createHarness({designatedGuildId: null, designatedGuildExists: false});
|
||||
await harness.service.ensureStockCommunity({owner: OWNER, name: 'Fluxer'});
|
||||
expect(harness.createdNames).toEqual(['Fluxer']);
|
||||
});
|
||||
|
||||
it('creates a fresh community when the stored guild id is not a snowflake', async () => {
|
||||
const harness = createHarness({designatedGuildId: 'not-a-snowflake', designatedGuildExists: true});
|
||||
await harness.service.ensureStockCommunity({owner: OWNER, name: 'Fluxer'});
|
||||
expect(harness.createdNames).toEqual(['Fluxer']);
|
||||
});
|
||||
});
|
||||
@@ -69,7 +69,8 @@ export class LimitConfigService {
|
||||
}
|
||||
|
||||
async refreshCache(): Promise<void> {
|
||||
this.premiumMode = (await this.repository.getInstancePolicyConfig()).premium_mode;
|
||||
const policyConfig = await this.repository.getInstancePolicyConfig();
|
||||
this.premiumMode = policyConfig.premium_mode;
|
||||
setCachedInstancePremiumMode(this.premiumMode);
|
||||
const currentHash = computeDefaultsHash();
|
||||
const lockToken = await this.cacheService.acquireLock(LIMIT_CONFIG_REFRESH_LOCK_KEY, 10);
|
||||
|
||||
@@ -60,7 +60,7 @@ import {KVActivityTracker} from '../infrastructure/KVActivityTracker';
|
||||
import {KVBulkMessageDeletionQueueService} from '../infrastructure/KVBulkMessageDeletionQueueService';
|
||||
import {NatsUnfurlerService} from '../infrastructure/NatsUnfurlerService';
|
||||
import {PremiumStateReconciliationQueueService} from '../infrastructure/PremiumStateReconciliationQueueService';
|
||||
import {createStorageService} from '../infrastructure/StorageServiceFactory';
|
||||
import {createDownloadsStorageService, createStorageService} from '../infrastructure/StorageServiceFactory';
|
||||
import {UserCacheService} from '../infrastructure/UserCacheService';
|
||||
import {createUsersServiceClient} from '../infrastructure/UsersServiceClient';
|
||||
import {VirusScanService} from '../infrastructure/VirusScanService';
|
||||
@@ -193,6 +193,10 @@ export const getStorageService: () => IStorageService = (() => {
|
||||
const fallback = singleton(() => createStorageService());
|
||||
return () => _injectedStorageService ?? fallback();
|
||||
})();
|
||||
const getDownloadsStorageService: () => IStorageService = (() => {
|
||||
const override = singleton(() => createDownloadsStorageService());
|
||||
return () => override() ?? getStorageService();
|
||||
})();
|
||||
export const getErrorI18nService = singleton(() => new ErrorI18nService());
|
||||
export const getLimitConfigService = singleton(
|
||||
() => new LimitConfigService(getInstanceConfigRepository(), getCacheService(), getKVClient()),
|
||||
@@ -262,7 +266,7 @@ export function getKVAccountDeletionQueue(): KVAccountDeletionQueueService {
|
||||
return accountDeletionQueue;
|
||||
}
|
||||
|
||||
export const getDownloadService = singleton(() => new DownloadService(getStorageService()));
|
||||
export const getDownloadService = singleton(() => new DownloadService(getDownloadsStorageService()));
|
||||
export const getThemeService = singleton(() => new ThemeService(getStorageService()));
|
||||
const getNcmecReporter = singleton(() => new NcmecReporter({config: createNcmecApiConfig(), fetch}));
|
||||
const getNcmecRepository = singleton(() => new NcmecRepository());
|
||||
|
||||
@@ -10,9 +10,7 @@ export class AuthSession {
|
||||
readonly approximateLastUsedAt: Date;
|
||||
readonly clientIp: string;
|
||||
readonly clientUserAgent: string | null;
|
||||
readonly clientIsDesktop: boolean | null;
|
||||
readonly clientOs?: string | null;
|
||||
readonly clientPlatform?: string | null;
|
||||
readonly clientOs: string | null;
|
||||
readonly clientCountry: string | null;
|
||||
readonly version: number;
|
||||
|
||||
@@ -23,9 +21,7 @@ export class AuthSession {
|
||||
this.approximateLastUsedAt = row.approx_last_used_at;
|
||||
this.clientIp = row.client_ip;
|
||||
this.clientUserAgent = row.client_user_agent ?? null;
|
||||
this.clientIsDesktop = row.client_is_desktop ?? null;
|
||||
this.clientOs = row.client_os ?? null;
|
||||
this.clientPlatform = row.client_platform ?? null;
|
||||
this.clientCountry = row.client_country ?? null;
|
||||
this.version = row.version;
|
||||
}
|
||||
@@ -38,9 +34,7 @@ export class AuthSession {
|
||||
approx_last_used_at: this.approximateLastUsedAt,
|
||||
client_ip: this.clientIp,
|
||||
client_user_agent: this.clientUserAgent,
|
||||
client_is_desktop: this.clientIsDesktop,
|
||||
client_os: this.clientOs,
|
||||
client_platform: this.clientPlatform,
|
||||
client_country: this.clientCountry,
|
||||
version: this.version,
|
||||
};
|
||||
@@ -54,9 +48,7 @@ export class AuthSessionTombstone {
|
||||
readonly approximateLastUsedAt: Date;
|
||||
readonly clientIp: string;
|
||||
readonly clientUserAgent: string | null;
|
||||
readonly clientIsDesktop: boolean | null;
|
||||
readonly clientOs?: string | null;
|
||||
readonly clientPlatform?: string | null;
|
||||
readonly clientOs: string | null;
|
||||
readonly clientCountry: string | null;
|
||||
readonly deletedAt: Date;
|
||||
readonly version: number;
|
||||
@@ -68,9 +60,7 @@ export class AuthSessionTombstone {
|
||||
this.approximateLastUsedAt = row.approx_last_used_at;
|
||||
this.clientIp = row.client_ip;
|
||||
this.clientUserAgent = row.client_user_agent ?? null;
|
||||
this.clientIsDesktop = row.client_is_desktop ?? null;
|
||||
this.clientOs = row.client_os ?? null;
|
||||
this.clientPlatform = row.client_platform ?? null;
|
||||
this.clientCountry = row.client_country ?? null;
|
||||
this.deletedAt = row.deleted_at;
|
||||
this.version = row.version;
|
||||
|
||||
@@ -273,6 +273,7 @@ export class OAuth2RequestService {
|
||||
}
|
||||
}
|
||||
await this.guildService.members.addUserToGuild({
|
||||
skipRiskGate: true,
|
||||
userId: botUserId,
|
||||
guildId,
|
||||
skipGuildLimitCheck: true,
|
||||
|
||||
@@ -10588,6 +10588,70 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/harvest-downloads/{harvestId}": {
|
||||
"get": {
|
||||
"operationId": "download_data_harvest_archive",
|
||||
"summary": "Download data harvest archive",
|
||||
"tags": ["Users"],
|
||||
"responses": {
|
||||
"204": {"description": "No Content"},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {"type": "string", "enum": ["RATE_LIMITED"]},
|
||||
"message": {"type": "string"},
|
||||
"retry_after": {"type": "number", "description": "Seconds to wait before retrying"},
|
||||
"global": {"type": "boolean", "description": "Whether this is a global rate limit"}
|
||||
},
|
||||
"required": ["code", "message", "retry_after"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"x-mint": {"metadata": {"title": "Download data harvest archive"}},
|
||||
"description": "Streams a completed data harvest archive. Authorised by a signed, expiring token rather than a session, so the link works from the harvest completion email. Only active when presigned harvest downloads are disabled.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "harvestId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {"type": "string"},
|
||||
"description": "The harvestId"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/invites/{invite_code}": {
|
||||
"get": {
|
||||
"operationId": "get_invite",
|
||||
@@ -26040,11 +26104,18 @@
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "The operating system of the requesting device"
|
||||
},
|
||||
"device": {
|
||||
"enum": ["mobile", "desktop"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["mobile", "desktop"],
|
||||
"description": "Device class of the requesting device, decided by the server"
|
||||
},
|
||||
"location": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/AuthSessionLocation"}, {"type": "null"}],
|
||||
"description": "The approximate location of the requesting device"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["device"]
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
@@ -26288,11 +26359,18 @@
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "The browser reported by the client"
|
||||
},
|
||||
"device": {
|
||||
"enum": ["mobile", "desktop"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["mobile", "desktop"],
|
||||
"description": "Device class of the session, decided by the server"
|
||||
},
|
||||
"location": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/AuthSessionLocation"}, {"type": "null"}],
|
||||
"description": "The geolocation data sent by the client"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["device"]
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
@@ -26837,7 +26915,7 @@
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "The message that this message is replying to or forwarding"
|
||||
"description": "The reply target. Present and populated when the target resolved, present and null when the target is gone, absent when this message carries no default reference. Clients must tell null apart from absent by key presence."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -26871,8 +26949,7 @@
|
||||
"value": "4096",
|
||||
"description": "This message will not trigger push or desktop notifications"
|
||||
},
|
||||
{"name": "VOICE_MESSAGE", "value": "8192", "description": "This message is a voice message"},
|
||||
{"name": "COMPACT_ATTACHMENTS", "value": "131072", "description": "Display attachments in a compact format"}
|
||||
{"name": "VOICE_MESSAGE", "value": "8192", "description": "This message is a voice message"}
|
||||
],
|
||||
"description": "Message flags bitfield"
|
||||
},
|
||||
@@ -27388,9 +27465,9 @@
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 25,
|
||||
"maximum": 50,
|
||||
"format": "int32",
|
||||
"description": "Number of messages to return for this channel (1-25)"
|
||||
"description": "Number of messages to return for this channel (1-50)"
|
||||
},
|
||||
"before": {"$ref": "#/components/schemas/SnowflakeType"},
|
||||
"after": {"$ref": "#/components/schemas/SnowflakeType"},
|
||||
@@ -29747,6 +29824,7 @@
|
||||
"id": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "description": "Guild ID"},
|
||||
"name": {"type": "string", "description": "Guild name"},
|
||||
"icon": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Guild icon hash"},
|
||||
"banner": {"anyOf": [{"type": "string"}, {"type": "null"}], "description": "Guild banner hash"},
|
||||
"description": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Discovery description"
|
||||
@@ -29775,9 +29853,21 @@
|
||||
},
|
||||
"description": "Discovery guild results"
|
||||
},
|
||||
"total": {"type": "number", "description": "Total number of matching guilds"}
|
||||
"total": {"type": "number", "description": "Total number of matching guilds"},
|
||||
"category_counts": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"category_type": {"type": "number", "description": "Discovery category type"},
|
||||
"count": {"type": "number", "description": "Number of matching guilds in this category"}
|
||||
},
|
||||
"required": ["category_type", "count"]
|
||||
},
|
||||
"description": "Match counts per category for the current filters, ignoring the category filter"
|
||||
}
|
||||
},
|
||||
"required": ["guilds", "total"]
|
||||
"required": ["guilds", "total", "category_counts"]
|
||||
},
|
||||
"DonationCheckoutResponse": {
|
||||
"type": "object",
|
||||
@@ -33226,12 +33316,20 @@
|
||||
"payment_intent_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"charge_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"refund_id": {"anyOf": [{"type": "string"}, {"type": "null"}]},
|
||||
"refunded_amount_cents": {"type": "integer", "format": "int53"},
|
||||
"refunded_amount_cents": {
|
||||
"type": "integer",
|
||||
"format": "int53",
|
||||
"description": "Amount actually refunded so far, in the currency minor unit; 0 until the provider confirms success"
|
||||
},
|
||||
"invoice_amount_paid_cents": {"type": "integer", "format": "int53"},
|
||||
"currency": {"type": "string"},
|
||||
"subscription_id": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Subscription that was cancelled along with the refund, when applicable"
|
||||
},
|
||||
"status": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Provider status of the refund (e.g. pending, succeeded, failed); money only moved once succeeded"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -33242,7 +33340,8 @@
|
||||
"refunded_amount_cents",
|
||||
"invoice_amount_paid_cents",
|
||||
"currency",
|
||||
"subscription_id"
|
||||
"subscription_id",
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"ReadStateAckResponse": {
|
||||
@@ -35860,7 +35959,7 @@
|
||||
"HarvestDownloadUrlResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"download_url": {"type": "string", "description": "The presigned URL to download the harvest archive"},
|
||||
"download_url": {"type": "string", "description": "The temporary URL to download the harvest archive"},
|
||||
"expires_at": {"type": "string", "description": "ISO 8601 timestamp when the harvest download expires"}
|
||||
},
|
||||
"required": ["download_url", "expires_at"]
|
||||
|
||||
@@ -216,6 +216,10 @@ export const UserRateLimitConfigs = {
|
||||
bucket: 'user:harvest:download',
|
||||
config: {limit: 10, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
USER_HARVEST_DOWNLOAD_FILE: {
|
||||
bucket: 'user:harvest:download_file',
|
||||
config: {limit: 60, windowMs: ms('1 minute')},
|
||||
} as RouteRateLimitConfig,
|
||||
USER_ENTRANCE_SOUND_LIST: {
|
||||
bucket: 'user:entrance_sound:list',
|
||||
config: {limit: 30, windowMs: ms('1 minute')},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user