mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-08 03:32:27 +09:00
Compare commits
105
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b30a4f5d14 | ||
|
|
f254ed679b | ||
|
|
258fe6f742 | ||
|
|
cfa20b7093 | ||
|
|
569146c5bc | ||
|
|
1b1d48b05e | ||
|
|
cadca2c18e | ||
|
|
2e3f78b3c6 | ||
|
|
b57545b1a4 | ||
|
|
e490be2f35 | ||
|
|
ab07fd23cf | ||
|
|
c1fd2234b8 | ||
|
|
3af43b3366 | ||
|
|
0e470f532e | ||
|
|
c541b86c00 | ||
|
|
53b3fa2f4a | ||
|
|
67e01be34a | ||
|
|
81fd8c9aad | ||
|
|
bcef7b3123 | ||
|
|
a98d8ef679 | ||
|
|
a5af857564 | ||
|
|
2830221949 | ||
|
|
84aa8880f5 | ||
|
|
395ec1d60f | ||
|
|
e6ee3b8059 | ||
|
|
61a13e1c1a | ||
|
|
fc0e2628a4 | ||
|
|
87fdfd9c34 | ||
|
|
88a5ff9c45 | ||
|
|
320949a79d | ||
|
|
3a862f1484 | ||
|
|
5da256df12 | ||
|
|
baf2cbf3fd | ||
|
|
74782dc4f2 | ||
|
|
7d8778495f | ||
|
|
53399ffb44 | ||
|
|
35d73eae76 | ||
|
|
54128e049a | ||
|
|
7d8d0ff804 | ||
|
|
2e8f381efc | ||
|
|
b29da84282 | ||
|
|
2988c846c8 | ||
|
|
8e91c1412b | ||
|
|
5b2099c777 | ||
|
|
f97841a58f | ||
|
|
0421c86039 | ||
|
|
d17f320bd7 | ||
|
|
f708586c59 | ||
|
|
905af5dd5a | ||
|
|
5fea319f4e | ||
|
|
01fd11fea9 | ||
|
|
d028679b90 | ||
|
|
4a93b677af | ||
|
|
d79cd99050 | ||
|
|
167862a8a6 | ||
|
|
48b569b9d4 | ||
|
|
75be6aa492 | ||
|
|
9fe65d5036 | ||
|
|
bfa9bf221d | ||
|
|
184eeb0846 | ||
|
|
0eff26a1c9 | ||
|
|
d729f641ff | ||
|
|
044a2c101d | ||
|
|
38e2c8db3e | ||
|
|
1b22d14f3d | ||
|
|
98cceae59d | ||
|
|
3e32414849 | ||
|
|
7707b9531c | ||
|
|
86745e01e9 | ||
|
|
098830a95a | ||
|
|
cf83f66911 | ||
|
|
c577b97f35 | ||
|
|
8cc485cf81 | ||
|
|
6c36d934f7 | ||
|
|
63e3be5750 | ||
|
|
cdecda7f78 | ||
|
|
4ad2858773 | ||
|
|
fda41bb57a | ||
|
|
ce08f82a92 | ||
|
|
ef067f36c6 | ||
|
|
fc2b6b5299 | ||
|
|
55846b24ea | ||
|
|
20a15ac11d | ||
|
|
667ac7da8e | ||
|
|
1b81c14c48 | ||
|
|
ceec183d38 | ||
|
|
69ddc07ebb | ||
|
|
2f008b8653 | ||
|
|
3d38d3f694 | ||
|
|
ad86a04e67 | ||
|
|
600c15e17d | ||
|
|
08c9fe9886 | ||
|
|
43924e3ac5 | ||
|
|
824b5c86c9 | ||
|
|
a2a68847fd | ||
|
|
2019909a5e | ||
|
|
d46c8d49c6 | ||
|
|
45530ebbf5 | ||
|
|
9cdad046b1 | ||
|
|
b6c6928073 | ||
|
|
dd1ee999a4 | ||
|
|
c506d6d5e3 | ||
|
|
8a65832a65 | ||
|
|
fd6ae4abd7 | ||
|
|
24b84c419c |
@@ -36,8 +36,7 @@ body:
|
||||
label: Build information
|
||||
description: >-
|
||||
Open User Settings, scroll to the bottom of the left sidebar, and select
|
||||
the build information. Fluxer copies it to the clipboard. On mobile,
|
||||
select the build information at the bottom of the settings list.
|
||||
the build information. Fluxer copies it to the clipboard.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
# yaml-language-server: $schema=https://www.schemastore.org/github-issue-config.json
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Mobile client bugs
|
||||
url: https://github.com/fluxerapp/flutter_client#bug-reporting
|
||||
about: Read the reporting instructions for the Fluxer mobile client.
|
||||
- name: Account and billing support
|
||||
url: https://fluxer.app/help
|
||||
about: Find account help and support contact details.
|
||||
- name: Feature proposals
|
||||
url: https://github.com/orgs/fluxerapp/discussions
|
||||
about: Propose a feature in a discussion.
|
||||
- name: Security vulnerabilities
|
||||
url: https://github.com/fluxerapp/fluxer/security/advisories/new
|
||||
about: Submit a private vulnerability report.
|
||||
- name: Translations
|
||||
url: https://weblate.fluxer.tools
|
||||
about: Improve an existing locale or start a new one.
|
||||
|
||||
@@ -525,6 +525,7 @@ jobs:
|
||||
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 }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
S3_ENDPOINT: ${{ vars.DOWNLOADS_S3_ENDPOINT }}
|
||||
S3_BUCKET: ${{ vars.DOWNLOADS_S3_BUCKET }}
|
||||
PUBLIC_DL_BASE: https://api.fluxer.app/dl
|
||||
@@ -602,7 +603,9 @@ jobs:
|
||||
env:
|
||||
CHANNEL: ${{ needs.meta.outputs.build_channel }}
|
||||
VERSION: ${{ needs.meta.outputs.version }}
|
||||
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
|
||||
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
DESKTOP_METADATA_PREFIX: _handoff/desktop-metadata/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
|
||||
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 }}
|
||||
@@ -660,3 +663,8 @@ jobs:
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_release_marker
|
||||
|
||||
- name: Publish payload metadata to S3
|
||||
run: >-
|
||||
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
|
||||
--step publish_payload_metadata
|
||||
|
||||
@@ -314,8 +314,8 @@ jobs:
|
||||
path: |
|
||||
~/.cache/rebar3
|
||||
fluxer_gateway/_build
|
||||
!fluxer_gateway/_build/default/lib/fluxer_gateway
|
||||
!fluxer_gateway/_build/test/lib/fluxer_gateway
|
||||
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
|
||||
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
|
||||
key: >-
|
||||
rebar3-${{ runner.os }}-otp28-rebar3.24.0-${{ hashFiles('fluxer_gateway/rebar.lock',
|
||||
'fluxer_gateway/rebar.config') }}
|
||||
@@ -345,8 +345,8 @@ jobs:
|
||||
path: |
|
||||
~/.cache/rebar3
|
||||
fluxer_gateway/_build
|
||||
!fluxer_gateway/_build/default/lib/fluxer_gateway
|
||||
!fluxer_gateway/_build/test/lib/fluxer_gateway
|
||||
!fluxer_gateway/_build/default/lib/fluxer_gateway/**
|
||||
!fluxer_gateway/_build/test/lib/fluxer_gateway/**
|
||||
key: >-
|
||||
rebar3-${{ runner.os }}-otp28-rebar3.24.0-${{ hashFiles('fluxer_gateway/rebar.lock',
|
||||
'fluxer_gateway/rebar.config') }}
|
||||
@@ -426,6 +426,31 @@ jobs:
|
||||
'packages/markdown_parser/rust/Cargo.toml', 'packages/markdown_parser/rust/.cargo/config.toml',
|
||||
'packages/markdown_parser/rust/src/**') }}
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: '24'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check formatting and lint
|
||||
run: pnpm exec biome ci .
|
||||
|
||||
- name: Lint JSX for browser-translation safety
|
||||
run: pnpm exec eslint . --max-warnings 0
|
||||
|
||||
i18n:
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 25
|
||||
|
||||
+23
-1
@@ -84,7 +84,18 @@
|
||||
},
|
||||
"useConst": "error",
|
||||
"noNonNullAssertion": "off",
|
||||
"noParameterAssign": "off"
|
||||
"noParameterAssign": "off",
|
||||
"noRestrictedImports": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"paths": {
|
||||
"@lingui/react": {
|
||||
"importNames": ["I18nProvider"],
|
||||
"message": "Use AppI18nProvider from @app/features/i18n/components/AppI18nProvider so <Trans> output stays safe under page translation."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"a11y": {
|
||||
"recommended": true,
|
||||
@@ -116,10 +127,21 @@
|
||||
},
|
||||
"assist": {"actions": {"source": {"organizeImports": "on"}}},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["fluxer_app/src/**/*.tsx"],
|
||||
"plugins": ["./tools/lint/no-adjacent-jsx-text.grit"]
|
||||
},
|
||||
{
|
||||
"includes": ["fluxer_docs/scripts/VerifyDocsCoverage.ts"],
|
||||
"linter": {"rules": {"suspicious": {"noTemplateCurlyInString": "off"}}}
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"fluxer_app/src/features/i18n/components/AppI18nProvider.tsx",
|
||||
"fluxer_app/src/features/i18n/components/AppI18nProvider.test.tsx"
|
||||
],
|
||||
"linter": {"rules": {"style": {"noRestrictedImports": "off"}}}
|
||||
},
|
||||
{
|
||||
"includes": ["**/*.astro"],
|
||||
"linter": {"rules": {"correctness": {"noUnusedImports": "off", "noUnusedVariables": "off"}}},
|
||||
|
||||
@@ -3,12 +3,20 @@
|
||||
# A name absent from this file is one Compose does not forward, and it reaches a
|
||||
# service only through a Compose override file that adds it to that service's
|
||||
# environment. packages/config/src/__tests__/DeployEnvCoverage.test.ts fails when
|
||||
# a Compose edit forgets the matching line here.
|
||||
# a Compose edit forgets the matching line here. Compose expands this file from
|
||||
# top to bottom, so a line written with ${...} has to sit below every name it
|
||||
# reads.
|
||||
|
||||
FLUXER_DOMAIN=chat.example.com
|
||||
FLUXER_PUBLIC_SCHEME=https
|
||||
FLUXER_PUBLIC_PORT=443
|
||||
|
||||
# The three lines above are the address browsers use, and every endpoint the
|
||||
# services advertise carries the port from FLUXER_PUBLIC_PORT. They do not move
|
||||
# what the host publishes. FLUXER_HTTP_PORT and FLUXER_HTTPS_PORT further down
|
||||
# do that, and a non-default port needs the matching one set as well. Both
|
||||
# complete recipes are written out beside them.
|
||||
|
||||
# How browsers reach this instance.
|
||||
#
|
||||
# Default: Fluxer binds 80 and 443 and gets its own Let's Encrypt certificate.
|
||||
@@ -33,50 +41,71 @@ FLUXER_PUBLIC_PORT=443
|
||||
# address if it reaches Fluxer from a public IP.
|
||||
#FLUXER_EDGE_TRUSTED_PROXIES=private_ranges
|
||||
|
||||
# The public origin browsers use, without a trailing slash. Derived from the three
|
||||
# values above and correct for the usual https-on-443 setup, so leave it alone
|
||||
# unless you serve Fluxer on a non-default port, where the port must appear here.
|
||||
# The origin browsers see, without a trailing slash. Leave it unset and each
|
||||
# service builds one from the three values at the top of this file. Set it and it
|
||||
# wins: every service reads the host, the scheme and the port out of it and
|
||||
# ignores those three names. Use it when browsers reach the instance on a host
|
||||
# FLUXER_DOMAIN does not name. It has to be a bare origin, a scheme and a host
|
||||
# and an optional port and nothing after them, or the services refuse to start.
|
||||
# It does not move the edge listener or the published ports either, so set the
|
||||
# publish below to the port written here.
|
||||
#FLUXER_PUBLIC_ORIGIN=https://chat.example.com
|
||||
|
||||
# Overrides the address Fluxer's edge listens on. Honoured in the default mode
|
||||
# only: docker-compose.proxy.yml sets the literal :8080 and Compose lets the last
|
||||
# file win, so a value here is discarded under the proxy overlay with no warning.
|
||||
# Set it only for an unusual default-mode layout, such as serving several
|
||||
# hostnames or binding a non-default TLS port.
|
||||
#FLUXER_EDGE_SITE_ADDRESS=chat.example.com
|
||||
# Overrides the address the edge listens on inside its container. Compose builds
|
||||
# it from FLUXER_PUBLIC_SCHEME and FLUXER_DOMAIN with no port, and the edge keeps
|
||||
# its container ports at 80 and 443 whatever the public port is. Caddy matches a
|
||||
# site by host and ignores the port in the Host header, so a request arriving on
|
||||
# a non-default published port still lands on this site. Put a port in this value
|
||||
# only if you also publish that same container port below, or nothing will be
|
||||
# listening where the publish points. Honoured in the default mode only:
|
||||
# docker-compose.proxy.yml sets the literal :8080 and tunnel.compose.yml the
|
||||
# literal :80, and Compose lets the last file win, so a value here is discarded
|
||||
# under either overlay with no warning. Set it for an unusual default-mode
|
||||
# layout, such as serving several hostnames. Write the scheme into it: a bare
|
||||
# hostname means automatic HTTPS on 443 whatever FLUXER_PUBLIC_SCHEME says.
|
||||
#FLUXER_EDGE_SITE_ADDRESS=https://chat.example.com
|
||||
|
||||
# The old name for the value above. It is read only when
|
||||
# FLUXER_EDGE_SITE_ADDRESS is unset, so an existing .env keeps the listener
|
||||
# it already had. Rename it to FLUXER_EDGE_SITE_ADDRESS at your convenience.
|
||||
#FLUXER_CADDY_SITE_ADDRESS=
|
||||
|
||||
# FLUXER_PUBLIC_ORIGIN is the origin browsers see. It must carry the port
|
||||
# whenever FLUXER_PUBLIC_PORT is not the default for its scheme, because an
|
||||
# origin written with a default port never matches a browser Origin header.
|
||||
# Serving on any other port means setting all three, plus the published port
|
||||
# below, and pointing FLUXER_EDGE_SITE_ADDRESS at the same scheme and host.
|
||||
# Compose expands this file from top to bottom, so FLUXER_PUBLIC_ORIGIN has to
|
||||
# stay below the two values it reads. Above them it silently expands to a bare
|
||||
# host with a trailing colon.
|
||||
#FLUXER_PUBLIC_SCHEME=http
|
||||
#FLUXER_PUBLIC_PORT=19080
|
||||
#FLUXER_PUBLIC_ORIGIN=${FLUXER_PUBLIC_SCHEME}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT}
|
||||
#FLUXER_HTTP_PORT=19080
|
||||
|
||||
# Ports Caddy publishes on the host. Caddy still listens on 80 and 443 inside
|
||||
# the container, so change only these when something else already owns the
|
||||
# standard ports or another proxy sits in front. Both take an optional bind
|
||||
# address in front of the port, and 127.0.0.1 keeps the publish off every
|
||||
# public interface. FLUXER_HTTPS_PORT moves the TCP and the UDP publish
|
||||
# together, because HTTP/3 needs both on the same port.
|
||||
# Host side of the edge's publishes, and the only two names that decide which
|
||||
# host ports Fluxer binds. The container side is fixed. Container 80 carries the
|
||||
# HTTP to HTTPS redirect and the Let's Encrypt HTTP challenge under an https
|
||||
# scheme, and the site itself under an http one. Container 443 carries the TLS
|
||||
# site. FLUXER_HTTPS_PORT moves the TCP and the UDP publish together, because
|
||||
# HTTP/3 needs both on the same port. Both take an optional bind address in front
|
||||
# of the port, and 127.0.0.1 keeps the publish off every public interface. Give
|
||||
# them different host ports: the same host port on both is two publishes of one
|
||||
# port and the edge refuses to start.
|
||||
#FLUXER_HTTP_PORT=80
|
||||
#FLUXER_HTTPS_PORT=443
|
||||
#FLUXER_HTTP_PORT=127.0.0.1:80
|
||||
#FLUXER_HTTPS_PORT=127.0.0.1:443
|
||||
|
||||
# HTTPS on 8443, complete. Host 80 stays published and still answers the ACME
|
||||
# challenge. Let's Encrypt only ever connects to the public 80 or 443, so the
|
||||
# certificate is issued if a router in front forwards public 80 to this host and
|
||||
# is not issued otherwise. Serve your own certificate from the Caddyfile when it
|
||||
# cannot.
|
||||
#FLUXER_PUBLIC_PORT=8443
|
||||
#FLUXER_HTTPS_PORT=8443
|
||||
|
||||
# Plain HTTP on 19080, complete. The port 80 publish moves to 19080, so nothing
|
||||
# binds host 80. Under an http scheme nothing listens on container 443, so the
|
||||
# last line parks that publish on loopback for a host that wants 443 for
|
||||
# something else. Drop it and 443 is published and idle, which is what earlier
|
||||
# releases did.
|
||||
#FLUXER_PUBLIC_SCHEME=http
|
||||
#FLUXER_PUBLIC_PORT=19080
|
||||
#FLUXER_HTTP_PORT=19080
|
||||
#FLUXER_HTTPS_PORT=127.0.0.1:443
|
||||
|
||||
# A tunnel or another proxy in front of the stack needs no HTTPS publish at all.
|
||||
# tunnel.compose.yml ships beside this file and replaces Caddy's published ports
|
||||
# with a single loopback HTTP publish, so nothing binds 443. FLUXER_HTTP_PORT
|
||||
# with a single loopback HTTP publish, so nothing binds 443, and points the edge
|
||||
# at plain HTTP on that publish so it stops redirecting to https. FLUXER_HTTP_PORT
|
||||
# still moves that one publish. Set the line below and plain docker compose
|
||||
# commands pick the file up, or add it to your own -f flags if you pass any. The
|
||||
# file uses the !override tag, which needs Compose 2.24.4 or newer.
|
||||
@@ -88,6 +117,60 @@ FLUXER_IMAGE_TAG=v1
|
||||
|
||||
POSTGRES_PASSWORD=CHANGE_ME
|
||||
MEILI_MASTER_KEY=CHANGE_ME
|
||||
# The stack ships its own Postgres and its own object store, and points at both
|
||||
# by service name. Set these to run either one outside the stack. Leave them
|
||||
# unset and the bundled services are used. Taking a service out of the stack
|
||||
# means an upgrade skips the backup step that reaches into it, and backing that
|
||||
# store up belongs to whoever runs it.
|
||||
#FLUXER_POSTGRES_HOST=db.example.com
|
||||
#FLUXER_POSTGRES_PORT=5432
|
||||
#FLUXER_POSTGRES_DATABASE=fluxer
|
||||
#FLUXER_POSTGRES_USERNAME=fluxer
|
||||
#FLUXER_POSTGRES_SSL=true
|
||||
#FLUXER_S3_ENDPOINT=https://s3.eu-central-1.amazonaws.com
|
||||
#FLUXER_S3_PUBLIC_ENDPOINT=https://cdn.example.com
|
||||
#FLUXER_S3_REGION=eu-central-1
|
||||
#FLUXER_S3_FORCE_PATH_STYLE=false
|
||||
# Bucket names. The bundled object store creates whichever names these hold, so
|
||||
# the two stay in step. An object store outside the stack needs the buckets to
|
||||
# exist already.
|
||||
#FLUXER_S3_BUCKET_CDN=fluxer
|
||||
#FLUXER_S3_BUCKET_UPLOADS=fluxer-uploads
|
||||
#FLUXER_S3_BUCKET_DOWNLOADS=fluxer-downloads
|
||||
#FLUXER_S3_BUCKET_REPORTS=fluxer-reports
|
||||
#FLUXER_S3_BUCKET_HARVESTS=fluxer-harvests
|
||||
|
||||
# The rest of the bundled services, pointed somewhere else the same way. Leave a
|
||||
# line unset and the service in the stack is used. Taking a service out of the
|
||||
# stack goes in an override file listed in COMPOSE_FILE, because an upgrade
|
||||
# replaces docker-compose.yml.
|
||||
#FLUXER_KV_URL=redis://cache.example.com:6379/0
|
||||
#FLUXER_NATS_URL=nats://mq.example.com:4222
|
||||
#FLUXER_NATS_JETSTREAM_URL=nats://mq.example.com:4222
|
||||
#FLUXER_SVC_NATS_URL=nats://mq.example.com:4222
|
||||
#FLUXER_SEARCH_URL=https://search.example.com
|
||||
#FLUXER_LIVEKIT_INTERNAL_URL=http://livekit.example.com:7880
|
||||
|
||||
# Voice off. The livekit service still runs until an override file takes it out.
|
||||
#FLUXER_LIVEKIT_ENABLED=false
|
||||
|
||||
# Optional systems, each off unless the instance is configured for it.
|
||||
#FLUXER_SMS_ENABLED=false
|
||||
#FLUXER_STRIPE_ENABLED=false
|
||||
#FLUXER_NCMEC_ENABLED=false
|
||||
#FLUXER_CLAMAV_ENABLED=false
|
||||
|
||||
# The client address. Set the header name a proxy in front actually writes, and
|
||||
# turn the trust off when nothing sits in front, because a trusted header an
|
||||
# attacker can set is a spoofed client address.
|
||||
#FLUXER_CLIENT_IP_HEADER_NAME=cf-connecting-ip
|
||||
#FLUXER_TRUST_CLIENT_IP_HEADER=true
|
||||
|
||||
# How much the services write. trace, debug, info, warn, error or fatal. Every
|
||||
# service names the object storage endpoint and its addressing at info on start,
|
||||
# so a bucket that answers 404 is visible without raising this.
|
||||
#LOG_LEVEL=debug
|
||||
|
||||
FLUXER_S3_ACCESS_KEY=fluxer
|
||||
FLUXER_S3_SECRET_KEY=CHANGE_ME
|
||||
|
||||
@@ -100,6 +183,12 @@ FLUXER_MEDIA_PROXY_UPLOAD_RELAY_SECRET_BASE64=CHANGE_ME
|
||||
FLUXER_ADMIN_SECRET_KEY_BASE=CHANGE_ME
|
||||
FLUXER_ADMIN_OAUTH_CLIENT_SECRET=CHANGE_ME
|
||||
|
||||
# The token every service sends to NATS. The bundled NATS runs without
|
||||
# authentication, so this stays empty unless a Compose override points the stack
|
||||
# at an external NATS that requires a token. Compose forwards the name to every
|
||||
# container that connects.
|
||||
#FLUXER_NATS_AUTH_TOKEN=
|
||||
|
||||
FLUXER_VAPID_PUBLIC_KEY=CHANGE_ME
|
||||
FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
|
||||
@@ -147,9 +236,11 @@ FLUXER_VAPID_PRIVATE_KEY=CHANGE_ME
|
||||
LIVEKIT_API_KEY=fluxer
|
||||
LIVEKIT_API_SECRET=CHANGE_ME
|
||||
|
||||
# The URL browsers use for voice signalling. Derived from FLUXER_PUBLIC_SCHEME,
|
||||
# FLUXER_DOMAIN and FLUXER_PUBLIC_PORT as wss://host[:port]/livekit when empty.
|
||||
# Set it only when LiveKit is served from another host.
|
||||
# The URL browsers use for voice signalling. Compose builds it from
|
||||
# FLUXER_PUBLIC_ORIGIN, or from FLUXER_PUBLIC_SCHEME, FLUXER_DOMAIN and
|
||||
# FLUXER_PUBLIC_PORT, as that origin followed by /livekit. The client rewrites a
|
||||
# leading http to ws itself. Set it only when LiveKit is served from another
|
||||
# host.
|
||||
#FLUXER_LIVEKIT_URL=
|
||||
|
||||
# Media ports. LiveKit advertises these in ICE candidates, so the host must
|
||||
@@ -188,7 +279,7 @@ FLUXER_CAPTCHA_TURNSTILE_SITE_KEY=
|
||||
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY=
|
||||
FLUXER_DISCOVERY_ENABLED=true
|
||||
|
||||
# Container memory. The 25 limits sum to 16.75 GiB, which is a sum of ceilings and
|
||||
# Container memory. The 25 limits sum to 18.25 GiB, which is a sum of ceilings and
|
||||
# not an allocation, so the defaults fit a host with 8 GB and are sized for 16 GB.
|
||||
# The four reservations are cgroup memory.low, which biases the kernel away from
|
||||
# reclaiming from the services whose death takes the whole instance down. They do
|
||||
@@ -199,7 +290,7 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_VALKEY_MEMORY_LIMIT=256mb
|
||||
#FLUXER_NATS_MEMORY_LIMIT=256mb
|
||||
#FLUXER_MEILISEARCH_MEMORY_LIMIT=768mb
|
||||
#FLUXER_SEAWEEDFS_MEMORY_LIMIT=512mb
|
||||
#FLUXER_SEAWEEDFS_MEMORY_LIMIT=2gb
|
||||
#FLUXER_SEAWEEDFS_INIT_MEMORY_LIMIT=128mb
|
||||
#FLUXER_LIVEKIT_MEMORY_LIMIT=512mb
|
||||
#FLUXER_API_MEMORY_LIMIT=2560mb
|
||||
@@ -227,6 +318,14 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
# which is the container ceiling the indexer shares with the search process.
|
||||
#FLUXER_MEILISEARCH_MAX_INDEXING_MEMORY=384mb
|
||||
|
||||
# SeaweedFS heap ceiling. Go collects against this value instead of against the
|
||||
# container limit, which it cannot see, so without it an upload burst grows the
|
||||
# heap past FLUXER_SEAWEEDFS_MEMORY_LIMIT and the kernel OOM-kills the container
|
||||
# mid-upload (exit 137). Keep it near three quarters of that limit, and raise both
|
||||
# together: the peak is the parts of one upload in flight at once, which is 25 MB
|
||||
# times 20 for a 500 MB attachment.
|
||||
#FLUXER_SEAWEEDFS_GOMEMLIMIT=1536MiB
|
||||
|
||||
# Node sizes its own heap from the container memory limit by default, at roughly
|
||||
# 55 percent of it, which always leaves room for the buffers and stacks that live
|
||||
# outside the heap. Leave these unset unless you have a reason to pin the value.
|
||||
@@ -266,10 +365,16 @@ FLUXER_DISCOVERY_ENABLED=true
|
||||
#FLUXER_ERLANG_SCHEDULERS_MAX=16
|
||||
|
||||
# In-flight request ceiling for the four services Compose forwards it to: the
|
||||
# users and messages routers and their shards. The Rust built-in defaults are 192
|
||||
# for messages, 320 for snowflakes and 64 elsewhere, and they govern every service
|
||||
# Compose does not forward this to.
|
||||
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=20
|
||||
# users and messages routers and their shards. Leave it unset and each service
|
||||
# uses its own built-in default, which is what the numbers below describe. Set it
|
||||
# and the one value replaces the built-in default on all four, so size it for the
|
||||
# busiest of them rather than for the smallest. The built-in defaults are 192 for
|
||||
# messages, 320 for snowflakes and 64 elsewhere, and they govern every service
|
||||
# Compose does not forward this to. A router holds a slot for the whole round
|
||||
# trip to its shard, so this is a ceiling on requests in flight at once and not a
|
||||
# rate: too low a value does not slow requests down, it rejects them, and the api
|
||||
# turns that rejection into a 503.
|
||||
#FLUXER_SVC_MAX_CONCURRENT_REQUESTS=192
|
||||
|
||||
# The api and the Rust services name their fixed Postgres statement shapes so the
|
||||
# server can reuse their plans. Named prepared statements require a session that
|
||||
|
||||
@@ -13,73 +13,51 @@
|
||||
}
|
||||
|
||||
handle_path /api/* {
|
||||
reverse_proxy api:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy api:8080
|
||||
}
|
||||
|
||||
handle /gateway {
|
||||
rewrite * /
|
||||
reverse_proxy gateway:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy gateway:8080
|
||||
}
|
||||
|
||||
handle_path /gateway/* {
|
||||
reverse_proxy gateway:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy gateway:8080
|
||||
}
|
||||
|
||||
handle_path /media/* {
|
||||
reverse_proxy media-proxy:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy media-proxy:8080
|
||||
}
|
||||
|
||||
handle_path /livekit/* {
|
||||
reverse_proxy livekit:7880 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy livekit:7880
|
||||
}
|
||||
|
||||
handle /admin {
|
||||
rewrite * /
|
||||
reverse_proxy admin:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy admin:8080
|
||||
}
|
||||
|
||||
handle_path /admin/* {
|
||||
reverse_proxy admin:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy admin:8080
|
||||
}
|
||||
|
||||
@staticAssets path /web/* /emoji/* /libs/* /avatars/* /badges/* /desktop/* /embeds/*
|
||||
handle @staticAssets {
|
||||
reverse_proxy static-proxy:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy static-proxy:8080
|
||||
}
|
||||
|
||||
handle /.well-known/fluxer {
|
||||
reverse_proxy api:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy api:8080
|
||||
}
|
||||
|
||||
handle {
|
||||
reverse_proxy app-proxy:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy app-proxy:8080
|
||||
}
|
||||
}
|
||||
|
||||
:8088 {
|
||||
handle /.well-known/fluxer {
|
||||
reverse_proxy api:8080 {
|
||||
header_up X-Forwarded-For {client_ip}
|
||||
}
|
||||
reverse_proxy api:8080
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,60 +2,63 @@ name: fluxer
|
||||
|
||||
x-fluxer-postgres-env: &fluxer-postgres-env
|
||||
FLUXER_DATABASE_BACKEND: postgres
|
||||
FLUXER_POSTGRES_HOST: postgres
|
||||
FLUXER_POSTGRES_PORT: "5432"
|
||||
FLUXER_POSTGRES_DATABASE: fluxer
|
||||
FLUXER_POSTGRES_USERNAME: fluxer
|
||||
FLUXER_POSTGRES_HOST: ${FLUXER_POSTGRES_HOST:-postgres}
|
||||
FLUXER_POSTGRES_PORT: "${FLUXER_POSTGRES_PORT:-5432}"
|
||||
FLUXER_POSTGRES_DATABASE: ${FLUXER_POSTGRES_DATABASE:-fluxer}
|
||||
FLUXER_POSTGRES_USERNAME: ${FLUXER_POSTGRES_USERNAME:-fluxer}
|
||||
FLUXER_POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||
FLUXER_POSTGRES_SSL: "false"
|
||||
FLUXER_POSTGRES_SSL: "${FLUXER_POSTGRES_SSL:-false}"
|
||||
FLUXER_POSTGRES_PREPARED_STATEMENTS: ${FLUXER_POSTGRES_PREPARED_STATEMENTS:-true}
|
||||
|
||||
x-fluxer-env: &fluxer-env
|
||||
<<: *fluxer-postgres-env
|
||||
FLUXER_ENV: production
|
||||
NODE_ENV: production
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
FLUXER_SELF_HOSTED: "true"
|
||||
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
|
||||
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
|
||||
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
|
||||
FLUXER_TRUST_CLIENT_IP_HEADER: "true"
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: x-forwarded-for
|
||||
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
|
||||
FLUXER_TRUST_CLIENT_IP_HEADER: "${FLUXER_TRUST_CLIENT_IP_HEADER:-true}"
|
||||
FLUXER_CLIENT_IP_HEADER_NAME: ${FLUXER_CLIENT_IP_HEADER_NAME:-x-forwarded-for}
|
||||
FLUXER_API_HEADERS_TIMEOUT_MS: ${FLUXER_API_HEADERS_TIMEOUT_MS:-30000}
|
||||
FLUXER_API_REQUEST_TIMEOUT_MS: ${FLUXER_API_REQUEST_TIMEOUT_MS:-120000}
|
||||
|
||||
FLUXER_KV_URL: redis://valkey:6379/0
|
||||
FLUXER_NATS_URL: nats://nats:4222
|
||||
FLUXER_NATS_JETSTREAM_URL: nats://nats:4222
|
||||
FLUXER_SVC_NATS_URL: nats://nats:4222
|
||||
FLUXER_KV_URL: ${FLUXER_KV_URL:-redis://valkey:6379/0}
|
||||
FLUXER_NATS_URL: ${FLUXER_NATS_URL:-nats://nats:4222}
|
||||
FLUXER_NATS_JETSTREAM_URL: ${FLUXER_NATS_JETSTREAM_URL:-${FLUXER_NATS_URL:-nats://nats:4222}}
|
||||
FLUXER_NATS_AUTH_TOKEN: ${FLUXER_NATS_AUTH_TOKEN:-}
|
||||
FLUXER_SVC_NATS_URL: ${FLUXER_SVC_NATS_URL:-${FLUXER_NATS_URL:-nats://nats:4222}}
|
||||
FLUXER_SVC_SHARD_COUNT: "1"
|
||||
|
||||
FLUXER_SEARCH_ENGINE: meilisearch
|
||||
FLUXER_SEARCH_URL: http://meilisearch:7700
|
||||
FLUXER_SEARCH_URL: ${FLUXER_SEARCH_URL:-http://meilisearch:7700}
|
||||
FLUXER_SEARCH_API_KEY: ${MEILI_MASTER_KEY:?set MEILI_MASTER_KEY in .env}
|
||||
|
||||
FLUXER_S3_ENDPOINT: http://seaweedfs:8333
|
||||
FLUXER_S3_PUBLIC_ENDPOINT: http://seaweedfs:8333
|
||||
FLUXER_S3_REGION: us-east-1
|
||||
FLUXER_S3_ENDPOINT: ${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}
|
||||
FLUXER_S3_PUBLIC_ENDPOINT: ${FLUXER_S3_PUBLIC_ENDPOINT:-${FLUXER_S3_ENDPOINT:-http://seaweedfs:8333}}
|
||||
FLUXER_S3_REGION: ${FLUXER_S3_REGION:-us-east-1}
|
||||
FLUXER_S3_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
||||
FLUXER_S3_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
FLUXER_S3_FORCE_PATH_STYLE: "true"
|
||||
FLUXER_S3_BUCKET_CDN: fluxer
|
||||
FLUXER_S3_BUCKET_UPLOADS: fluxer-uploads
|
||||
FLUXER_S3_BUCKET_DOWNLOADS: fluxer-downloads
|
||||
FLUXER_S3_BUCKET_REPORTS: fluxer-reports
|
||||
FLUXER_S3_BUCKET_HARVESTS: fluxer-harvests
|
||||
FLUXER_S3_FORCE_PATH_STYLE: "${FLUXER_S3_FORCE_PATH_STYLE:-true}"
|
||||
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
|
||||
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
|
||||
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
|
||||
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
|
||||
AWS_ACCESS_KEY_ID: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
||||
AWS_SECRET_ACCESS_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
AWS_DEFAULT_REGION: us-east-1
|
||||
AWS_DEFAULT_REGION: ${FLUXER_S3_REGION:-us-east-1}
|
||||
AWS_EC2_METADATA_DISABLED: "true"
|
||||
|
||||
FLUXER_LIVEKIT_ENABLED: "true"
|
||||
FLUXER_LIVEKIT_ENABLED: "${FLUXER_LIVEKIT_ENABLED:-true}"
|
||||
FLUXER_LIVEKIT_API_KEY: ${LIVEKIT_API_KEY:?set LIVEKIT_API_KEY in .env}
|
||||
FLUXER_LIVEKIT_API_SECRET: ${LIVEKIT_API_SECRET:?set LIVEKIT_API_SECRET in .env}
|
||||
FLUXER_LIVEKIT_INTERNAL_URL: http://livekit:7880
|
||||
FLUXER_LIVEKIT_INTERNAL_URL: ${FLUXER_LIVEKIT_INTERNAL_URL:-http://livekit:7880}
|
||||
FLUXER_LIVEKIT_WEBHOOK_URL: http://api:8080/webhooks/livekit
|
||||
FLUXER_LIVEKIT_DEFAULT_REGION: '{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}'
|
||||
FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/livekit}
|
||||
FLUXER_LIVEKIT_URL: ${FLUXER_LIVEKIT_URL:-${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}:${FLUXER_PUBLIC_PORT:-443}}/livekit}
|
||||
|
||||
FLUXER_KLIPY_API_KEY: ${FLUXER_KLIPY_API_KEY:-}
|
||||
|
||||
@@ -70,16 +73,16 @@ x-fluxer-env: &fluxer-env
|
||||
FLUXER_EMAIL_SMTP_PASSWORD: ${FLUXER_EMAIL_SMTP_PASSWORD:-}
|
||||
FLUXER_EMAIL_SMTP_SECURE: ${FLUXER_EMAIL_SMTP_SECURE:-true}
|
||||
|
||||
FLUXER_SMS_ENABLED: "false"
|
||||
FLUXER_SMS_ENABLED: "${FLUXER_SMS_ENABLED:-false}"
|
||||
FLUXER_CAPTCHA_ENABLED: ${FLUXER_CAPTCHA_ENABLED:-false}
|
||||
FLUXER_CAPTCHA_PROVIDER: ${FLUXER_CAPTCHA_PROVIDER:-none}
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SITE_KEY:-}
|
||||
FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY: ${FLUXER_CAPTCHA_HCAPTCHA_SECRET_KEY:-}
|
||||
FLUXER_CAPTCHA_TURNSTILE_SITE_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SITE_KEY:-}
|
||||
FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY: ${FLUXER_CAPTCHA_TURNSTILE_SECRET_KEY:-}
|
||||
FLUXER_STRIPE_ENABLED: "false"
|
||||
FLUXER_NCMEC_ENABLED: "false"
|
||||
FLUXER_CLAMAV_ENABLED: "false"
|
||||
FLUXER_STRIPE_ENABLED: "${FLUXER_STRIPE_ENABLED:-false}"
|
||||
FLUXER_NCMEC_ENABLED: "${FLUXER_NCMEC_ENABLED:-false}"
|
||||
FLUXER_CLAMAV_ENABLED: "${FLUXER_CLAMAV_ENABLED:-false}"
|
||||
FLUXER_DISCOVERY_ENABLED: ${FLUXER_DISCOVERY_ENABLED:-true}
|
||||
|
||||
FLUXER_SUDO_MODE_SECRET: ${FLUXER_SUDO_MODE_SECRET:?set FLUXER_SUDO_MODE_SECRET in .env}
|
||||
@@ -131,7 +134,7 @@ services:
|
||||
- "${FLUXER_HTTPS_PORT:-443}:443"
|
||||
- "${FLUXER_HTTPS_PORT:-443}:443/udp"
|
||||
environment:
|
||||
FLUXER_EDGE_SITE_ADDRESS: ${FLUXER_EDGE_SITE_ADDRESS:-${FLUXER_CADDY_SITE_ADDRESS:-${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}}}
|
||||
FLUXER_EDGE_SITE_ADDRESS: ${FLUXER_EDGE_SITE_ADDRESS:-${FLUXER_CADDY_SITE_ADDRESS:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}}}
|
||||
FLUXER_EDGE_TRUSTED_PROXIES: ${FLUXER_EDGE_TRUSTED_PROXIES:-private_ranges}
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
@@ -258,9 +261,11 @@ services:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: ${FLUXER_SEAWEEDFS_MEMORY_LIMIT:-512mb}
|
||||
memory: ${FLUXER_SEAWEEDFS_MEMORY_LIMIT:-2gb}
|
||||
restart: unless-stopped
|
||||
networks: [fluxer]
|
||||
environment:
|
||||
GOMEMLIMIT: ${FLUXER_SEAWEEDFS_GOMEMLIMIT:-1536MiB}
|
||||
command: ["server", "-s3", "-dir=/data"]
|
||||
volumes:
|
||||
- seaweedfs-data:/data
|
||||
@@ -284,11 +289,16 @@ services:
|
||||
environment:
|
||||
FLUXER_S3_ACCESS_KEY: ${FLUXER_S3_ACCESS_KEY:?set FLUXER_S3_ACCESS_KEY in .env}
|
||||
FLUXER_S3_SECRET_KEY: ${FLUXER_S3_SECRET_KEY:?set FLUXER_S3_SECRET_KEY in .env}
|
||||
FLUXER_S3_BUCKET_CDN: ${FLUXER_S3_BUCKET_CDN:-fluxer}
|
||||
FLUXER_S3_BUCKET_UPLOADS: ${FLUXER_S3_BUCKET_UPLOADS:-fluxer-uploads}
|
||||
FLUXER_S3_BUCKET_DOWNLOADS: ${FLUXER_S3_BUCKET_DOWNLOADS:-fluxer-downloads}
|
||||
FLUXER_S3_BUCKET_REPORTS: ${FLUXER_S3_BUCKET_REPORTS:-fluxer-reports}
|
||||
FLUXER_S3_BUCKET_HARVESTS: ${FLUXER_S3_BUCKET_HARVESTS:-fluxer-harvests}
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- >
|
||||
buckets="fluxer fluxer-uploads fluxer-downloads fluxer-reports fluxer-harvests";
|
||||
buckets="$$FLUXER_S3_BUCKET_CDN $$FLUXER_S3_BUCKET_UPLOADS $$FLUXER_S3_BUCKET_DOWNLOADS $$FLUXER_S3_BUCKET_REPORTS $$FLUXER_S3_BUCKET_HARVESTS";
|
||||
missing="$$buckets";
|
||||
for attempt in $$(seq 1 60); do
|
||||
if ! nc -z seaweedfs 9333 2>/dev/null; then
|
||||
@@ -491,6 +501,10 @@ services:
|
||||
environment:
|
||||
FLUXER_APP_PROXY_HOST: 0.0.0.0
|
||||
FLUXER_APP_PROXY_PORT: "8080"
|
||||
FLUXER_BASE_DOMAIN: ${FLUXER_DOMAIN:?set FLUXER_DOMAIN in .env}
|
||||
FLUXER_PUBLIC_SCHEME: ${FLUXER_PUBLIC_SCHEME:-https}
|
||||
FLUXER_PUBLIC_PORT: ${FLUXER_PUBLIC_PORT:-443}
|
||||
FLUXER_PUBLIC_ORIGIN: ${FLUXER_PUBLIC_ORIGIN:-}
|
||||
DISCOVERY_UPSTREAM_URL: http://edge:8088/.well-known/fluxer
|
||||
PUBLIC_BOOTSTRAP_API_ENDPOINT: /api
|
||||
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: ${FLUXER_PUBLIC_ORIGIN:-${FLUXER_PUBLIC_SCHEME:-https}://${FLUXER_DOMAIN}}/api
|
||||
@@ -551,7 +565,7 @@ services:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: users
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -569,7 +583,7 @@ services:
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -619,7 +633,7 @@ services:
|
||||
<<: *fluxer-env
|
||||
FLUXER_SVC_NAME: messages
|
||||
FLUXER_SVC_MODE: router
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
@@ -637,7 +651,7 @@ services:
|
||||
FLUXER_SVC_MODE: shard
|
||||
FLUXER_SVC_SHARD_ID: "0"
|
||||
FLUXER_POSTGRES_MAX_CONNECTIONS: "20"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-20}"
|
||||
FLUXER_SVC_MAX_CONCURRENT_REQUESTS: "${FLUXER_SVC_MAX_CONCURRENT_REQUESTS:-}"
|
||||
healthcheck: *fluxer-svc-healthcheck
|
||||
depends_on:
|
||||
nats: {condition: service_healthy}
|
||||
|
||||
@@ -2,3 +2,5 @@ services:
|
||||
edge:
|
||||
ports: !override
|
||||
- "${FLUXER_HTTP_PORT:-127.0.0.1:80}:80"
|
||||
environment:
|
||||
FLUXER_EDGE_SITE_ADDRESS: ":80"
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import reactGoogleTranslate from 'eslint-plugin-react-google-translate';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules/**',
|
||||
'**/dist/**',
|
||||
'**/build/**',
|
||||
'**/coverage/**',
|
||||
'**/*.generated.*',
|
||||
'fluxer_app/src/features/i18n/locales/*/messages.mjs',
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ['fluxer_app/src/**/*.tsx'],
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: 'error',
|
||||
},
|
||||
languageOptions: {
|
||||
parser: tseslint.parser,
|
||||
parserOptions: {
|
||||
project: './fluxer_app/tsconfig.json',
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: {'react-google-translate': reactGoogleTranslate},
|
||||
rules: {
|
||||
'react-google-translate/no-conditional-text-nodes-with-siblings': [
|
||||
'error',
|
||||
{ignoreParents: ['Trans', 'Plural', 'Select', 'SelectOrdinal']},
|
||||
],
|
||||
'react-google-translate/no-return-text-nodes': 'error',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -9801,7 +9801,7 @@
|
||||
},
|
||||
"GuildFeatureSchema": {
|
||||
"type": "string",
|
||||
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
|
||||
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, AUDIO_BITRATE_128_KBPS, AUDIO_BITRATE_256_KBPS, AUDIO_BITRATE_384_KBPS, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
|
||||
},
|
||||
"AddGuildMembersAdminBulkJobCreateRequest": {
|
||||
"type": "object",
|
||||
@@ -12287,14 +12287,7 @@
|
||||
"max_concurrent_guild_starts": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"},
|
||||
"gateway_dispatch_relay_shards": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"},
|
||||
"gateway_dispatch_relay_max_queue": {"type": "integer", "minimum": 0, "maximum": 1000000, "format": "int32"},
|
||||
"voice_e2ee_scope": {"enum": ["guild_feature_only", "platform_wide"], "type": "string"},
|
||||
"voice_reconciliation_v3_percentage": {"type": "number", "minimum": 0, "maximum": 100},
|
||||
"voice_reconciliation_v3_interval_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 500,
|
||||
"maximum": 60000,
|
||||
"format": "int32"
|
||||
}
|
||||
"voice_e2ee_scope": {"enum": ["guild_feature_only", "platform_wide"], "type": "string"}
|
||||
}
|
||||
},
|
||||
"InstanceConfigUpdateRequest": {
|
||||
@@ -12534,14 +12527,7 @@
|
||||
"max_concurrent_guild_starts": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"},
|
||||
"gateway_dispatch_relay_shards": {"type": "integer", "minimum": 1, "maximum": 10000, "format": "int32"},
|
||||
"gateway_dispatch_relay_max_queue": {"type": "integer", "minimum": 0, "maximum": 1000000, "format": "int32"},
|
||||
"voice_e2ee_scope": {"enum": ["guild_feature_only", "platform_wide"], "type": "string"},
|
||||
"voice_reconciliation_v3_percentage": {"type": "number", "minimum": 0, "maximum": 100},
|
||||
"voice_reconciliation_v3_interval_ms": {
|
||||
"type": "integer",
|
||||
"minimum": 500,
|
||||
"maximum": 60000,
|
||||
"format": "int32"
|
||||
}
|
||||
"voice_e2ee_scope": {"enum": ["guild_feature_only", "platform_wide"], "type": "string"}
|
||||
}
|
||||
},
|
||||
"BrandingAssetUploadRequest": {
|
||||
@@ -13905,6 +13891,14 @@
|
||||
"type": "number"
|
||||
},
|
||||
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
|
||||
"soft_connection_limit": {
|
||||
"description": "Connection count above which placement prefers another server, or null when the server has no limit",
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647,
|
||||
"format": "int32"
|
||||
},
|
||||
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
|
||||
"required_guild_features": {
|
||||
"type": "array",
|
||||
@@ -13942,6 +13936,7 @@
|
||||
"latitude",
|
||||
"longitude",
|
||||
"is_active",
|
||||
"soft_connection_limit",
|
||||
"vip_only",
|
||||
"required_guild_features",
|
||||
"allowed_guild_ids",
|
||||
@@ -14241,6 +14236,14 @@
|
||||
"type": "number"
|
||||
},
|
||||
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
|
||||
"soft_connection_limit": {
|
||||
"description": "Connection count above which placement prefers another server, or null when the server has no limit",
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647,
|
||||
"format": "int32"
|
||||
},
|
||||
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
|
||||
"required_guild_features": {
|
||||
"type": "array",
|
||||
@@ -14278,6 +14281,7 @@
|
||||
"latitude",
|
||||
"longitude",
|
||||
"is_active",
|
||||
"soft_connection_limit",
|
||||
"vip_only",
|
||||
"required_guild_features",
|
||||
"allowed_guild_ids",
|
||||
@@ -14313,6 +14317,14 @@
|
||||
"type": "number"
|
||||
},
|
||||
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
|
||||
"soft_connection_limit": {
|
||||
"description": "Connection count above which placement prefers another server, or null for no limit",
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647,
|
||||
"format": "int32"
|
||||
},
|
||||
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
|
||||
"required_guild_features": {
|
||||
"type": "array",
|
||||
@@ -14370,6 +14382,14 @@
|
||||
"type": "number"
|
||||
},
|
||||
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
|
||||
"soft_connection_limit": {
|
||||
"description": "Connection count above which placement prefers another server, or null when the server has no limit",
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647,
|
||||
"format": "int32"
|
||||
},
|
||||
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
|
||||
"required_guild_features": {
|
||||
"type": "array",
|
||||
@@ -14407,6 +14427,7 @@
|
||||
"latitude",
|
||||
"longitude",
|
||||
"is_active",
|
||||
"soft_connection_limit",
|
||||
"vip_only",
|
||||
"required_guild_features",
|
||||
"allowed_guild_ids",
|
||||
@@ -14442,6 +14463,14 @@
|
||||
"type": "number"
|
||||
},
|
||||
"is_active": {"type": "boolean", "description": "Whether the server is currently active"},
|
||||
"soft_connection_limit": {
|
||||
"description": "Connection count above which placement prefers another server, or null for no limit",
|
||||
"nullable": true,
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 2147483647,
|
||||
"format": "int32"
|
||||
},
|
||||
"vip_only": {"type": "boolean", "description": "Whether this server is restricted to VIP users"},
|
||||
"required_guild_features": {
|
||||
"type": "array",
|
||||
|
||||
@@ -28,6 +28,7 @@ pub struct VoiceServer {
|
||||
pub latitude: Option<f64>,
|
||||
pub longitude: Option<f64>,
|
||||
pub is_active: Option<bool>,
|
||||
pub soft_connection_limit: Option<i64>,
|
||||
pub vip_only: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub required_guild_features: Vec<String>,
|
||||
|
||||
@@ -55,15 +55,13 @@ impl AdminApiClient {
|
||||
params: &serde_json::Value,
|
||||
) -> ApiResult<UpdateVoiceRegionResponse> {
|
||||
let region_id = required_field(params, "id")?;
|
||||
let body =
|
||||
serde_json::from_value::<generated_types::UpdateVoiceRegionRequest>(params.clone())
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.update_admin_voice_region(®ion_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
validate_against::<generated_types::UpdateVoiceRegionRequest>(params)?;
|
||||
self.patch_with_reason(
|
||||
&format!("/admin/voice/regions/{}", urlencoding::encode(®ion_id)),
|
||||
Some(params),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_voice_region(&self, id: &str) -> ApiResult<DeleteVoiceResponse> {
|
||||
@@ -121,15 +119,17 @@ impl AdminApiClient {
|
||||
let region_id = required_field(params, "region_id")?;
|
||||
let server_id = required_field(params, "server_id")?;
|
||||
paired_coordinates(params)?;
|
||||
let body =
|
||||
serde_json::from_value::<generated_types::UpdateVoiceServerRequest>(params.clone())
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))?;
|
||||
let response = self
|
||||
.generated()
|
||||
.update_admin_voice_server(®ion_id, &server_id, &body)
|
||||
.await
|
||||
.map_err(|e| self.generated_error(e))?;
|
||||
self.generated_value(response.into_inner())
|
||||
validate_against::<generated_types::UpdateVoiceServerRequest>(params)?;
|
||||
self.patch_with_reason(
|
||||
&format!(
|
||||
"/admin/voice/regions/{}/servers/{}",
|
||||
urlencoding::encode(®ion_id),
|
||||
urlencoding::encode(&server_id)
|
||||
),
|
||||
Some(params),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete_voice_server(
|
||||
@@ -150,6 +150,12 @@ fn bool_param(value: bool) -> &'static str {
|
||||
if value { "true" } else { "false" }
|
||||
}
|
||||
|
||||
fn validate_against<T: serde::de::DeserializeOwned>(params: &serde_json::Value) -> ApiResult<()> {
|
||||
serde_json::from_value::<T>(params.clone())
|
||||
.map(drop)
|
||||
.map_err(|e| ApiError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn paired_coordinates(params: &serde_json::Value) -> ApiResult<()> {
|
||||
let has_coordinate = |field: &str| params.get(field).is_some_and(|value| !value.is_null());
|
||||
if has_coordinate("latitude") == has_coordinate("longitude") {
|
||||
|
||||
@@ -129,6 +129,11 @@ impl AdminConfig {
|
||||
pub fn secure_cookies(&self) -> bool {
|
||||
self.admin_endpoint.starts_with("https://")
|
||||
}
|
||||
|
||||
pub fn admin_origin(&self) -> Option<String> {
|
||||
let origin = url::Url::parse(&self.admin_endpoint).ok()?.origin();
|
||||
origin.is_tuple().then(|| origin.ascii_serialization())
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeEnv {
|
||||
@@ -202,6 +207,7 @@ mod tests {
|
||||
unsafe { env::remove_var(name) };
|
||||
}
|
||||
unsafe { env::remove_var("FLUXER_PUBLIC_PORT") };
|
||||
unsafe { env::remove_var("FLUXER_PUBLIC_ORIGIN") };
|
||||
unsafe { env::set_var("FLUXER_ADMIN_SECRET_KEY_BASE", "test-secret") };
|
||||
for (name, value) in vars {
|
||||
unsafe { env::set_var(name, value) };
|
||||
@@ -350,7 +356,7 @@ mod tests {
|
||||
("FLUXER_BASE_DOMAIN", "fluxer.example"),
|
||||
("FLUXER_PUBLIC_PORT", "19080"),
|
||||
("FLUXER_ADMIN_ENDPOINT", "http://fluxer.example/admin"),
|
||||
("FLUXER_APP_ENDPOINT", "http://fluxer.example:19080"),
|
||||
("FLUXER_APP_ENDPOINT", "http://fluxer.example"),
|
||||
("FLUXER_MEDIA_ENDPOINT", "http://fluxer.example/media"),
|
||||
("FLUXER_STATIC_CDN_ENDPOINT", "https://cdn.example.net"),
|
||||
(
|
||||
|
||||
@@ -27,7 +27,6 @@ pub async fn csrf_protection(
|
||||
) -> Response {
|
||||
let config = state.config();
|
||||
let secret = config.secret_key_base.clone();
|
||||
let admin_endpoint = config.admin_endpoint.clone();
|
||||
let secure_cookies = config.secure_cookies();
|
||||
|
||||
let user_id = request
|
||||
@@ -50,7 +49,7 @@ pub async fn csrf_protection(
|
||||
.iter()
|
||||
.any(|suffix| path.ends_with(suffix));
|
||||
if !is_ignored {
|
||||
if !is_same_site_request(&request, &admin_endpoint) {
|
||||
if !is_same_site_request(&request, config.admin_origin().as_deref()) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let header_token = extract_csrf_header(&request);
|
||||
@@ -167,7 +166,7 @@ async fn extract_csrf_from_form_body(
|
||||
Ok((request, token))
|
||||
}
|
||||
|
||||
fn is_same_site_request(request: &Request, admin_endpoint: &str) -> bool {
|
||||
fn is_same_site_request(request: &Request, admin_origin: Option<&str>) -> bool {
|
||||
if let Some(site) = request
|
||||
.headers()
|
||||
.get("sec-fetch-site")
|
||||
@@ -180,7 +179,7 @@ fn is_same_site_request(request: &Request, admin_endpoint: &str) -> bool {
|
||||
.get(header::ORIGIN)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
{
|
||||
Some(origin) => origin == admin_endpoint,
|
||||
Some(origin) => admin_origin.is_some_and(|expected| origin == expected),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
@@ -275,6 +274,98 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
async fn action_status(admin_endpoint: &str, origin: &str) -> StatusCode {
|
||||
let state = state_with_admin_endpoint(admin_endpoint);
|
||||
let app = Router::new()
|
||||
.route("/", get(|| async { "ok" }).post(|| async { "ok" }))
|
||||
.layer(from_fn_with_state(state, csrf_protection));
|
||||
let issued = app
|
||||
.clone()
|
||||
.oneshot(Request::builder().uri("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.expect("router responds");
|
||||
let cookie = issued
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.filter_map(|value| value.split(';').next())
|
||||
.find(|pair| pair.contains("csrf_token=") && !pair.ends_with('='))
|
||||
.expect("a csrf cookie is issued")
|
||||
.to_owned();
|
||||
let token = cookie.split_once('=').expect("a cookie value").1.to_owned();
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri("/")
|
||||
.header(header::COOKIE, cookie.as_str())
|
||||
.header(header::ORIGIN, origin)
|
||||
.header(CSRF_HEADER_NAME, token.as_str())
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.expect("router responds");
|
||||
response.status()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_matching_origin_passes_the_same_site_check() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test/admin",
|
||||
"https://admin.example.test",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_matching_origin_on_a_non_default_port_passes_the_same_site_check() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test:19080/admin",
|
||||
"https://admin.example.test:19080",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_foreign_origin_fails_the_same_site_check() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test:19080/admin",
|
||||
"https://evil.example.test:19080",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn another_port_on_the_admin_host_fails_the_same_site_check() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test:19080/admin",
|
||||
"https://admin.example.test",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unparseable_admin_endpoint_fails_closed() {
|
||||
let status = action_status("not-an-endpoint", "https://admin.example.test").await;
|
||||
assert_eq!(status, StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_explicit_default_port_matches_a_portless_origin() {
|
||||
let status = action_status(
|
||||
"https://admin.example.test:443/admin",
|
||||
"https://admin.example.test",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oauth2_callback_is_exempt() {
|
||||
let exempt = IGNORED_PATH_SUFFIXES
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::{
|
||||
api::client::AdminApiClient,
|
||||
middleware::{auth::AuthContext, csrf},
|
||||
state::AppState,
|
||||
templates,
|
||||
templates::{self, pages::voice_servers::VoiceServersPageParams},
|
||||
};
|
||||
use axum::{
|
||||
Router,
|
||||
@@ -13,12 +13,34 @@ use axum::{
|
||||
routing::get,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct VoiceServersQuery {
|
||||
region_id: Option<String>,
|
||||
}
|
||||
|
||||
async fn load_server_connection_counts(client: &AdminApiClient) -> HashMap<String, i64> {
|
||||
let response = match client.get_gateway_voice_state_counts().await {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: load voice state counts");
|
||||
return HashMap::new();
|
||||
}
|
||||
};
|
||||
let Some(servers) = response.data.get("servers").and_then(|v| v.as_array()) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
servers
|
||||
.iter()
|
||||
.filter_map(|entry| {
|
||||
let server_id = entry.get("server_id")?.as_str()?.to_owned();
|
||||
let count = entry.get("voice_state_count")?.as_i64()?;
|
||||
Some((server_id, count))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
@@ -79,17 +101,21 @@ async fn voice_servers_page(
|
||||
let markup = templates::pages::voice_servers::voice_servers_page(
|
||||
config,
|
||||
&auth.0,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&csrf_token,
|
||||
&VoiceServersPageParams {
|
||||
region_id: None,
|
||||
region_name: None,
|
||||
servers: None,
|
||||
connection_counts: &HashMap::new(),
|
||||
error: None,
|
||||
csrf_token: &csrf_token,
|
||||
},
|
||||
);
|
||||
return Html(markup.into_string()).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||
let connection_counts = load_server_connection_counts(&client).await;
|
||||
|
||||
let region_name = match client.get_voice_region(region_id, false).await {
|
||||
Ok(resp) => resp
|
||||
@@ -107,11 +133,14 @@ async fn voice_servers_page(
|
||||
let markup = templates::pages::voice_servers::voice_servers_page(
|
||||
config,
|
||||
&auth.0,
|
||||
Some(region_id),
|
||||
Some(®ion_name),
|
||||
Some(&resp.servers),
|
||||
None,
|
||||
&csrf_token,
|
||||
&VoiceServersPageParams {
|
||||
region_id: Some(region_id),
|
||||
region_name: Some(®ion_name),
|
||||
servers: Some(&resp.servers),
|
||||
connection_counts: &connection_counts,
|
||||
error: None,
|
||||
csrf_token: &csrf_token,
|
||||
},
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
@@ -120,11 +149,14 @@ async fn voice_servers_page(
|
||||
let markup = templates::pages::voice_servers::voice_servers_page(
|
||||
config,
|
||||
&auth.0,
|
||||
Some(region_id),
|
||||
Some(®ion_name),
|
||||
None,
|
||||
Some(&msg),
|
||||
&csrf_token,
|
||||
&VoiceServersPageParams {
|
||||
region_id: Some(region_id),
|
||||
region_name: Some(®ion_name),
|
||||
servers: None,
|
||||
connection_counts: &connection_counts,
|
||||
error: Some(&msg),
|
||||
csrf_token: &csrf_token,
|
||||
},
|
||||
);
|
||||
Html(markup.into_string()).into_response()
|
||||
}
|
||||
|
||||
@@ -49,19 +49,26 @@ pub(crate) fn build_region_body(form: &MultiValueForm) -> serde_json::Value {
|
||||
}
|
||||
body.insert("is_default".into(), form.bool_value("is_default").into());
|
||||
body.insert("vip_only".into(), form.bool_value("vip_only").into());
|
||||
body.insert(
|
||||
"required_guild_features".into(),
|
||||
form.list_values_any(&["required_guild_features[]", "required_guild_features"])
|
||||
.into(),
|
||||
);
|
||||
body.insert(
|
||||
"allowed_guild_ids".into(),
|
||||
form.list_values_any(&["allowed_guild_ids[]", "allowed_guild_ids"])
|
||||
.into(),
|
||||
);
|
||||
insert_submitted_list(&mut body, form, "required_guild_features");
|
||||
insert_submitted_list(&mut body, form, "allowed_guild_ids");
|
||||
serde_json::Value::Object(body)
|
||||
}
|
||||
|
||||
fn insert_submitted_list(
|
||||
body: &mut serde_json::Map<String, serde_json::Value>,
|
||||
form: &MultiValueForm,
|
||||
field: &str,
|
||||
) {
|
||||
let repeated = format!("{field}[]");
|
||||
if !form.contains_key(&repeated) && !form.contains_key(field) {
|
||||
return;
|
||||
}
|
||||
body.insert(
|
||||
field.to_owned(),
|
||||
form.list_values_any(&[repeated.as_str(), field]).into(),
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
|
||||
let mut body = serde_json::Map::new();
|
||||
if let Some(v) = form.clean("region_id") {
|
||||
@@ -92,17 +99,19 @@ pub(crate) fn build_server_body(form: &MultiValueForm) -> serde_json::Value {
|
||||
body.insert("longitude".into(), lng.into());
|
||||
}
|
||||
body.insert("is_active".into(), form.bool_value("is_active").into());
|
||||
if let Some(raw) = form.first("soft_connection_limit") {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
body.insert("soft_connection_limit".into(), serde_json::Value::Null);
|
||||
} else if let Ok(limit) = trimmed.parse::<i64>()
|
||||
&& limit > 0
|
||||
{
|
||||
body.insert("soft_connection_limit".into(), limit.into());
|
||||
}
|
||||
}
|
||||
body.insert("vip_only".into(), form.bool_value("vip_only").into());
|
||||
body.insert(
|
||||
"required_guild_features".into(),
|
||||
form.list_values_any(&["required_guild_features[]", "required_guild_features"])
|
||||
.into(),
|
||||
);
|
||||
body.insert(
|
||||
"allowed_guild_ids".into(),
|
||||
form.list_values_any(&["allowed_guild_ids[]", "allowed_guild_ids"])
|
||||
.into(),
|
||||
);
|
||||
insert_submitted_list(&mut body, form, "required_guild_features");
|
||||
insert_submitted_list(&mut body, form, "allowed_guild_ids");
|
||||
serde_json::Value::Object(body)
|
||||
}
|
||||
|
||||
@@ -255,6 +264,86 @@ mod tests {
|
||||
assert_eq!(body["allowed_guild_ids"], serde_json::json!(["1", "2"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_clears_restriction_lists_the_form_submitted_empty() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"region_id=us-east&server_id=s1&required_guild_features=&allowed_guild_ids=",
|
||||
);
|
||||
let body = build_server_body(&form);
|
||||
assert_eq!(body["required_guild_features"], serde_json::json!([]));
|
||||
assert_eq!(body["allowed_guild_ids"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_leaves_restriction_lists_alone_when_the_form_omits_them() {
|
||||
let form = MultiValueForm::parse(
|
||||
b"region_id=us-east&server_id=s1&endpoint=wss%3A%2F%2Fvoice.example&is_active=false&vip_only=true",
|
||||
);
|
||||
let body = build_server_body(&form);
|
||||
let object = body.as_object().unwrap();
|
||||
assert!(!object.contains_key("required_guild_features"));
|
||||
assert!(!object.contains_key("allowed_guild_ids"));
|
||||
assert_eq!(body["is_active"], serde_json::json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_region_body_clears_restriction_lists_the_form_submitted_empty() {
|
||||
let form = MultiValueForm::parse(b"id=us-east&required_guild_features=&allowed_guild_ids=");
|
||||
let body = build_region_body(&form);
|
||||
assert_eq!(body["required_guild_features"], serde_json::json!([]));
|
||||
assert_eq!(body["allowed_guild_ids"], serde_json::json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_region_body_leaves_restriction_lists_alone_when_the_form_omits_them() {
|
||||
let form = MultiValueForm::parse(b"id=us-east&name=US%20East");
|
||||
let body = build_region_body(&form);
|
||||
let object = body.as_object().unwrap();
|
||||
assert!(!object.contains_key("required_guild_features"));
|
||||
assert!(!object.contains_key("allowed_guild_ids"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_sets_soft_connection_limit_from_a_positive_value() {
|
||||
let form =
|
||||
MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=250");
|
||||
let body = build_server_body(&form);
|
||||
assert_eq!(body["soft_connection_limit"], serde_json::json!(250));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_clears_soft_connection_limit_when_the_field_is_empty() {
|
||||
let form = MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=");
|
||||
let body = build_server_body(&form);
|
||||
assert_eq!(body["soft_connection_limit"], serde_json::Value::Null);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_omits_soft_connection_limit_when_the_field_is_absent_or_invalid() {
|
||||
let absent = MultiValueForm::parse(b"region_id=us-east&server_id=s1&is_active=true");
|
||||
assert!(
|
||||
!build_server_body(&absent)
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.contains_key("soft_connection_limit")
|
||||
);
|
||||
let invalid =
|
||||
MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=abc");
|
||||
assert!(
|
||||
!build_server_body(&invalid)
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.contains_key("soft_connection_limit")
|
||||
);
|
||||
let zero = MultiValueForm::parse(b"region_id=us-east&server_id=s1&soft_connection_limit=0");
|
||||
assert!(
|
||||
!build_server_body(&zero)
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.contains_key("soft_connection_limit")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_server_body_preserves_single_and_repeated_values() {
|
||||
let form = MultiValueForm::parse(
|
||||
|
||||
@@ -147,6 +147,9 @@ const SUSPICIOUS_ACTIVITY_FLAGS: &[&str] = &[
|
||||
const GUILD_FEATURES: &[&str] = &[
|
||||
"ANIMATED_ICON",
|
||||
"ANIMATED_BANNER",
|
||||
"AUDIO_BITRATE_128_KBPS",
|
||||
"AUDIO_BITRATE_256_KBPS",
|
||||
"AUDIO_BITRATE_384_KBPS",
|
||||
"BANNER",
|
||||
"CLONE_EMOJI_DISABLED",
|
||||
"CLONE_STICKER_DISABLED",
|
||||
|
||||
@@ -14,6 +14,9 @@ use maud::{Markup, html};
|
||||
const GUILD_FEATURES: &[&str] = &[
|
||||
"ANIMATED_ICON",
|
||||
"ANIMATED_BANNER",
|
||||
"AUDIO_BITRATE_128_KBPS",
|
||||
"AUDIO_BITRATE_256_KBPS",
|
||||
"AUDIO_BITRATE_384_KBPS",
|
||||
"BANNER",
|
||||
"CLONE_EMOJI_DISABLED",
|
||||
"CLONE_STICKER_DISABLED",
|
||||
|
||||
@@ -560,6 +560,8 @@ fn traits_form(
|
||||
}
|
||||
}
|
||||
|
||||
const DERIVED_TRAITS: [&str; 1] = ["premium"];
|
||||
|
||||
fn parse_trait_definitions(limit_config: Option<&LimitConfigResponse>) -> Vec<&str> {
|
||||
limit_config
|
||||
.map(|response| {
|
||||
@@ -569,6 +571,7 @@ fn parse_trait_definitions(limit_config: Option<&LimitConfigResponse>) -> Vec<&s
|
||||
.iter()
|
||||
.map(|value| value.trim())
|
||||
.filter(|value| !value.is_empty())
|
||||
.filter(|value| !DERIVED_TRAITS.contains(value))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
@@ -579,5 +582,6 @@ fn custom_traits<'a>(user: &'a AdminUser, trait_definitions: &[&str]) -> Vec<&'a
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.filter(|trait_name| !trait_definitions.contains(trait_name))
|
||||
.filter(|trait_name| !DERIVED_TRAITS.contains(trait_name))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -18,19 +18,33 @@ use crate::{
|
||||
},
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::voice_servers_forms::{create_server_form, edit_server_form};
|
||||
|
||||
pub struct VoiceServersPageParams<'a> {
|
||||
pub region_id: Option<&'a str>,
|
||||
pub region_name: Option<&'a str>,
|
||||
pub servers: Option<&'a [VoiceServer]>,
|
||||
pub connection_counts: &'a HashMap<String, i64>,
|
||||
pub error: Option<&'a str>,
|
||||
pub csrf_token: &'a str,
|
||||
}
|
||||
|
||||
pub fn voice_servers_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
region_id: Option<&str>,
|
||||
region_name: Option<&str>,
|
||||
servers: Option<&[VoiceServer]>,
|
||||
error: Option<&str>,
|
||||
csrf_token: &str,
|
||||
p: &VoiceServersPageParams<'_>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
let VoiceServersPageParams {
|
||||
region_id,
|
||||
region_name,
|
||||
servers,
|
||||
connection_counts,
|
||||
error,
|
||||
csrf_token,
|
||||
} = *p;
|
||||
let options = LayoutOptions {
|
||||
csrf_token,
|
||||
inspected_voice_region_id: region_id,
|
||||
@@ -67,7 +81,7 @@ pub fn voice_servers_page(
|
||||
html! {},
|
||||
))
|
||||
@if let Some(servers) = servers {
|
||||
(servers_list(config, rid, servers, csrf_token))
|
||||
(servers_list(config, rid, servers, connection_counts, csrf_token))
|
||||
}
|
||||
div id="create" class="mt-8" {
|
||||
(create_server_form(config, rid, csrf_token))
|
||||
@@ -109,6 +123,7 @@ fn servers_list(
|
||||
config: &AdminConfig,
|
||||
region_id: &str,
|
||||
servers: &[VoiceServer],
|
||||
connection_counts: &HashMap<String, i64>,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
if servers.is_empty() {
|
||||
@@ -121,7 +136,7 @@ fn servers_list(
|
||||
html! {
|
||||
div class="space-y-4" {
|
||||
@for server in servers {
|
||||
(server_card(config, region_id, server, csrf_token))
|
||||
(server_card(config, region_id, server, connection_counts.get(&server.server_id).copied(), csrf_token))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,6 +146,7 @@ fn server_card(
|
||||
config: &AdminConfig,
|
||||
region_id: &str,
|
||||
server: &VoiceServer,
|
||||
connection_count: Option<i64>,
|
||||
csrf_token: &str,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
@@ -145,6 +161,15 @@ fn server_card(
|
||||
let lng_str = server
|
||||
.longitude
|
||||
.map_or_else(|| "Region default".to_string(), |v| v.to_string());
|
||||
let soft_limit_str = server
|
||||
.soft_connection_limit
|
||||
.map_or_else(|| "No limit".to_string(), |v| v.to_string());
|
||||
let connections_str =
|
||||
connection_count.map_or_else(|| "Unavailable".to_string(), |v| v.to_string());
|
||||
let at_soft_limit = matches!(
|
||||
(server.soft_connection_limit, connection_count),
|
||||
(Some(limit), Some(count)) if limit > 0 && count >= limit
|
||||
);
|
||||
|
||||
card(html! {
|
||||
div class="mb-4 flex flex-col gap-1" {
|
||||
@@ -155,6 +180,9 @@ fn server_card(
|
||||
} @else {
|
||||
(badge("INACTIVE", BadgeVariant::Default))
|
||||
}
|
||||
@if at_soft_limit {
|
||||
(badge("AT SOFT LIMIT", BadgeVariant::Warning))
|
||||
}
|
||||
(voice_status_badges(vip_only, has_features, has_guild_ids))
|
||||
}
|
||||
p class="text-sm text-neutral-500" { (endpoint) }
|
||||
@@ -164,6 +192,8 @@ fn server_card(
|
||||
(data_field_text("Status", if is_active { "Active" } else { "Inactive" }))
|
||||
(data_field_text("Latitude", &lat_str))
|
||||
(data_field_text("Longitude", &lng_str))
|
||||
(data_field_text("Soft connection limit", &soft_limit_str))
|
||||
(data_field_text("Live connections", &connections_str))
|
||||
}
|
||||
(voice_features_list(&server.required_guild_features))
|
||||
(voice_guild_ids_list(&server.allowed_guild_ids))
|
||||
|
||||
@@ -26,6 +26,9 @@ pub fn edit_server_form(
|
||||
let lat_val = server.latitude.map_or_else(String::new, |v| v.to_string());
|
||||
let lng_val = server.longitude.map_or_else(String::new, |v| v.to_string());
|
||||
let is_active = server.is_active.unwrap_or(false);
|
||||
let soft_limit_val = server
|
||||
.soft_connection_limit
|
||||
.map_or_else(String::new, |v| v.to_string());
|
||||
let vip_only = server.vip_only.unwrap_or(false);
|
||||
let features_csv = server.required_guild_features.join(", ");
|
||||
let guild_ids_csv = server.allowed_guild_ids.join(", ");
|
||||
@@ -57,6 +60,15 @@ pub fn edit_server_form(
|
||||
"Optional per-server coordinate override",
|
||||
))
|
||||
}
|
||||
(form_field_with_helper(
|
||||
"Soft Connection Limit",
|
||||
&format!("{id_prefix}-soft-connection-limit"),
|
||||
"soft_connection_limit",
|
||||
"number",
|
||||
&soft_limit_val,
|
||||
"Leave empty for no limit",
|
||||
"Placement prefers another server once this server holds this many connections",
|
||||
))
|
||||
(form_field_with_helper(
|
||||
"API Key",
|
||||
&format!("{id_prefix}-api-key"),
|
||||
@@ -105,6 +117,15 @@ pub fn create_server_form(config: &AdminConfig, region_id: &str, csrf_token: &st
|
||||
(form_field_with_id("API Secret", "new-server-api-secret", "api_secret", "password", "", "LiveKit API secret", true))
|
||||
(form_field_with_id("Latitude (optional)", "new-server-latitude", "latitude", "number", "", "40.7128", false))
|
||||
(form_field_with_id("Longitude (optional)", "new-server-longitude", "longitude", "number", "", "-74.0060", false))
|
||||
(form_field_with_helper(
|
||||
"Soft Connection Limit (optional)",
|
||||
"new-server-soft-connection-limit",
|
||||
"soft_connection_limit",
|
||||
"number",
|
||||
"",
|
||||
"Leave empty for no limit",
|
||||
"Placement prefers another server once this server holds this many connections",
|
||||
))
|
||||
}
|
||||
div class="space-y-3" {
|
||||
(checkbox("is_active", "true", "Server is active", true, true))
|
||||
|
||||
@@ -18,6 +18,7 @@ use tower::ServiceExt;
|
||||
const SECRET_KEY: &str = "legacy-csrf-cookie-test-secret";
|
||||
const ADMIN_ORIGIN: &str = "https://admin.example.test";
|
||||
const LEGACY_HEX_TOKEN: &str = "8f14e45fceea167a5a36dedd4bea25438f14e45fceea167a5a36dedd4bea2543";
|
||||
const CREATED_KEY_SECRET: &str = "fa_1900000000000000001_OneTimeSecretForTests";
|
||||
|
||||
struct TestApp {
|
||||
router: Router,
|
||||
@@ -128,6 +129,10 @@ async fn load_page(app: &TestApp, cookie: &str) -> (String, String) {
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let text = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{text}");
|
||||
assert!(
|
||||
text.contains("AdminUser"),
|
||||
"the page did not render the admin the mock API returns"
|
||||
);
|
||||
let cookie_token = host_csrf_cookie(&headers)
|
||||
.unwrap_or_else(|| panic!("no __Host-csrf_token in Set-Cookie: {headers:?}"));
|
||||
let page_token = form_csrf_value(&text).expect("no _csrf hidden input rendered");
|
||||
@@ -166,7 +171,16 @@ async fn submit_action(app: &TestApp, cookie: &str, form_token: &str) -> StatusC
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response.status()
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
let text = String::from_utf8(body.to_vec()).unwrap();
|
||||
if status == StatusCode::OK {
|
||||
assert!(
|
||||
text.contains(CREATED_KEY_SECRET),
|
||||
"the action did not render the key the mock API creates"
|
||||
);
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
fn host_csrf_cookie(headers: &HeaderMap) -> Option<String> {
|
||||
@@ -207,11 +221,11 @@ async fn spawn_mock_api() -> String {
|
||||
|
||||
async fn mock_api(method: Method, uri: Uri) -> Response {
|
||||
match (method, uri.path()) {
|
||||
(Method::GET, "/admin/users/me") => Json(json!({ "user": admin_user() })).into_response(),
|
||||
(Method::GET, "/admin/users/@me") => Json(json!({ "user": admin_user() })).into_response(),
|
||||
(Method::GET, "/admin/api-keys") => Json(json!([])).into_response(),
|
||||
(Method::POST, "/admin/api-keys") => Json(json!({
|
||||
"key_id": "1900000000000000001",
|
||||
"key": "fa_1900000000000000001_OneTimeSecretForTests",
|
||||
"key": CREATED_KEY_SECRET,
|
||||
"name": "Legacy Cookie Key",
|
||||
"created_at": "2026-07-10T15:00:00.000Z",
|
||||
"expires_at": null,
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::{Body, to_bytes},
|
||||
extract::State,
|
||||
http::{Method, Request, StatusCode, Uri, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use fluxer_admin::{
|
||||
build_router,
|
||||
config::{AdminConfig, ProxyConfig, RuntimeEnv},
|
||||
session,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::net::TcpListener;
|
||||
use tower::ServiceExt;
|
||||
|
||||
const SECRET_KEY: &str = "voice-restriction-writes-test-secret";
|
||||
const REGION_ID: &str = "europe-north";
|
||||
const SERVER_ID: &str = "europe-north-server-1";
|
||||
|
||||
type CapturedBodies = Arc<Mutex<Vec<(String, Value)>>>;
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_the_restriction_fields_reaches_the_api_as_empty_lists() {
|
||||
let app = setup().await;
|
||||
let csrf_token = csrf_token(&app).await;
|
||||
let status = post_form(
|
||||
&app,
|
||||
"/voice-servers?action=update",
|
||||
&format!(
|
||||
"_csrf={csrf_token}®ion_id={REGION_ID}&server_id={SERVER_ID}\
|
||||
&endpoint=wss%3A%2F%2Fvoice.example.com&is_active=true\
|
||||
&required_guild_features=&allowed_guild_ids=&soft_connection_limit="
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::SEE_OTHER);
|
||||
|
||||
let body = captured_body(
|
||||
&app,
|
||||
"PATCH /admin/voice/regions/europe-north/servers/europe-north-server-1",
|
||||
);
|
||||
assert_eq!(body["required_guild_features"], json!([]));
|
||||
assert_eq!(body["allowed_guild_ids"], json!([]));
|
||||
assert_eq!(body["soft_connection_limit"], Value::Null);
|
||||
assert_eq!(body["vip_only"], json!(false));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activating_a_server_leaves_the_restriction_fields_untouched() {
|
||||
let app = setup().await;
|
||||
let csrf_token = csrf_token(&app).await;
|
||||
let status = post_form(
|
||||
&app,
|
||||
"/voice-servers?action=update",
|
||||
&format!(
|
||||
"_csrf={csrf_token}®ion_id={REGION_ID}&server_id={SERVER_ID}\
|
||||
&endpoint=wss%3A%2F%2Fvoice.example.com&is_active=false&vip_only=true"
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::SEE_OTHER);
|
||||
|
||||
let body = captured_body(
|
||||
&app,
|
||||
"PATCH /admin/voice/regions/europe-north/servers/europe-north-server-1",
|
||||
);
|
||||
let object = body.as_object().expect("object body");
|
||||
assert!(!object.contains_key("required_guild_features"));
|
||||
assert!(!object.contains_key("allowed_guild_ids"));
|
||||
assert_eq!(body["is_active"], json!(false));
|
||||
assert_eq!(body["vip_only"], json!(true));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clearing_the_region_restriction_fields_reaches_the_api_as_empty_lists() {
|
||||
let app = setup().await;
|
||||
let csrf_token = csrf_token(&app).await;
|
||||
let status = post_form(
|
||||
&app,
|
||||
"/voice-regions?action=update",
|
||||
&format!(
|
||||
"_csrf={csrf_token}&id={REGION_ID}&name=Northern%20Europe&emoji=%F0%9F%87%B8%F0%9F%87%AA\
|
||||
&latitude=59.33&longitude=18.06&required_guild_features=&allowed_guild_ids="
|
||||
),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::SEE_OTHER);
|
||||
|
||||
let body = captured_body(&app, "PATCH /admin/voice/regions/europe-north");
|
||||
assert_eq!(body["required_guild_features"], json!([]));
|
||||
assert_eq!(body["allowed_guild_ids"], json!([]));
|
||||
}
|
||||
|
||||
struct TestApp {
|
||||
router: Router,
|
||||
session_cookie: String,
|
||||
captured: CapturedBodies,
|
||||
}
|
||||
|
||||
async fn setup() -> TestApp {
|
||||
let captured: CapturedBodies = Arc::new(Mutex::new(Vec::new()));
|
||||
let api_endpoint = spawn_mock_api(Arc::clone(&captured)).await;
|
||||
let router = build_router(test_config(api_endpoint));
|
||||
let session_value = session::create_session("1500000000000000000", "test-token", SECRET_KEY);
|
||||
TestApp {
|
||||
router,
|
||||
session_cookie: format!("{}={session_value}", session::SESSION_COOKIE_NAME),
|
||||
captured,
|
||||
}
|
||||
}
|
||||
|
||||
fn captured_body(app: &TestApp, route: &str) -> Value {
|
||||
let captured = app.captured.lock().expect("captured bodies");
|
||||
captured
|
||||
.iter()
|
||||
.find(|(seen, _)| seen == route)
|
||||
.map(|(_, body)| body.clone())
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"no request captured for {route}, saw {:?}",
|
||||
captured.iter().map(|(seen, _)| seen).collect::<Vec<_>>()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async fn csrf_token(app: &TestApp) -> String {
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::GET)
|
||||
.uri("/voice-regions")
|
||||
.header(header::COOKIE, &app.session_cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
response
|
||||
.headers()
|
||||
.get_all(header::SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.find_map(|value| {
|
||||
let pair = value.split(';').next()?;
|
||||
let token = pair
|
||||
.strip_prefix("__Host-csrf_token=")
|
||||
.or_else(|| pair.strip_prefix("csrf_token="))?;
|
||||
(!token.is_empty()).then(|| token.to_owned())
|
||||
})
|
||||
.expect("csrf_token cookie")
|
||||
}
|
||||
|
||||
async fn post_form(app: &TestApp, uri: &str, body: &str) -> StatusCode {
|
||||
let csrf = body
|
||||
.split('&')
|
||||
.find_map(|pair| pair.strip_prefix("_csrf="))
|
||||
.expect("form carries a csrf token");
|
||||
let response = app
|
||||
.router
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method(Method::POST)
|
||||
.uri(uri)
|
||||
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
|
||||
.header(
|
||||
header::COOKIE,
|
||||
format!("{}; __Host-csrf_token={csrf}", app.session_cookie),
|
||||
)
|
||||
.body(Body::from(body.to_owned()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
response.status()
|
||||
}
|
||||
|
||||
async fn spawn_mock_api(captured: CapturedBodies) -> String {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(
|
||||
listener,
|
||||
Router::new().fallback(mock_api).with_state(captured),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn mock_api(
|
||||
State(captured): State<CapturedBodies>,
|
||||
method: Method,
|
||||
uri: Uri,
|
||||
request: Request<Body>,
|
||||
) -> Response {
|
||||
let path = uri.path().to_owned();
|
||||
if method == Method::PATCH {
|
||||
let bytes = to_bytes(request.into_body(), usize::MAX).await.unwrap();
|
||||
let body: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
|
||||
captured
|
||||
.lock()
|
||||
.expect("captured bodies")
|
||||
.push((format!("PATCH {path}"), body));
|
||||
}
|
||||
match (method, path.as_str()) {
|
||||
(Method::GET, "/admin/users/@me") => Json(json!({ "user": admin_user() })).into_response(),
|
||||
(Method::PATCH, "/admin/voice/regions/europe-north") => {
|
||||
Json(json!({ "region": region() })).into_response()
|
||||
}
|
||||
(Method::PATCH, "/admin/voice/regions/europe-north/servers/europe-north-server-1") => {
|
||||
Json(json!({ "server": server() })).into_response()
|
||||
}
|
||||
(Method::GET, "/admin/voice/regions") => {
|
||||
Json(json!({ "regions": [region()] })).into_response()
|
||||
}
|
||||
_ => (
|
||||
StatusCode::NOT_FOUND,
|
||||
Json(json!({ "message": "not found" })),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
fn region() -> Value {
|
||||
json!({
|
||||
"id": REGION_ID,
|
||||
"name": "Northern Europe",
|
||||
"emoji": "flag",
|
||||
"latitude": 59.33,
|
||||
"longitude": 18.06,
|
||||
"is_default": true,
|
||||
"vip_only": false,
|
||||
"required_guild_features": [],
|
||||
"allowed_guild_ids": [],
|
||||
"allowed_user_ids": [],
|
||||
"created_at": null,
|
||||
"updated_at": null
|
||||
})
|
||||
}
|
||||
|
||||
fn server() -> Value {
|
||||
json!({
|
||||
"region_id": REGION_ID,
|
||||
"server_id": SERVER_ID,
|
||||
"endpoint": "wss://voice.example.com",
|
||||
"latitude": null,
|
||||
"longitude": null,
|
||||
"is_active": true,
|
||||
"soft_connection_limit": null,
|
||||
"vip_only": false,
|
||||
"required_guild_features": [],
|
||||
"allowed_guild_ids": [],
|
||||
"allowed_user_ids": [],
|
||||
"created_at": null,
|
||||
"updated_at": null
|
||||
})
|
||||
}
|
||||
|
||||
fn admin_user() -> Value {
|
||||
json!({
|
||||
"id": "1500000000000000000",
|
||||
"username": "AdminUser",
|
||||
"discriminator": 1,
|
||||
"avatar": null,
|
||||
"banner": null,
|
||||
"email": "[email protected]",
|
||||
"email_verified": true,
|
||||
"email_bounced": false,
|
||||
"global_name": "AdminUser",
|
||||
"bio": null,
|
||||
"pronouns": null,
|
||||
"accent_color": null,
|
||||
"date_of_birth": null,
|
||||
"locale": "en-US",
|
||||
"acls": ["*"],
|
||||
"traits": [],
|
||||
"flags": "0",
|
||||
"premium_flags": 0,
|
||||
"bot": false,
|
||||
"system": false,
|
||||
"premium_type": null,
|
||||
"premium_since": null,
|
||||
"premium_until": null,
|
||||
"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,
|
||||
"temp_banned_until": null,
|
||||
"pending_deletion_at": null,
|
||||
"pending_bulk_message_deletion_at": null,
|
||||
"deletion_reason_code": null,
|
||||
"deletion_public_reason": null,
|
||||
"last_active_at": null,
|
||||
"last_active_ip": null,
|
||||
"last_active_ip_reverse": null,
|
||||
"last_active_location": null
|
||||
})
|
||||
}
|
||||
|
||||
fn test_config(api_endpoint: String) -> AdminConfig {
|
||||
AdminConfig {
|
||||
env: RuntimeEnv::Test,
|
||||
host: "127.0.0.1".to_owned(),
|
||||
port: 0,
|
||||
secret_key_base: SECRET_KEY.to_owned(),
|
||||
base_path: String::new(),
|
||||
api_endpoint,
|
||||
media_endpoint: "https://media.example.test".to_owned(),
|
||||
static_cdn_endpoint: "https://static.example.test".to_owned(),
|
||||
admin_endpoint: "https://admin.example.test".to_owned(),
|
||||
web_app_endpoint: "https://app.example.test".to_owned(),
|
||||
kv_url: String::new(),
|
||||
oauth_client_id: "admin-client".to_owned(),
|
||||
oauth_client_secret: "admin-secret".to_owned(),
|
||||
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
|
||||
build_version: "test".to_owned(),
|
||||
release_channel: "test".to_owned(),
|
||||
self_hosted: false,
|
||||
proxy: ProxyConfig {
|
||||
trust_client_ip_header: false,
|
||||
client_ip_header_name: "x-forwarded-for".to_owned(),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {getRegionDisplayName} from '@fluxer/geo_utils/src/RegionFormatting';
|
||||
import {getSameIpDecisionKey, isValidIp, normalizeIpString} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import maxmind, {type CityResponse, type Reader} from 'maxmind';
|
||||
import maxmind, {type AsnResponse, type CityResponse, type Reader} from 'maxmind';
|
||||
|
||||
export const UNKNOWN_LOCATION = 'Unknown Location';
|
||||
|
||||
@@ -15,6 +15,15 @@ export interface GeoipResult {
|
||||
countryName: string | null;
|
||||
latitude?: number | null;
|
||||
longitude?: number | null;
|
||||
accuracyRadiusKm?: number | null;
|
||||
timeZone?: string | null;
|
||||
}
|
||||
|
||||
export interface GeoipAsnResult {
|
||||
normalizedIp: string | null;
|
||||
asn: number | null;
|
||||
asnOrg: string | null;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
type CacheEntry = {
|
||||
@@ -22,12 +31,21 @@ type CacheEntry = {
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
type AsnCacheEntry = {
|
||||
result: GeoipAsnResult;
|
||||
expiresAt: number;
|
||||
};
|
||||
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||
const CACHE_MAX_ENTRIES = 10_000;
|
||||
const geoipCache = new Map<string, CacheEntry>();
|
||||
const asnCache = new Map<string, AsnCacheEntry>();
|
||||
|
||||
let maxmindReader: Reader<CityResponse> | null = null;
|
||||
let maxmindReaderPromise: Promise<Reader<CityResponse>> | null = null;
|
||||
let maxmindAsnReader: Reader<AsnResponse> | null = null;
|
||||
let maxmindAsnReaderPromise: Promise<Reader<AsnResponse>> | null = null;
|
||||
let maxmindAsnUnavailable = false;
|
||||
|
||||
function buildFallbackResult(normalizedIp: string): GeoipResult {
|
||||
return {
|
||||
@@ -39,6 +57,17 @@ function buildFallbackResult(normalizedIp: string): GeoipResult {
|
||||
countryName: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
accuracyRadiusKm: null,
|
||||
timeZone: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildAsnFallbackResult(normalizedIp: string | null): GeoipAsnResult {
|
||||
return {
|
||||
normalizedIp: normalizedIp || null,
|
||||
asn: null,
|
||||
asnOrg: null,
|
||||
available: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,6 +88,24 @@ async function ensureReader(dbPath: string): Promise<Reader<CityResponse>> {
|
||||
return maxmindReaderPromise;
|
||||
}
|
||||
|
||||
async function ensureAsnReader(dbPath: string): Promise<Reader<AsnResponse>> {
|
||||
if (maxmindAsnReader) return maxmindAsnReader;
|
||||
if (!maxmindAsnReaderPromise) {
|
||||
maxmindAsnReaderPromise = maxmind
|
||||
.open<AsnResponse>(dbPath, {watchForUpdates: true, watchForUpdatesNonPersistent: true})
|
||||
.then((reader) => {
|
||||
maxmindAsnReader = reader;
|
||||
return reader;
|
||||
})
|
||||
.catch((error) => {
|
||||
maxmindAsnReaderPromise = null;
|
||||
maxmindAsnUnavailable = true;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return maxmindAsnReaderPromise;
|
||||
}
|
||||
|
||||
function stateLabel(record?: CityResponse): string | null {
|
||||
const subdivision = record?.subdivisions?.[0];
|
||||
if (!subdivision) return null;
|
||||
@@ -112,6 +159,31 @@ function setCachedGeoipResult(cacheKey: string, result: GeoipResult): void {
|
||||
geoipCache.set(cacheKey, {result, expiresAt: Date.now() + CACHE_TTL_MS});
|
||||
}
|
||||
|
||||
function getCachedAsnResult(cacheKey: string, normalizedIp: string): GeoipAsnResult | null {
|
||||
const cached = asnCache.get(cacheKey);
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
if (Date.now() >= cached.expiresAt) {
|
||||
asnCache.delete(cacheKey);
|
||||
return null;
|
||||
}
|
||||
asnCache.delete(cacheKey);
|
||||
asnCache.set(cacheKey, cached);
|
||||
return {...cached.result, normalizedIp};
|
||||
}
|
||||
|
||||
function setCachedAsnResult(cacheKey: string, result: GeoipAsnResult): void {
|
||||
asnCache.delete(cacheKey);
|
||||
if (asnCache.size >= CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = asnCache.keys().next().value;
|
||||
if (oldestKey !== undefined) {
|
||||
asnCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
asnCache.set(cacheKey, {result, expiresAt: Date.now() + CACHE_TTL_MS});
|
||||
}
|
||||
|
||||
async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult> {
|
||||
try {
|
||||
const reader = await ensureReader(dbPath);
|
||||
@@ -128,12 +200,32 @@ async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult
|
||||
countryName: record.country?.names?.en ?? (countryCode ? countryDisplayName(countryCode) : null) ?? null,
|
||||
latitude: record.location?.latitude ?? null,
|
||||
longitude: record.location?.longitude ?? null,
|
||||
accuracyRadiusKm: record.location?.accuracy_radius ?? null,
|
||||
timeZone: record.location?.time_zone ?? null,
|
||||
};
|
||||
} catch {
|
||||
return buildFallbackResult(clean);
|
||||
}
|
||||
}
|
||||
|
||||
async function lookupMaxmindAsn(clean: string, dbPath: string): Promise<GeoipAsnResult> {
|
||||
try {
|
||||
const reader = await ensureAsnReader(dbPath);
|
||||
const record = reader.get(clean);
|
||||
if (!record) {
|
||||
return {normalizedIp: clean, asn: null, asnOrg: null, available: true};
|
||||
}
|
||||
return {
|
||||
normalizedIp: clean,
|
||||
asn: record.autonomous_system_number ?? null,
|
||||
asnOrg: record.autonomous_system_organization ?? null,
|
||||
available: true,
|
||||
};
|
||||
} catch {
|
||||
return buildAsnFallbackResult(clean);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveGeoip(clean: string, dbPath: string): Promise<GeoipResult> {
|
||||
const cacheKey = getSameIpDecisionKey(clean) ?? clean;
|
||||
const cached = getCachedGeoipResult(cacheKey, clean);
|
||||
@@ -145,6 +237,17 @@ async function resolveGeoip(clean: string, dbPath: string): Promise<GeoipResult>
|
||||
return result;
|
||||
}
|
||||
|
||||
async function resolveAsn(clean: string, dbPath: string): Promise<GeoipAsnResult> {
|
||||
const cacheKey = getSameIpDecisionKey(clean) ?? clean;
|
||||
const cached = getCachedAsnResult(cacheKey, clean);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const result = await lookupMaxmindAsn(clean, dbPath);
|
||||
setCachedAsnResult(cacheKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function lookupGeoipByIp(ip: string, dbPath: string | undefined): Promise<GeoipResult> {
|
||||
if (!dbPath) {
|
||||
return buildFallbackResult(ip);
|
||||
@@ -156,6 +259,27 @@ export async function lookupGeoipByIp(ip: string, dbPath: string | undefined): P
|
||||
return resolveGeoip(clean, dbPath);
|
||||
}
|
||||
|
||||
export async function lookupAsnByIp(ip: string, asnDbPath: string | undefined): Promise<GeoipAsnResult> {
|
||||
if (!asnDbPath || maxmindAsnUnavailable) {
|
||||
return buildAsnFallbackResult(null);
|
||||
}
|
||||
const clean = normalizeIpString(ip);
|
||||
if (!isValidIp(clean)) {
|
||||
return buildAsnFallbackResult(clean);
|
||||
}
|
||||
return resolveAsn(clean, asnDbPath);
|
||||
}
|
||||
|
||||
export function resetGeoipReadersForTesting(): void {
|
||||
maxmindReader = null;
|
||||
maxmindReaderPromise = null;
|
||||
maxmindAsnReader = null;
|
||||
maxmindAsnReaderPromise = null;
|
||||
maxmindAsnUnavailable = false;
|
||||
geoipCache.clear();
|
||||
asnCache.clear();
|
||||
}
|
||||
|
||||
export function formatGeoipLocation(result: GeoipResult): string | null {
|
||||
const parts: Array<string> = [];
|
||||
if (result.city) parts.push(result.city);
|
||||
|
||||
@@ -12,6 +12,7 @@ const GEOIP_DOWNLOAD_PATH_QUERY_PARAM = 'download_path';
|
||||
const GEOIP_ASN_DOWNLOAD_PATH_QUERY_PARAM = 'asn_download_path';
|
||||
const GEOIP_ASN_KEY_QUERY_PARAM = 'asn_key';
|
||||
const DEFAULT_GEOIP_TEMPORARY_DIRECTORY = '/tmp/fluxer/geoip';
|
||||
const DEFAULT_GEOIP_ASN_DB_BASENAME = 'GeoLite2-ASN.mmdb';
|
||||
|
||||
type GeoipSourceMode = 'filesystem' | 's3';
|
||||
|
||||
@@ -167,9 +168,11 @@ async function downloadS3Object(
|
||||
}
|
||||
|
||||
function createGeoipFilesystemSourceConfig(rawValue: string | undefined): GeoipFilesystemSourceConfig {
|
||||
const maxmindDbPath = rawValue === '' ? undefined : rawValue;
|
||||
return {
|
||||
mode: 'filesystem',
|
||||
maxmindDbPath: rawValue === '' ? undefined : rawValue,
|
||||
maxmindDbPath,
|
||||
maxmindAsnDbPath: maxmindDbPath ? path.join(path.dirname(maxmindDbPath), DEFAULT_GEOIP_ASN_DB_BASENAME) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,11 @@ const CACHE_KEY_PREFIX = 'ipinfo:max:';
|
||||
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/u;
|
||||
const POSITIVE_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60;
|
||||
const NEGATIVE_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
|
||||
const FAILURE_TTL_REQUEST_FAILED_SECONDS = 60;
|
||||
const FAILURE_TTL_HTTP_ERROR_SECONDS = 300;
|
||||
const FAILURE_TTL_QUOTA_SECONDS = 900;
|
||||
const FAILURE_TTL_SCHEMA_MISMATCH_SECONDS = 600;
|
||||
const FAILURE_TTL_BACKGROUND_CAP_SECONDS = 120;
|
||||
|
||||
export interface IpInfoGeoBlock {
|
||||
countryCode: string | null;
|
||||
@@ -73,6 +78,41 @@ export interface IpInfoCache {
|
||||
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
|
||||
}
|
||||
|
||||
export type IpInfoLookupPriority = 'critical' | 'standard' | 'background';
|
||||
|
||||
export interface IpInfoLookupBudget {
|
||||
tryConsume(priority: IpInfoLookupPriority): Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface CachedIpInfoFailure extends IpInfoLookupResult {
|
||||
cachedFailure: true;
|
||||
failureOutcome: 'http_error' | 'request_failed' | 'schema_mismatch';
|
||||
failureHttpStatus: number | null;
|
||||
cachedAtMs: number;
|
||||
}
|
||||
|
||||
export function resolveIpInfoLookupPriority(source: string | undefined): IpInfoLookupPriority {
|
||||
if (source === 'admin.ip_ban' || source === 'admin.scheduled_deletion_suspicious_ip') return 'critical';
|
||||
if (source === 'AbusiveIpAutoBanner') return 'background';
|
||||
return 'standard';
|
||||
}
|
||||
|
||||
export function isCachedIpInfoFailure(value: unknown): value is CachedIpInfoFailure {
|
||||
return typeof value === 'object' && value !== null && (value as {available?: unknown}).available === false;
|
||||
}
|
||||
|
||||
function failureCacheTtlSeconds(
|
||||
outcome: CachedIpInfoFailure['failureOutcome'],
|
||||
httpStatus: number | null,
|
||||
priority: IpInfoLookupPriority,
|
||||
): number {
|
||||
let ttl = FAILURE_TTL_HTTP_ERROR_SECONDS;
|
||||
if (outcome === 'request_failed') ttl = FAILURE_TTL_REQUEST_FAILED_SECONDS;
|
||||
else if (outcome === 'schema_mismatch') ttl = FAILURE_TTL_SCHEMA_MISMATCH_SECONDS;
|
||||
else if (httpStatus === 402 || httpStatus === 403 || httpStatus === 429) ttl = FAILURE_TTL_QUOTA_SECONDS;
|
||||
return priority === 'background' ? Math.min(ttl, FAILURE_TTL_BACKGROUND_CAP_SECONDS) : ttl;
|
||||
}
|
||||
|
||||
export interface IpInfoLookupContext {
|
||||
source?: string;
|
||||
reason?: string;
|
||||
@@ -86,7 +126,7 @@ export interface IpInfoRequestAuditEvent {
|
||||
source: string;
|
||||
reason: string | null;
|
||||
metadata?: Record<string, string | number | boolean | null>;
|
||||
outcome: 'http_success' | 'http_error' | 'request_failed' | 'schema_mismatch';
|
||||
outcome: 'http_success' | 'http_error' | 'request_failed' | 'schema_mismatch' | 'budget_shed';
|
||||
httpStatus: number | null;
|
||||
available: boolean;
|
||||
riskNote: string;
|
||||
@@ -110,6 +150,7 @@ interface IpInfoServiceContext {
|
||||
apiKey: string;
|
||||
cache: IpInfoCache;
|
||||
auditLogger?: IpInfoRequestAuditLogger;
|
||||
budget?: IpInfoLookupBudget;
|
||||
}
|
||||
|
||||
export interface IpInfoService {
|
||||
@@ -177,8 +218,12 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
|
||||
return {
|
||||
async lookup(ip: string, context?: IpInfoLookupContext): Promise<IpInfoLookupResult> {
|
||||
const cacheKey = `${CACHE_KEY_PREFIX}${getSameIpDecisionKey(ip) ?? ip}`;
|
||||
const priority = resolveIpInfoLookupPriority(context?.source);
|
||||
const cached = await ctx.cache.get<IpInfoLookupResult>(cacheKey);
|
||||
if (cached !== null) {
|
||||
if (isCachedIpInfoFailure(cached)) {
|
||||
return unavailable(ip, cached.riskNote);
|
||||
}
|
||||
return {...cached, ip};
|
||||
}
|
||||
const existing = inflight.get(cacheKey);
|
||||
@@ -222,6 +267,30 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
|
||||
return params.result;
|
||||
};
|
||||
const performLookup = async (): Promise<IpInfoLookupResult> => {
|
||||
if (ctx.budget && !(await ctx.budget.tryConsume(priority))) {
|
||||
return finalize({
|
||||
result: unavailable(ip, `IPInfo lookup shed (budget exhausted, priority: ${priority})`),
|
||||
outcome: 'budget_shed',
|
||||
httpStatus: null,
|
||||
});
|
||||
}
|
||||
const finalizeFailure = async (params: {
|
||||
result: IpInfoLookupResult;
|
||||
outcome: CachedIpInfoFailure['failureOutcome'];
|
||||
httpStatus: number | null;
|
||||
}): Promise<IpInfoLookupResult> => {
|
||||
const entry: CachedIpInfoFailure = {
|
||||
...params.result,
|
||||
cachedFailure: true,
|
||||
failureOutcome: params.outcome,
|
||||
failureHttpStatus: params.httpStatus,
|
||||
cachedAtMs: Date.now(),
|
||||
};
|
||||
await ctx.cache
|
||||
.set(cacheKey, entry, failureCacheTtlSeconds(params.outcome, params.httpStatus, priority))
|
||||
.catch(() => {});
|
||||
return finalize(params);
|
||||
};
|
||||
let payload: unknown;
|
||||
try {
|
||||
const res = await fetch(fetchUrl, {
|
||||
@@ -229,7 +298,7 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
|
||||
headers: {Accept: 'application/json'},
|
||||
});
|
||||
if (!res.ok) {
|
||||
return finalize({
|
||||
return finalizeFailure({
|
||||
result: unavailable(ip, `IPInfo HTTP ${res.status}`),
|
||||
outcome: 'http_error',
|
||||
httpStatus: res.status,
|
||||
@@ -238,7 +307,7 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
|
||||
payload = await res.json();
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
return finalize({
|
||||
return finalizeFailure({
|
||||
result: unavailable(ip, `IPInfo request failed: ${detail}`),
|
||||
outcome: 'request_failed',
|
||||
httpStatus: null,
|
||||
@@ -246,7 +315,7 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
|
||||
}
|
||||
const parsedResponse = RawIpInfoResponseSchema.safeParse(payload);
|
||||
if (!parsedResponse.success) {
|
||||
return finalize({
|
||||
return finalizeFailure({
|
||||
result: unavailable(ip, formatSchemaMismatch(parsedResponse.error)),
|
||||
outcome: 'schema_mismatch',
|
||||
httpStatus: 200,
|
||||
@@ -261,8 +330,10 @@ export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
|
||||
httpStatus: 200,
|
||||
});
|
||||
};
|
||||
const promise = performLookup().finally(() => {
|
||||
inflight.delete(cacheKey);
|
||||
const promise: Promise<IpInfoLookupResult> = performLookup().finally(() => {
|
||||
if (inflight.get(cacheKey) === promise) {
|
||||
inflight.delete(cacheKey);
|
||||
}
|
||||
});
|
||||
inflight.set(cacheKey, promise);
|
||||
return promise;
|
||||
|
||||
@@ -8,6 +8,7 @@ interface TieredIpInfoCacheOptions {
|
||||
hot: IpInfoCache;
|
||||
cold: IpInfoCache;
|
||||
hotTtlSeconds?: number;
|
||||
skipColdWrite?: (value: unknown) => boolean;
|
||||
}
|
||||
|
||||
export function createTieredIpInfoCache(opts: TieredIpInfoCacheOptions): IpInfoCache {
|
||||
@@ -18,14 +19,17 @@ export function createTieredIpInfoCache(opts: TieredIpInfoCacheOptions): IpInfoC
|
||||
if (hit !== null) return hit;
|
||||
const cold = await opts.cold.get<T>(key).catch(() => null);
|
||||
if (cold === null) return null;
|
||||
if (opts.skipColdWrite?.(cold) === true) return cold;
|
||||
void opts.hot.set(key, cold, hotTtl).catch(() => {});
|
||||
return cold;
|
||||
},
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
await Promise.all([
|
||||
opts.hot.set(key, value, hotTtl).catch(() => {}),
|
||||
opts.cold.set(key, value, ttlSeconds).catch(() => {}),
|
||||
]);
|
||||
const effectiveHotTtl = Math.max(1, Math.min(hotTtl, ttlSeconds ?? hotTtl));
|
||||
const writes: Array<Promise<void>> = [opts.hot.set(key, value, effectiveHotTtl).catch(() => {})];
|
||||
if (opts.skipColdWrite?.(value) !== true) {
|
||||
writes.push(opts.cold.set(key, value, ttlSeconds).catch(() => {}));
|
||||
}
|
||||
await Promise.all(writes);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -81,6 +81,22 @@ function withUploadRelaySecret(master: MasterConfig, secretBase64: string): Mast
|
||||
};
|
||||
}
|
||||
|
||||
function withStripeLegacyPrices(
|
||||
master: MasterConfig,
|
||||
legacyPrices: Record<string, Array<string> | undefined> | undefined,
|
||||
): MasterConfig {
|
||||
return {
|
||||
...master,
|
||||
integrations: {
|
||||
...master.integrations,
|
||||
stripe: {
|
||||
...master.integrations.stripe,
|
||||
legacy_prices: legacyPrices,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildAPIConfigFromMaster upload relay secret', () => {
|
||||
let master: MasterConfig;
|
||||
beforeAll(async () => {
|
||||
@@ -111,3 +127,42 @@ describe('buildAPIConfigFromMaster upload relay secret', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAPIConfigFromMaster stripe legacy prices', () => {
|
||||
let master: MasterConfig;
|
||||
beforeAll(async () => {
|
||||
master = await loadConfig();
|
||||
});
|
||||
|
||||
it('carries the retired stripe price map from master config onto the api config', () => {
|
||||
const legacyPrices = {
|
||||
monthly_brl: ['price_retired_monthly_brl'],
|
||||
yearly_brl: ['price_retired_yearly_brl_a', 'price_retired_yearly_brl_b'],
|
||||
monthly_try: ['price_1TMYpdFPC94Os7FdZVRx98Up'],
|
||||
};
|
||||
expect(buildAPIConfigFromMaster(withStripeLegacyPrices(master, legacyPrices)).stripe.legacyPrices).toEqual(
|
||||
legacyPrices,
|
||||
);
|
||||
});
|
||||
|
||||
it('carries the retired price map even when no live prices are configured', () => {
|
||||
const withoutPrices: MasterConfig = {
|
||||
...master,
|
||||
integrations: {
|
||||
...master.integrations,
|
||||
stripe: {
|
||||
...master.integrations.stripe,
|
||||
prices: undefined,
|
||||
legacy_prices: {monthly_try: ['price_1TMYpdFPC94Os7FdZVRx98Up']},
|
||||
},
|
||||
},
|
||||
};
|
||||
const config = buildAPIConfigFromMaster(withoutPrices);
|
||||
expect(config.stripe.prices).toBeUndefined();
|
||||
expect(config.stripe.legacyPrices).toEqual({monthly_try: ['price_1TMYpdFPC94Os7FdZVRx98Up']});
|
||||
});
|
||||
|
||||
it('leaves the retired price map undefined when master config does not set one', () => {
|
||||
expect(buildAPIConfigFromMaster(withStripeLegacyPrices(master, undefined)).stripe.legacyPrices).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -372,17 +372,29 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
monthlyUsd: master.integrations.stripe.prices.monthly_usd,
|
||||
monthlyEur: master.integrations.stripe.prices.monthly_eur,
|
||||
monthlyBrl: master.integrations.stripe.prices.monthly_brl,
|
||||
monthlyDkk: master.integrations.stripe.prices.monthly_dkk,
|
||||
monthlyInr: master.integrations.stripe.prices.monthly_inr,
|
||||
monthlyNok: master.integrations.stripe.prices.monthly_nok,
|
||||
monthlyPln: master.integrations.stripe.prices.monthly_pln,
|
||||
monthlySek: master.integrations.stripe.prices.monthly_sek,
|
||||
monthlyTry: master.integrations.stripe.prices.monthly_try,
|
||||
yearlyUsd: master.integrations.stripe.prices.yearly_usd,
|
||||
yearlyEur: master.integrations.stripe.prices.yearly_eur,
|
||||
yearlyBrl: master.integrations.stripe.prices.yearly_brl,
|
||||
yearlyDkk: master.integrations.stripe.prices.yearly_dkk,
|
||||
yearlyInr: master.integrations.stripe.prices.yearly_inr,
|
||||
yearlyNok: master.integrations.stripe.prices.yearly_nok,
|
||||
yearlyPln: master.integrations.stripe.prices.yearly_pln,
|
||||
yearlySek: master.integrations.stripe.prices.yearly_sek,
|
||||
yearlyTry: master.integrations.stripe.prices.yearly_try,
|
||||
gift1MonthUsd: master.integrations.stripe.prices.gift_1_month_usd,
|
||||
gift1MonthEur: master.integrations.stripe.prices.gift_1_month_eur,
|
||||
gift1MonthSek: master.integrations.stripe.prices.gift_1_month_sek,
|
||||
gift1YearSek: master.integrations.stripe.prices.gift_1_year_sek,
|
||||
gift1MonthDkk: master.integrations.stripe.prices.gift_1_month_dkk,
|
||||
gift1YearDkk: master.integrations.stripe.prices.gift_1_year_dkk,
|
||||
gift1MonthNok: master.integrations.stripe.prices.gift_1_month_nok,
|
||||
gift1YearNok: master.integrations.stripe.prices.gift_1_year_nok,
|
||||
gift1MonthBrl: master.integrations.stripe.prices.gift_1_month_brl,
|
||||
gift1MonthInr: master.integrations.stripe.prices.gift_1_month_inr,
|
||||
gift1MonthPln: master.integrations.stripe.prices.gift_1_month_pln,
|
||||
@@ -395,6 +407,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
|
||||
gift1YearTry: master.integrations.stripe.prices.gift_1_year_try,
|
||||
}
|
||||
: undefined,
|
||||
legacyPrices: master.integrations.stripe.legacy_prices,
|
||||
},
|
||||
bunny: {
|
||||
purgeEnabled: master.integrations.bunny.purge_enabled,
|
||||
|
||||
@@ -30,8 +30,8 @@ import {phraseBlocklistCache} from '../../middleware/PhraseBlocklistCache';
|
||||
import {profileSubstringBlocklistCache} from '../../middleware/ProfileSubstringBlocklistCache';
|
||||
import {urlBlocklistCache} from '../../middleware/UrlBlocklistCache';
|
||||
import {
|
||||
getIpBanBlastRadiusVerdict,
|
||||
getSuspiciousIpSkipReason,
|
||||
hasHighCgnatBlastRadiusRisk,
|
||||
isSingleIpBanCandidate,
|
||||
} from '../../risk/IpBanCgnatGuard';
|
||||
import {isIpBanExempt} from '../../risk/IpBanExemptions';
|
||||
@@ -292,7 +292,7 @@ export class AdminBanManagementService {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const highRisk = await hasHighCgnatBlastRadiusRisk(ip, this.deps.ipInfoService, {
|
||||
const {cgnat: highRisk} = await getIpBanBlastRadiusVerdict(ip, this.deps.ipInfoService, {
|
||||
source: 'admin.ip_ban',
|
||||
reason: 'pre_write_cgnat_guard',
|
||||
});
|
||||
|
||||
@@ -229,14 +229,11 @@ export class AdminMessageService {
|
||||
hitsPerPage: limit,
|
||||
page: 1,
|
||||
});
|
||||
const messageEntries = result.hits.map((hit) => ({
|
||||
channelId: createChannelID(BigInt(hit.channelId)),
|
||||
messageId: createMessageID(BigInt(hit.id)),
|
||||
}));
|
||||
const resolvedMessages = await Promise.all(
|
||||
messageEntries.map(({channelId, messageId}) => this.getMessageResponseForAdmin(channelId, messageId)),
|
||||
);
|
||||
const messageResponses = resolvedMessages.filter((message): message is MessageResponse => message !== null);
|
||||
const messageResponses = await createMessageResponseDataService().buildMessages({
|
||||
userId: createUserID(0n),
|
||||
messages: result.messages,
|
||||
access: await this.getMessageResponseAccessForAdmin(channelId),
|
||||
});
|
||||
const attachmentStatuses = await this.getAttachmentStatusesForMessages(messageResponses);
|
||||
const priorReports = await this.getPriorReportsForMessages(messageResponses);
|
||||
const adminMessages = messageResponses.map((message) =>
|
||||
@@ -282,19 +279,6 @@ export class AdminMessageService {
|
||||
});
|
||||
}
|
||||
|
||||
private async getMessageResponseForAdmin(
|
||||
channelId: ChannelID,
|
||||
messageId: MessageID,
|
||||
): Promise<MessageResponse | null> {
|
||||
const access = await this.getMessageResponseAccessForAdmin(channelId);
|
||||
return createMessageResponseDataService().getMessage({
|
||||
userId: createUserID(0n),
|
||||
channelId,
|
||||
messageId,
|
||||
access,
|
||||
});
|
||||
}
|
||||
|
||||
private async getPriorReportsForMessages(messages: Array<MessageResponse>): Promise<Map<string, Array<string>>> {
|
||||
const authorIds = messages.map((message) => createUserID(BigInt(message.author.id)));
|
||||
return this.deps.ncmecSubmissionService.getUserPriorReportIds(authorIds);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
|
||||
import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import {createGuildID, createUserID, type UserID} from '../../BrandedTypes';
|
||||
import {isSyntheticUserId} from '../../constants/Core';
|
||||
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
|
||||
import {Logger} from '../../Logger';
|
||||
import {getGuildSearchService, getUserSearchService} from '../../SearchFactory';
|
||||
@@ -129,12 +130,11 @@ export class AdminSearchService {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
const query = data.query?.trim() || '';
|
||||
const isIdQuery = /^\d+$/.test(query);
|
||||
const directUserId = /^\d+$/.test(query) ? createUserID(BigInt(query)) : null;
|
||||
const canResolveDirectUser = directUserId !== null && !isSyntheticUserId(directUserId) && data.offset === 0;
|
||||
const [searchResult, directUser] = await Promise.all([
|
||||
userSearchService.search(query, {}, {limit: data.limit, offset: data.offset}),
|
||||
isIdQuery && data.offset === 0
|
||||
? userRepository.findUnique(createUserID(BigInt(query))).catch(() => null)
|
||||
: Promise.resolve(null),
|
||||
canResolveDirectUser ? userRepository.findUnique(directUserId).catch(() => null) : Promise.resolve(null),
|
||||
]);
|
||||
const {hits, total} = searchResult;
|
||||
const userIds = hits.map((hit) => createUserID(BigInt(hit.id)));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type {LookupUserRequest} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
|
||||
import type {ApiContext} from '../../ApiContext';
|
||||
import {createUserID} from '../../BrandedTypes';
|
||||
import {isSyntheticUserId} from '../../constants/Core';
|
||||
import {Logger} from '../../Logger';
|
||||
import {mapUserToAdminResponse} from '../models/UserTypes';
|
||||
|
||||
@@ -32,7 +33,7 @@ export class AdminUserLookupService {
|
||||
} else if (/^\d+$/.test(query)) {
|
||||
try {
|
||||
const userId = createUserID(BigInt(query));
|
||||
user = await userRepository.findUnique(userId);
|
||||
user = isSyntheticUserId(userId) ? null : await userRepository.findUnique(userId);
|
||||
} catch (error) {
|
||||
Logger.debug({query, error}, 'Failed to lookup user by numeric ID, invalid ID format');
|
||||
user = null;
|
||||
|
||||
@@ -45,6 +45,7 @@ import {Logger} from '../../Logger';
|
||||
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
|
||||
import type {IRiskHistoryRepository} from '../../risk/HistoricalOutcomeRepository';
|
||||
import type {HistoricalOutcomeCode} from '../../risk/RiskHistoryTypes';
|
||||
import {resolveAssignedTraits} from '../../user/UserTraits';
|
||||
import {getIpAddressReverse, getLocationLabelFromIp} from '../../utils/IpUtils';
|
||||
import {resolveSessionClientInfo} from '../../utils/SessionClientIdentity';
|
||||
import {mapUserToAdminResponse} from '../models/UserTypes';
|
||||
@@ -383,7 +384,8 @@ export class AdminUserSecurityService {
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
}
|
||||
const traitSet = data.traits.length > 0 ? new Set(data.traits) : null;
|
||||
const assigned = resolveAssignedTraits(user.traits ?? [], data.traits);
|
||||
const traitSet = assigned.size > 0 ? assigned : null;
|
||||
const updatedUser = await userRepository.patchUpsert(
|
||||
userId,
|
||||
{
|
||||
|
||||
@@ -235,6 +235,7 @@ export class AdminVoiceService {
|
||||
serverId: data.server_id,
|
||||
endpoint: data.endpoint,
|
||||
isActive: data.is_active ?? true,
|
||||
softConnectionLimit: data.soft_connection_limit ?? null,
|
||||
apiKey: data.api_key ?? null,
|
||||
apiSecret: data.api_secret ?? null,
|
||||
latitude: data.latitude ?? null,
|
||||
@@ -271,6 +272,7 @@ export class AdminVoiceService {
|
||||
if (data.latitude !== undefined) updates.latitude = data.latitude;
|
||||
if (data.longitude !== undefined) updates.longitude = data.longitude;
|
||||
if (data.is_active !== undefined) updates.isActive = data.is_active;
|
||||
if (data.soft_connection_limit !== undefined) updates.softConnectionLimit = data.soft_connection_limit;
|
||||
updates.restrictions = patchVoiceRestrictions(existing.restrictions, data);
|
||||
updates.updatedAt = new Date();
|
||||
await voiceRepository.upsertServer(updates);
|
||||
@@ -339,6 +341,7 @@ export class AdminVoiceService {
|
||||
latitude: server.latitude ?? null,
|
||||
longitude: server.longitude ?? null,
|
||||
is_active: server.isActive,
|
||||
soft_connection_limit: server.softConnectionLimit ?? null,
|
||||
vip_only: server.restrictions.vipOnly,
|
||||
required_guild_features: Array.from(server.restrictions.requiredGuildFeatures),
|
||||
allowed_guild_ids: allowedGuildIds,
|
||||
|
||||
@@ -21,6 +21,8 @@ interface UserListResponse {
|
||||
total: number;
|
||||
}
|
||||
|
||||
const SYNTHETIC_USER_IDS = ['0', '1'];
|
||||
|
||||
async function setLastActiveIp(harness: ApiTestHarness, token: string, ip: string): Promise<void> {
|
||||
await createBuilder(harness, `${token}`)
|
||||
.get('/users/@me')
|
||||
@@ -170,5 +172,35 @@ describe('Admin user directory', () => {
|
||||
expect(result.users.map((user) => user.id)).toEqual([target.userId]);
|
||||
expect(result.users[0]?.email).toBeNull();
|
||||
});
|
||||
test.each(SYNTHETIC_USER_IDS)('omits the synthetic account %s from the resolve selector', async (userId) => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]);
|
||||
const result = await createBuilder<UserListResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/users?resolve=${userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(result.users).toEqual([]);
|
||||
expect(result.total).toBe(0);
|
||||
});
|
||||
test.each(SYNTHETIC_USER_IDS)('omits the synthetic account %s from the q selector', async (userId) => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]);
|
||||
const result = await createBuilder<UserListResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/users?q=${userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(result.users.map((user) => user.id)).not.toContain(userId);
|
||||
});
|
||||
});
|
||||
describe('GET /admin/users/:user_id', () => {
|
||||
test.each(SYNTHETIC_USER_IDS)('reports no user for the synthetic account %s', async (userId) => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, [AdminACLs.AUTHENTICATE, AdminACLs.USER_LOOKUP]);
|
||||
const result = await createBuilder<UserListResponse>(harness, `${admin.token}`)
|
||||
.get(`/admin/users/${userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(result.users).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,4 +266,161 @@ describe('VoiceAdminController', () => {
|
||||
expect(persisted?.apiKey).toBe(fixture.initialApiKey);
|
||||
expect(persisted?.apiSecret).toBe(fixture.initialApiSecret);
|
||||
});
|
||||
test('stores, keeps, and clears a voice server soft connection limit', async () => {
|
||||
const admin = await createAdminWithAcls(harness, [
|
||||
AdminACLs.VOICE_REGION_CREATE,
|
||||
AdminACLs.VOICE_SERVER_CREATE,
|
||||
AdminACLs.VOICE_SERVER_LIST,
|
||||
AdminACLs.VOICE_SERVER_UPDATE,
|
||||
]);
|
||||
const regionId = 'voice-region-soft-limit';
|
||||
const serverId = 'voice-server-soft-limit';
|
||||
await createBuilder<CreateVoiceRegionResponse>(harness, `${admin.token}`)
|
||||
.post('/admin/voice/regions')
|
||||
.body({
|
||||
id: regionId,
|
||||
name: `Region ${regionId}`,
|
||||
emoji: ':earth_americas:',
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const created = await createBuilder<CreateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.post(`/admin/voice/regions/${regionId}/servers`)
|
||||
.body({
|
||||
server_id: serverId,
|
||||
endpoint: 'https://voice-soft-limit.example.com/socket',
|
||||
api_key: 'soft-limit-api-key',
|
||||
api_secret: 'soft-limit-api-secret',
|
||||
soft_connection_limit: 250,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(created.server.soft_connection_limit).toBe(250);
|
||||
expect((await voiceRepository.getServer(regionId, serverId))?.softConnectionLimit).toBe(250);
|
||||
await createBuilder<UpdateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.patch(`/admin/voice/regions/${regionId}/servers/${serverId}`)
|
||||
.body({endpoint: 'https://voice-soft-limit-2.example.com/socket'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect((await voiceRepository.getServer(regionId, serverId))?.softConnectionLimit).toBe(250);
|
||||
const cleared = await createBuilder<UpdateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.patch(`/admin/voice/regions/${regionId}/servers/${serverId}`)
|
||||
.body({soft_connection_limit: null})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(cleared.server.soft_connection_limit).toBeNull();
|
||||
expect((await voiceRepository.getServer(regionId, serverId))?.softConnectionLimit).toBeNull();
|
||||
});
|
||||
test('clears voice server restriction lists when empty arrays are supplied', async () => {
|
||||
const admin = await createAdminWithAcls(harness, [
|
||||
AdminACLs.VOICE_REGION_CREATE,
|
||||
AdminACLs.VOICE_SERVER_CREATE,
|
||||
AdminACLs.VOICE_SERVER_UPDATE,
|
||||
]);
|
||||
const regionId = 'voice-region-clear-restrictions';
|
||||
const serverId = 'voice-server-clear-restrictions';
|
||||
await createBuilder<CreateVoiceRegionResponse>(harness, `${admin.token}`)
|
||||
.post('/admin/voice/regions')
|
||||
.body({
|
||||
id: regionId,
|
||||
name: `Region ${regionId}`,
|
||||
emoji: ':earth_americas:',
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder<CreateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.post(`/admin/voice/regions/${regionId}/servers`)
|
||||
.body({
|
||||
server_id: serverId,
|
||||
endpoint: 'https://voice-clear.example.com/socket',
|
||||
api_key: 'clear-api-key',
|
||||
api_secret: 'clear-api-secret',
|
||||
required_guild_features: ['VIP_VOICE'],
|
||||
allowed_guild_ids: [1234567890123456789n.toString()],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const stored = await voiceRepository.getServer(regionId, serverId);
|
||||
expect(Array.from(stored?.restrictions.requiredGuildFeatures ?? [])).toEqual(['VIP_VOICE']);
|
||||
expect(stored?.restrictions.allowedGuildIds.size).toBe(1);
|
||||
const cleared = await createBuilder<UpdateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.patch(`/admin/voice/regions/${regionId}/servers/${serverId}`)
|
||||
.body({required_guild_features: [], allowed_guild_ids: []})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(cleared.server.required_guild_features).toEqual([]);
|
||||
expect(cleared.server.allowed_guild_ids).toEqual([]);
|
||||
const persisted = await voiceRepository.getServer(regionId, serverId);
|
||||
expect(persisted?.restrictions.requiredGuildFeatures.size).toBe(0);
|
||||
expect(persisted?.restrictions.allowedGuildIds.size).toBe(0);
|
||||
});
|
||||
test('leaves voice server restriction lists unchanged when they are omitted', async () => {
|
||||
const admin = await createAdminWithAcls(harness, [
|
||||
AdminACLs.VOICE_REGION_CREATE,
|
||||
AdminACLs.VOICE_SERVER_CREATE,
|
||||
AdminACLs.VOICE_SERVER_UPDATE,
|
||||
]);
|
||||
const regionId = 'voice-region-keep-restrictions';
|
||||
const serverId = 'voice-server-keep-restrictions';
|
||||
await createBuilder<CreateVoiceRegionResponse>(harness, `${admin.token}`)
|
||||
.post('/admin/voice/regions')
|
||||
.body({
|
||||
id: regionId,
|
||||
name: `Region ${regionId}`,
|
||||
emoji: ':earth_americas:',
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder<CreateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.post(`/admin/voice/regions/${regionId}/servers`)
|
||||
.body({
|
||||
server_id: serverId,
|
||||
endpoint: 'https://voice-keep.example.com/socket',
|
||||
api_key: 'keep-api-key',
|
||||
api_secret: 'keep-api-secret',
|
||||
required_guild_features: ['VIP_VOICE'],
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder<UpdateVoiceServerResponse>(harness, `${admin.token}`)
|
||||
.patch(`/admin/voice/regions/${regionId}/servers/${serverId}`)
|
||||
.body({is_active: false})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const persisted = await voiceRepository.getServer(regionId, serverId);
|
||||
expect(Array.from(persisted?.restrictions.requiredGuildFeatures ?? [])).toEqual(['VIP_VOICE']);
|
||||
expect(persisted?.isActive).toBe(false);
|
||||
});
|
||||
test('rejects a voice server soft connection limit below one', async () => {
|
||||
const admin = await createAdminWithAcls(harness, [AdminACLs.VOICE_REGION_CREATE, AdminACLs.VOICE_SERVER_CREATE]);
|
||||
const regionId = 'voice-region-soft-limit-invalid';
|
||||
await createBuilder<CreateVoiceRegionResponse>(harness, `${admin.token}`)
|
||||
.post('/admin/voice/regions')
|
||||
.body({
|
||||
id: regionId,
|
||||
name: `Region ${regionId}`,
|
||||
emoji: ':earth_americas:',
|
||||
latitude: 1,
|
||||
longitude: 2,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await createBuilder(harness, `${admin.token}`)
|
||||
.post(`/admin/voice/regions/${regionId}/servers`)
|
||||
.body({
|
||||
server_id: 'voice-server-soft-limit-invalid',
|
||||
endpoint: 'https://voice-soft-limit-invalid.example.com/socket',
|
||||
api_key: 'soft-limit-invalid-api-key',
|
||||
api_secret: 'soft-limit-invalid-api-secret',
|
||||
soft_connection_limit: 0,
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.INVALID_FORM_BODY)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,7 +209,7 @@ export async function forgotPassword(ctx: ApiContext, {data, request}: ForgotPas
|
||||
}
|
||||
const hasValidDns = await emailDnsValidation.hasValidDnsRecords(data.email);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
|
||||
}
|
||||
const user = await users.findByEmail(data.email);
|
||||
if (!user) {
|
||||
|
||||
@@ -187,7 +187,7 @@ export async function register(
|
||||
contactDomain = normalizePolicyContactDomain(extractEmailDomain(rawEmail));
|
||||
const hasValidDns = await emailDnsValidation.hasValidDnsRecords(rawEmail);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
|
||||
}
|
||||
contactDomainBlocked = accountPolicyEvaluator.isBlockedRegistrationEmailDomain(contactDomain);
|
||||
if (contactDomainBlocked) {
|
||||
|
||||
@@ -123,6 +123,7 @@ export function ChannelController(app: HonoApp) {
|
||||
const existing = await ctx.get('channelService').channelData.operations.getChannel({
|
||||
userId: ctx.get('user').id,
|
||||
channelId,
|
||||
skipNsfwValidation: true,
|
||||
});
|
||||
ctx.set('channelUpdateType', existing.type);
|
||||
return undefined;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {CannotSendMessageToNonTextChannelError} from '@fluxer/errors/src/domains/channel/CannotSendMessageToNonTextChannelError';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import {FileSizeTooLargeError} from '@fluxer/errors/src/domains/core/FileSizeTooLargeError';
|
||||
@@ -18,6 +19,7 @@ import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidat
|
||||
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
|
||||
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
|
||||
import {ServiceUnavailableError} from '@fluxer/errors/src/HttpErrors';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {
|
||||
CompleteMultipartAttachmentUploadItem,
|
||||
CompleteMultipartAttachmentUploadResult,
|
||||
@@ -26,7 +28,9 @@ import type {
|
||||
} from '@fluxer/schema/src/domains/message/AttachmentUploadSchemas';
|
||||
import type {AttachmentID, ChannelID, MessageID, UserID} from '../../BrandedTypes';
|
||||
import {Config} from '../../Config';
|
||||
import {SYSTEM_USER_ID} from '../../constants/Core';
|
||||
import type {IPurgeQueue} from '../../infrastructure/BunnyPurgeQueue';
|
||||
import type {IGatewayService} from '../../infrastructure/IGatewayService';
|
||||
import type {IStorageService} from '../../infrastructure/IStorageService';
|
||||
import type {LimitConfigService} from '../../limits/LimitConfigService';
|
||||
import {resolveLimitSafe} from '../../limits/LimitConfigUtils';
|
||||
@@ -64,6 +68,8 @@ interface DeleteAttachmentParams {
|
||||
requestCache: RequestCache;
|
||||
}
|
||||
|
||||
type UploadActor = 'member' | 'webhook';
|
||||
|
||||
interface UploadFormDataAttachmentsParams {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
@@ -76,6 +82,7 @@ interface UploadFormDataAttachmentsParams {
|
||||
id: number;
|
||||
filename: string;
|
||||
}>;
|
||||
actor?: UploadActor;
|
||||
}
|
||||
|
||||
interface RequestPresignedAttachmentUploadUrlsParams {
|
||||
@@ -104,6 +111,7 @@ export class AttachmentUploadService {
|
||||
private messageInteractionService: MessageInteractionService,
|
||||
private messageService: MessageService,
|
||||
private limitConfigService: LimitConfigService,
|
||||
private gatewayService: IGatewayService,
|
||||
) {}
|
||||
|
||||
async uploadFormDataAttachments({
|
||||
@@ -112,8 +120,9 @@ export class AttachmentUploadService {
|
||||
clientIp,
|
||||
files,
|
||||
attachmentMetadata,
|
||||
actor = 'member',
|
||||
}: UploadFormDataAttachmentsParams): Promise<Array<UploadedAttachment>> {
|
||||
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId});
|
||||
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId, actor});
|
||||
assertAttachmentFileSizesWithinLimit(
|
||||
files.map(({file}) => file.size),
|
||||
maxFileSize,
|
||||
@@ -168,7 +177,7 @@ export class AttachmentUploadService {
|
||||
if (!Config.presignedAttachmentUploadsEnabled) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId});
|
||||
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId, actor: 'member'});
|
||||
assertAttachmentFileSizesWithinLimit(
|
||||
attachments.map(({file_size}) => file_size),
|
||||
maxFileSize,
|
||||
@@ -275,7 +284,7 @@ export class AttachmentUploadService {
|
||||
if (!Config.presignedAttachmentUploadsEnabled) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId});
|
||||
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId, actor: 'member'});
|
||||
const bucket = Config.s3.buckets.uploads;
|
||||
return Promise.all(
|
||||
uploads.map(async ({upload_filename, upload_id}, index) => {
|
||||
@@ -412,21 +421,24 @@ export class AttachmentUploadService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getUploadPermissionAndLimit({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<{
|
||||
private async getUploadPermissionAndLimit({
|
||||
userId,
|
||||
channelId,
|
||||
actor,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
actor: UploadActor;
|
||||
}): Promise<{
|
||||
maxFileSize: number;
|
||||
}> {
|
||||
const {channel, guild, checkPermission, member} =
|
||||
await this.messageInteractionService.authService.getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
});
|
||||
const {channel, guild} =
|
||||
actor === 'webhook'
|
||||
? await this.getWebhookUploadChannel(channelId)
|
||||
: await this.getMemberUploadChannel({userId, channelId});
|
||||
if (!TEXT_BASED_CHANNEL_TYPES.has(channel.type)) {
|
||||
throw new CannotSendMessageToNonTextChannelError();
|
||||
}
|
||||
if (guild) {
|
||||
await checkPermission(Permissions.SEND_MESSAGES | Permissions.ATTACH_FILES);
|
||||
assertGuildMemberCanCommunicate(member);
|
||||
}
|
||||
const user = await this.userRepository.findUnique(userId);
|
||||
if (!user) {
|
||||
throw new UnknownUserError();
|
||||
@@ -439,6 +451,41 @@ export class AttachmentUploadService {
|
||||
const maxFileSize = user.isBot ? Math.min(resolvedMaxFileSize, ATTACHMENT_MAX_SIZE_BOT) : resolvedMaxFileSize;
|
||||
return {maxFileSize};
|
||||
}
|
||||
|
||||
private async getMemberUploadChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<{
|
||||
channel: Channel;
|
||||
guild: GuildResponse | null;
|
||||
}> {
|
||||
const {channel, guild, checkPermission, member} =
|
||||
await this.messageInteractionService.authService.getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
});
|
||||
if (guild) {
|
||||
await checkPermission(Permissions.SEND_MESSAGES | Permissions.ATTACH_FILES);
|
||||
assertGuildMemberCanCommunicate(member);
|
||||
}
|
||||
return {channel, guild};
|
||||
}
|
||||
|
||||
private async getWebhookUploadChannel(channelId: ChannelID): Promise<{
|
||||
channel: Channel;
|
||||
guild: GuildResponse | null;
|
||||
}> {
|
||||
const channel = await this.channelRepository.channelData.findUnique(channelId);
|
||||
if (!channel) {
|
||||
throw new UnknownChannelError();
|
||||
}
|
||||
if (!channel.guildId) {
|
||||
return {channel, guild: null};
|
||||
}
|
||||
const guild = await this.gatewayService.getGuildData({
|
||||
guildId: channel.guildId,
|
||||
userId: SYSTEM_USER_ID,
|
||||
skipMembershipCheck: true,
|
||||
});
|
||||
return {channel, guild};
|
||||
}
|
||||
}
|
||||
|
||||
async function mapWithConcurrency<T, TResult>(
|
||||
|
||||
@@ -111,7 +111,7 @@ export class ChannelDataService {
|
||||
clientFeatures: ReadonlySet<string>;
|
||||
requestCache: RequestCache;
|
||||
}): Promise<Channel> {
|
||||
const {channel} = await this.auth.getChannelAuthenticated({userId, channelId});
|
||||
const {channel} = await this.auth.getChannelAuthenticated({userId, channelId, skipNsfwValidation: true});
|
||||
if (channel.type === ChannelTypes.GROUP_DM) {
|
||||
return await this.groupDmUpdate.updateGroupDmChannel({
|
||||
userId,
|
||||
|
||||
@@ -162,6 +162,7 @@ export class ChannelService {
|
||||
this.interactions,
|
||||
this.messages,
|
||||
limitConfigService,
|
||||
gatewayService,
|
||||
);
|
||||
this.groupDms = new GroupDmOperationsService(
|
||||
channelRepository,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
GUILD_TEXT_BASED_CHANNEL_TYPES,
|
||||
Permissions,
|
||||
} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ContentWarningLevel, GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ContentWarningLevel, clampVoiceChannelBitrate, GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {MAX_CHANNELS_PER_CATEGORY} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {InvalidChannelTypeError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeError';
|
||||
@@ -90,8 +90,20 @@ export class ChannelOperationsService {
|
||||
private rateLimitService: IRateLimitService,
|
||||
) {}
|
||||
|
||||
async getChannel({userId, channelId}: {userId: UserID; channelId: ChannelID}): Promise<Channel> {
|
||||
const {channel} = await this.channelAuthService.getChannelAuthenticated({userId, channelId});
|
||||
async getChannel({
|
||||
userId,
|
||||
channelId,
|
||||
skipNsfwValidation,
|
||||
}: {
|
||||
userId: UserID;
|
||||
channelId: ChannelID;
|
||||
skipNsfwValidation?: boolean;
|
||||
}): Promise<Channel> {
|
||||
const {channel} = await this.channelAuthService.getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
skipNsfwValidation,
|
||||
});
|
||||
return channel;
|
||||
}
|
||||
|
||||
@@ -127,6 +139,7 @@ export class ChannelOperationsService {
|
||||
const {channel, guild, checkPermission} = await this.channelAuthService.getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
skipNsfwValidation: true,
|
||||
});
|
||||
if (channel.type === ChannelTypes.GROUP_DM) {
|
||||
throw new InvalidChannelTypeError();
|
||||
@@ -247,13 +260,17 @@ export class ChannelOperationsService {
|
||||
validateCapacity: requestedParentId !== null && requestedParentId !== (channel.parentId ?? null),
|
||||
});
|
||||
}
|
||||
let nextBitrate = channel.bitrate;
|
||||
if (data.bitrate !== undefined && channel.type === ChannelTypes.GUILD_VOICE) {
|
||||
nextBitrate = data.bitrate === null ? null : clampVoiceChannelBitrate(data.bitrate, guild.features ?? []);
|
||||
}
|
||||
const updatedChannelData = {
|
||||
...channel.toRow(),
|
||||
name: channelName,
|
||||
topic: data.topic !== undefined ? data.topic : channel.topic,
|
||||
url: data.url !== undefined && channel.type === ChannelTypes.GUILD_LINK ? data.url : channel.url,
|
||||
parent_id: requestedParentId,
|
||||
bitrate: data.bitrate !== undefined && channel.type === ChannelTypes.GUILD_VOICE ? data.bitrate : channel.bitrate,
|
||||
bitrate: nextBitrate,
|
||||
user_limit:
|
||||
data.user_limit !== undefined && channel.type === ChannelTypes.GUILD_VOICE
|
||||
? data.user_limit
|
||||
@@ -456,7 +473,11 @@ export class ChannelOperationsService {
|
||||
if (this.voiceAvailabilityService === null) {
|
||||
return [];
|
||||
}
|
||||
const {channel, guild} = await this.channelAuthService.getChannelAuthenticated({userId, channelId});
|
||||
const {channel, guild} = await this.channelAuthService.getChannelAuthenticated({
|
||||
userId,
|
||||
channelId,
|
||||
skipNsfwValidation: true,
|
||||
});
|
||||
if (channel.type !== ChannelTypes.GUILD_VOICE) {
|
||||
throw new InvalidChannelTypeError();
|
||||
}
|
||||
|
||||
@@ -308,7 +308,7 @@ export async function createMessageSnapshotsForForward(
|
||||
return [new MessageSnapshotModel(snapshotData)];
|
||||
}
|
||||
|
||||
function collectEmbedReferencedAttachmentCdnKeys(message: Message): Array<string> {
|
||||
function collectEmbedReferencedAttachmentCdnKeys(message: Message, ownKeys: ReadonlySet<string>): Array<string> {
|
||||
const mediaPrefix = `${Config.endpoints.media}/`;
|
||||
const keys = new Set<string>();
|
||||
const consider = (url: string | null | undefined): void => {
|
||||
@@ -316,7 +316,7 @@ function collectEmbedReferencedAttachmentCdnKeys(message: Message): Array<string
|
||||
return;
|
||||
}
|
||||
const key = url.slice(mediaPrefix.length);
|
||||
if (key.startsWith('attachments/')) {
|
||||
if (ownKeys.has(key)) {
|
||||
keys.add(key);
|
||||
}
|
||||
};
|
||||
@@ -342,6 +342,11 @@ export async function purgeMessageAttachments(
|
||||
): Promise<void> {
|
||||
const cdnKeys = new Set<string>();
|
||||
const cdnUrls: Array<string> = [];
|
||||
const ownedCdnKeys = new Set<string>(
|
||||
collectMessageAttachments(message).map((attachment) =>
|
||||
makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename),
|
||||
),
|
||||
);
|
||||
for (const attachment of collectMessageAttachments(message)) {
|
||||
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
|
||||
if (cdnKeys.has(cdnKey)) {
|
||||
@@ -352,7 +357,7 @@ export async function purgeMessageAttachments(
|
||||
cdnUrls.push(makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename));
|
||||
}
|
||||
}
|
||||
for (const embedKey of collectEmbedReferencedAttachmentCdnKeys(message)) {
|
||||
for (const embedKey of collectEmbedReferencedAttachmentCdnKeys(message, ownedCdnKeys)) {
|
||||
if (cdnKeys.has(embedKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ type AttachmentMetadata = ClientAttachmentRequest | ClientUploadedAttachmentRequ
|
||||
|
||||
interface ParseMultipartMessageDataOptions {
|
||||
onPayloadParsed?: (payload: unknown) => void;
|
||||
actor?: 'member' | 'webhook';
|
||||
}
|
||||
|
||||
export async function parseMultipartMessageData(
|
||||
@@ -158,6 +159,7 @@ export async function parseMultipartMessageData(
|
||||
clientIp,
|
||||
files: filesWithIndices,
|
||||
attachmentMetadata: inlineNewAttachments,
|
||||
actor: options?.actor,
|
||||
});
|
||||
const uploadedMap = new Map(uploadedAttachments.map((attachment) => [attachment.id, attachment]));
|
||||
const processedInlineAttachments = inlineNewAttachments.map((clientData) => {
|
||||
|
||||
@@ -4,11 +4,10 @@ import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants'
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import type {MessageSearchRequest} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
import type {MessageResponse, MessageSearchResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {MessageSearchResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import {AttachmentDecayService} from '../../../attachment/AttachmentDecayService';
|
||||
import type {AttachmentID, ChannelID, MessageID, UserID} from '../../../BrandedTypes';
|
||||
import {createChannelID, createMessageID} from '../../../BrandedTypes';
|
||||
import type {UserCacheService} from '../../../infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '../../../middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '../../../models/Channel';
|
||||
@@ -192,29 +191,19 @@ export class MessageRetrievalService {
|
||||
hitsPerPage,
|
||||
page,
|
||||
});
|
||||
const messageEntries = result.hits.map((hit) => ({
|
||||
channelId: createChannelID(BigInt(hit.channelId)),
|
||||
messageId: createMessageID(BigInt(hit.id)),
|
||||
}));
|
||||
const access = {
|
||||
sourceGuildId: channel.guildId,
|
||||
messageHistoryCutoff: !hasReadHistory ? (authChannel.guild?.message_history_cutoff ?? null) : null,
|
||||
canReadMessageHistory: hasReadHistory,
|
||||
};
|
||||
const responseDataService = createMessageResponseDataService();
|
||||
const foundMessages = await Promise.all(
|
||||
messageEntries.map(({channelId, messageId}) =>
|
||||
responseDataService.getMessage({
|
||||
userId,
|
||||
channelId,
|
||||
messageId,
|
||||
access,
|
||||
}),
|
||||
),
|
||||
const builtMessages = await createMessageResponseDataService().buildMessages({
|
||||
userId,
|
||||
messages: result.messages,
|
||||
access,
|
||||
});
|
||||
const messageResponses = builtMessages.map(
|
||||
({referenced_message: _referencedMessage, ...searchMessage}) => searchMessage,
|
||||
);
|
||||
const messageResponses = foundMessages
|
||||
.filter((message): message is MessageResponse => message !== null)
|
||||
.map(({referenced_message: _referencedMessage, ...searchMessage}) => searchMessage);
|
||||
return {
|
||||
channels: messageResponses.length > 0 ? [await this.mapSearchChannelResponse(channel, userId, requestCache)] : [],
|
||||
messages: messageResponses,
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
SENDABLE_MESSAGE_FLAGS,
|
||||
} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildNSFWLevel, GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import {RelationshipTypes, SensitiveMediaFilterLevel, UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {
|
||||
DELETED_USER_ID,
|
||||
RelationshipTypes,
|
||||
SensitiveMediaFilterLevel,
|
||||
UserFlags,
|
||||
} from '@fluxer/constants/src/UserConstants';
|
||||
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
|
||||
@@ -211,15 +216,18 @@ export class MessageSendService {
|
||||
return processed.length > 0 ? processed : undefined;
|
||||
}
|
||||
|
||||
private resolveWebhookAttachmentUploadUserId(
|
||||
private async resolveWebhookAttachmentUploadUserId(
|
||||
webhook: Webhook,
|
||||
attachments?: Array<AttachmentRequestData>,
|
||||
): UserID | undefined {
|
||||
const uploadUserId = webhook.creatorId ?? undefined;
|
||||
if (uploadUserId === undefined && this.attachmentsToProcess(attachments) !== undefined) {
|
||||
throw InputValidationError.fromCode('attachments', ValidationErrorCodes.INVALID_MESSAGE_DATA);
|
||||
): Promise<UserID | undefined> {
|
||||
if (this.attachmentsToProcess(attachments) === undefined) {
|
||||
return webhook.creatorId ?? undefined;
|
||||
}
|
||||
return uploadUserId;
|
||||
if (!webhook.creatorId) {
|
||||
return createUserID(DELETED_USER_ID);
|
||||
}
|
||||
const creator = await this.deps.userRepository.findUnique(webhook.creatorId);
|
||||
return creator ? webhook.creatorId : createUserID(DELETED_USER_ID);
|
||||
}
|
||||
|
||||
private getOneToOneDmRecipientId(channel: Channel, senderId: UserID): UserID | null {
|
||||
@@ -1184,7 +1192,7 @@ export class MessageSendService {
|
||||
flags: this.deps.validationService.calculateMessageFlags(data),
|
||||
embeds: data.embeds,
|
||||
attachments: this.attachmentsToProcess(data.attachments),
|
||||
attachmentUploadUserId: this.resolveWebhookAttachmentUploadUserId(webhook, data.attachments),
|
||||
attachmentUploadUserId: await this.resolveWebhookAttachmentUploadUserId(webhook, data.attachments),
|
||||
stickerIds: data.sticker_ids ? data.sticker_ids.flatMap((stickerId) => createStickerID(stickerId)) : undefined,
|
||||
messageReference,
|
||||
messageSnapshots,
|
||||
@@ -1269,7 +1277,7 @@ export class MessageSendService {
|
||||
data,
|
||||
channel,
|
||||
guild,
|
||||
attachmentUploadUserId: this.resolveWebhookAttachmentUploadUserId(webhook, data.attachments),
|
||||
attachmentUploadUserId: await this.resolveWebhookAttachmentUploadUserId(webhook, data.attachments),
|
||||
allowEmbeds: true,
|
||||
});
|
||||
await this.deps.dispatchService.dispatchMessageUpdate({channel, message: updatedMessage, requestCache});
|
||||
|
||||
@@ -46,6 +46,18 @@ describe('Channel Operation Permissions', () => {
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
it('should let a minor manage a mature channel without reading it', async () => {
|
||||
const owner = await createTestAccount(harness, {dateOfBirth: '2010-01-01'});
|
||||
const guild = await createGuild(harness, owner.token, 'Mature Channel Guild');
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
await updateChannel(harness, owner.token, systemChannel.id, {nsfw: true});
|
||||
const renamed = await updateChannel(harness, owner.token, systemChannel.id, {name: 'still-manageable'});
|
||||
expect(renamed.name).toBe('still-manageable');
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/channels/${systemChannel.id}/messages`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN)
|
||||
.execute();
|
||||
});
|
||||
it('should reject member from updating channel without MANAGE_CHANNELS', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
@@ -226,17 +226,29 @@ export interface APIConfig {
|
||||
monthlyUsd?: string;
|
||||
monthlyEur?: string;
|
||||
monthlyBrl?: string;
|
||||
monthlyDkk?: string;
|
||||
monthlyInr?: string;
|
||||
monthlyNok?: string;
|
||||
monthlyPln?: string;
|
||||
monthlySek?: string;
|
||||
monthlyTry?: string;
|
||||
yearlyUsd?: string;
|
||||
yearlyEur?: string;
|
||||
yearlyBrl?: string;
|
||||
yearlyDkk?: string;
|
||||
yearlyInr?: string;
|
||||
yearlyNok?: string;
|
||||
yearlyPln?: string;
|
||||
yearlySek?: string;
|
||||
yearlyTry?: string;
|
||||
gift1MonthUsd?: string;
|
||||
gift1MonthEur?: string;
|
||||
gift1MonthSek?: string;
|
||||
gift1YearSek?: string;
|
||||
gift1MonthDkk?: string;
|
||||
gift1YearDkk?: string;
|
||||
gift1MonthNok?: string;
|
||||
gift1YearNok?: string;
|
||||
gift1MonthBrl?: string;
|
||||
gift1MonthInr?: string;
|
||||
gift1MonthPln?: string;
|
||||
@@ -248,6 +260,7 @@ export interface APIConfig {
|
||||
gift1YearPln?: string;
|
||||
gift1YearTry?: string;
|
||||
};
|
||||
legacyPrices?: Record<string, Array<string> | undefined>;
|
||||
};
|
||||
bunny: {
|
||||
purgeEnabled: boolean;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createUserID} from '../BrandedTypes';
|
||||
import {DELETED_USER_ID} from '@fluxer/constants/src/UserConstants';
|
||||
import {createUserID, type UserID} from '../BrandedTypes';
|
||||
|
||||
export const SYSTEM_USER_ID = createUserID(0n);
|
||||
|
||||
export function isSyntheticUserId(userId: UserID): boolean {
|
||||
return userId === SYSTEM_USER_ID || userId === DELETED_USER_ID;
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface VoiceServerRow {
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
is_active: boolean | null;
|
||||
soft_connection_limit: number | null;
|
||||
vip_only: boolean | null;
|
||||
required_guild_features: Set<string> | null;
|
||||
allowed_guild_ids: Set<bigint> | null;
|
||||
@@ -56,6 +57,7 @@ export const VOICE_SERVER_COLUMNS = [
|
||||
'latitude',
|
||||
'longitude',
|
||||
'is_active',
|
||||
'soft_connection_limit',
|
||||
'vip_only',
|
||||
'required_guild_features',
|
||||
'allowed_guild_ids',
|
||||
|
||||
@@ -40,7 +40,7 @@ export class DonationCheckoutService {
|
||||
}
|
||||
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(params.email);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
|
||||
}
|
||||
const isRecurring = params.interval !== null;
|
||||
const existingDonor = await this.donationRepository.findDonorByEmail(params.email);
|
||||
|
||||
@@ -24,7 +24,7 @@ export class DonationMagicLinkService {
|
||||
async sendMagicLink(email: string): Promise<void> {
|
||||
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(email);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
|
||||
}
|
||||
const donor = await this.donationRepository.findDonorByEmail(email);
|
||||
if (!donor) {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
StorageObjectRangeNotSatisfiableError,
|
||||
} from '../infrastructure/IStorageService';
|
||||
import {Logger} from '../Logger';
|
||||
import {isJsonRecord, parseJsonRecord, parseJsonUnknown} from '../utils/JsonBoundaryUtils';
|
||||
import {isJsonRecord, parseJsonRecord} from '../utils/JsonBoundaryUtils';
|
||||
import {
|
||||
parseDesktopArtifactScope,
|
||||
parseDesktopReleaseDescriptor,
|
||||
@@ -58,6 +58,7 @@ function isUnsatisfiableRangeError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
const MAX_DESKTOP_OBJECTS_PER_PREFIX = 10_000;
|
||||
const MAX_DESKTOP_RELEASE_CANDIDATES = 10;
|
||||
const DESKTOP_BUCKET_PREFIX = 'desktop';
|
||||
const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test';
|
||||
const DOWNLOAD_KEY_ALLOWED_PREFIXES = [`${DESKTOP_BUCKET_PREFIX}/`, `${DESKTOP_TEST_BUCKET_PREFIX}/`];
|
||||
@@ -198,6 +199,14 @@ export type GitHubDesktopReleaseResolution =
|
||||
| {kind: 'awaiting_release'}
|
||||
| {kind: 'ready'; location: string};
|
||||
|
||||
type DesktopReleaseState = {kind: 'untracked'} | {kind: 'unpublished'} | {kind: 'published'; descriptorText: string};
|
||||
|
||||
type ListedDesktopVersion = {
|
||||
version: string;
|
||||
pub_date: Date;
|
||||
files: Map<DesktopFormat, {filename: string; sha256Key: string | null}>;
|
||||
};
|
||||
|
||||
export class DownloadService {
|
||||
constructor(private readonly storageService: IStorageService) {}
|
||||
|
||||
@@ -216,41 +225,28 @@ export class DownloadService {
|
||||
) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`;
|
||||
const descriptorText = await this.readOptionalTextFromStorage(descriptorKey);
|
||||
if (descriptorText == null) {
|
||||
const release = await this.readDesktopReleaseState(scope.channel, manifest.version);
|
||||
if (release.kind === 'untracked') {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const descriptor = parseDesktopReleaseDescriptor(parseJsonUnknown(descriptorText));
|
||||
if (release.kind === 'unpublished') {
|
||||
return {kind: 'awaiting_release'};
|
||||
}
|
||||
const descriptor = parseDesktopReleaseDescriptor(parseJsonRecord(release.descriptorText));
|
||||
if (
|
||||
!descriptor ||
|
||||
descriptor.channel !== scope.channel ||
|
||||
descriptor.version !== manifest.version ||
|
||||
descriptor.release_tag !== `fluxer-desktop-${scope.channel}@${manifest.version}`
|
||||
) {
|
||||
throw new Error(`Invalid GitHub desktop release descriptor: ${descriptorKey}`);
|
||||
throw new Error(
|
||||
`Invalid GitHub desktop release descriptor: ${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.json`,
|
||||
);
|
||||
}
|
||||
const releaseAsset = descriptor.assets.find((asset) => asset.storage_key === key);
|
||||
if (!releaseAsset) {
|
||||
return {kind: 'not_current'};
|
||||
}
|
||||
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${manifest.version}.ready.json`;
|
||||
const marker = await this.readOptionalJsonObjectFromStorage(markerKey);
|
||||
if (marker == null) {
|
||||
return {kind: 'awaiting_release'};
|
||||
}
|
||||
const readiness = parseDesktopReleaseReadiness(marker);
|
||||
const descriptorSha256 = createHash('sha256').update(descriptorText).digest('hex');
|
||||
if (
|
||||
!readiness ||
|
||||
readiness.channel !== descriptor.channel ||
|
||||
readiness.version !== descriptor.version ||
|
||||
readiness.release_tag !== descriptor.release_tag ||
|
||||
readiness.source_sha !== descriptor.source_sha ||
|
||||
readiness.descriptor_sha256 !== descriptorSha256
|
||||
) {
|
||||
throw new Error(`Invalid GitHub desktop release readiness marker: ${markerKey}`);
|
||||
}
|
||||
return {
|
||||
kind: 'ready',
|
||||
location: `${GITHUB_RELEASE_DOWNLOAD_BASE_URL}/${encodeURIComponent(descriptor.release_tag)}/${encodeURIComponent(releaseAsset.release_asset)}`,
|
||||
@@ -269,18 +265,22 @@ export class DownloadService {
|
||||
return null;
|
||||
}
|
||||
const manifestKey = `${prefix}/manifest.json`;
|
||||
const releasability = new Map<string, boolean>();
|
||||
try {
|
||||
const manifest = await this.readJsonObjectFromStorage(manifestKey);
|
||||
if (!isDesktopManifest(manifest)) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
if (
|
||||
!isDesktopManifest(manifest) ||
|
||||
!(await this.isReleasableDesktopVersion(params, manifest.version, releasability))
|
||||
) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
const entry = manifest.files[params.format];
|
||||
if (!entry) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
const filename = this.extractFilename(entry);
|
||||
if (filename.trim().length === 0) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
const resolvedFilename = await this.resolveManifestFilename({
|
||||
channel: params.channel,
|
||||
@@ -291,7 +291,11 @@ export class DownloadService {
|
||||
test: params.test,
|
||||
});
|
||||
if (!resolvedFilename) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
const parsed = this.parseVersionFromFilename(resolvedFilename, params.channel, params.plat, params.arch);
|
||||
if (parsed && parsed.version !== manifest.version) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
return this.buildDesktopArtifactKey({
|
||||
channel: params.channel,
|
||||
@@ -302,7 +306,7 @@ export class DownloadService {
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
|
||||
return this.resolveLatestDesktopKeyFromObjects(params);
|
||||
return this.resolveLatestDesktopKeyFromObjects(params, releasability);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -320,10 +324,14 @@ export class DownloadService {
|
||||
return null;
|
||||
}
|
||||
const manifestKey = `${prefix}/manifest.json`;
|
||||
const releasability = new Map<string, boolean>();
|
||||
try {
|
||||
const manifest = await this.readJsonObjectFromStorage(manifestKey);
|
||||
if (!isDesktopManifest(manifest)) {
|
||||
return this.getLatestDesktopVersionFromObjects(params);
|
||||
if (
|
||||
!isDesktopManifest(manifest) ||
|
||||
!(await this.isReleasableDesktopVersion(params, manifest.version, releasability))
|
||||
) {
|
||||
return this.getLatestDesktopVersionFromObjects(params, releasability);
|
||||
}
|
||||
const result = await this.getLatestDesktopVersionFromManifest(params, manifest);
|
||||
if (result) {
|
||||
@@ -331,11 +339,11 @@ export class DownloadService {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
|
||||
return this.getLatestDesktopVersionFromObjects(params);
|
||||
return this.getLatestDesktopVersionFromObjects(params, releasability);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return this.getLatestDesktopVersionFromObjects(params);
|
||||
return this.getLatestDesktopVersionFromObjects(params, releasability);
|
||||
}
|
||||
|
||||
async listDesktopVersions(params: {
|
||||
@@ -351,158 +359,17 @@ export class DownloadService {
|
||||
versions: Array<VersionInfo>;
|
||||
hasMore: boolean;
|
||||
}> {
|
||||
const basePrefix = desktopArtifactPrefix(params);
|
||||
if (!basePrefix) {
|
||||
return {versions: [], hasMore: false};
|
||||
let listedVersions = await this.listDesktopVersionFiles(params);
|
||||
if (params.before) {
|
||||
listedVersions = listedVersions.filter((entry) => this.compareVersions(entry.version, params.before ?? '') > 0);
|
||||
}
|
||||
const prefix = `${basePrefix}/`;
|
||||
try {
|
||||
const objects = await this.listDesktopArtifacts(prefix);
|
||||
if (objects.length === 0) {
|
||||
return {versions: [], hasMore: false};
|
||||
}
|
||||
const versionMap = new Map<
|
||||
string,
|
||||
{
|
||||
pub_date: Date;
|
||||
files: Map<
|
||||
DesktopFormat,
|
||||
{
|
||||
filename: string;
|
||||
sha256Key: string | null;
|
||||
}
|
||||
>;
|
||||
}
|
||||
>();
|
||||
const sha256Files = new Set<string>();
|
||||
for (const obj of objects) {
|
||||
if (obj.key.endsWith('.sha256')) {
|
||||
sha256Files.add(obj.key);
|
||||
}
|
||||
}
|
||||
for (const obj of objects) {
|
||||
const filename = obj.key.slice(prefix.length);
|
||||
if (filename.includes('/') || filename.endsWith('.sha256') || filename === 'manifest.json') {
|
||||
continue;
|
||||
}
|
||||
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const {version, format} = parsed;
|
||||
const sha256Key = sha256Files.has(`${obj.key}.sha256`) ? `${obj.key}.sha256` : null;
|
||||
if (!versionMap.has(version)) {
|
||||
versionMap.set(version, {
|
||||
pub_date: obj.lastModified ?? new Date(),
|
||||
files: new Map(),
|
||||
});
|
||||
}
|
||||
const entry = versionMap.get(version);
|
||||
if (entry) {
|
||||
if (!entry.files.has(format)) {
|
||||
entry.files.set(format, {filename, sha256Key});
|
||||
}
|
||||
if (obj.lastModified && obj.lastModified > entry.pub_date) {
|
||||
entry.pub_date = obj.lastModified;
|
||||
}
|
||||
}
|
||||
}
|
||||
const sortedVersions = Array.from(versionMap.keys()).sort(this.compareVersions);
|
||||
let filteredVersions = sortedVersions;
|
||||
if (params.before) {
|
||||
filteredVersions = filteredVersions.filter((v) => this.compareVersions(v, params.before ?? '') > 0);
|
||||
}
|
||||
if (params.after) {
|
||||
filteredVersions = filteredVersions.filter((v) => this.compareVersions(v, params.after ?? '') < 0);
|
||||
}
|
||||
const hasMore = filteredVersions.length > params.limit;
|
||||
const paginatedVersions = filteredVersions.slice(0, params.limit);
|
||||
const sha256Promises: Array<
|
||||
Promise<{
|
||||
key: string;
|
||||
hash: string | null;
|
||||
}>
|
||||
> = [];
|
||||
for (const version of paginatedVersions) {
|
||||
const entry = versionMap.get(version);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
for (const [, fileInfo] of entry.files) {
|
||||
if (fileInfo.sha256Key) {
|
||||
sha256Promises.push(
|
||||
(async () => {
|
||||
try {
|
||||
const streamResult = await this.storageService.streamObject({
|
||||
bucket: Config.s3.buckets.downloads,
|
||||
key: fileInfo.sha256Key as string,
|
||||
});
|
||||
if (streamResult) {
|
||||
const body = Readable.toWeb(streamResult.body);
|
||||
const text = await new Response(body as ReadableStream).text();
|
||||
return {key: fileInfo.sha256Key as string, hash: text.trim().split(/\s+/u)[0]};
|
||||
}
|
||||
} catch {
|
||||
return {key: fileInfo.sha256Key as string, hash: null};
|
||||
}
|
||||
return {key: fileInfo.sha256Key as string, hash: null};
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
const sha256Results = await Promise.all(sha256Promises);
|
||||
const sha256Map = new Map<string, string | null>();
|
||||
for (const result of sha256Results) {
|
||||
sha256Map.set(result.key, result.hash);
|
||||
}
|
||||
const versions: Array<VersionInfo> = [];
|
||||
for (const version of paginatedVersions) {
|
||||
const entry = versionMap.get(version);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const files: Record<string, VersionFile> = {};
|
||||
for (const [format, fileInfo] of entry.files) {
|
||||
const sha256 = fileInfo.sha256Key ? (sha256Map.get(fileInfo.sha256Key) ?? null) : null;
|
||||
const validSha256 = sha256 && this.isValidSha256(sha256) ? sha256 : null;
|
||||
files[format] = {
|
||||
url: this.buildDesktopVersionUrl({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
version,
|
||||
format,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
}),
|
||||
sha256: validSha256,
|
||||
checksum_url: validSha256
|
||||
? this.buildDesktopVersionChecksumUrl({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
version,
|
||||
format,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
versions.push({
|
||||
version,
|
||||
pub_date: entry.pub_date.toISOString(),
|
||||
files,
|
||||
});
|
||||
}
|
||||
return {versions, hasMore};
|
||||
} catch (error) {
|
||||
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
|
||||
return {versions: [], hasMore: false};
|
||||
}
|
||||
throw error;
|
||||
if (params.after) {
|
||||
listedVersions = listedVersions.filter((entry) => this.compareVersions(entry.version, params.after ?? '') < 0);
|
||||
}
|
||||
return {
|
||||
versions: await this.buildListedDesktopVersions(params, listedVersions.slice(0, params.limit)),
|
||||
hasMore: listedVersions.length > params.limit,
|
||||
};
|
||||
}
|
||||
|
||||
async resolveVersionedDesktopKey(params: {
|
||||
@@ -558,10 +425,10 @@ export class DownloadService {
|
||||
}): Promise<DesktopChecksumFile | null> {
|
||||
const version = await this.getLatestDesktopVersion(params);
|
||||
const file = version?.files[params.format];
|
||||
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
|
||||
if (!version || !file?.sha256 || !this.isValidSha256(file.sha256)) {
|
||||
return null;
|
||||
}
|
||||
const key = await this.resolveLatestDesktopKey(params);
|
||||
const key = await this.resolveVersionedDesktopKey({...params, version: version.version});
|
||||
if (!key) {
|
||||
return null;
|
||||
}
|
||||
@@ -586,8 +453,16 @@ export class DownloadService {
|
||||
if (objectSha256) {
|
||||
return this.buildDesktopChecksumFile(key, filename, objectSha256);
|
||||
}
|
||||
const latest = await this.getLatestDesktopVersion(params);
|
||||
const file = latest?.version === params.version ? latest.files[params.format] : undefined;
|
||||
const prefix = desktopArtifactPrefix(params);
|
||||
if (!prefix) {
|
||||
return null;
|
||||
}
|
||||
const manifest = await this.readOptionalJsonObjectFromStorage(`${prefix}/manifest.json`);
|
||||
const versionInfo =
|
||||
isDesktopManifest(manifest) && manifest.version === params.version
|
||||
? await this.getLatestDesktopVersionFromManifest(params, manifest)
|
||||
: null;
|
||||
const file = versionInfo?.files[params.format];
|
||||
if (!file?.sha256 || !this.isValidSha256(file.sha256)) {
|
||||
return null;
|
||||
}
|
||||
@@ -768,6 +643,77 @@ export class DownloadService {
|
||||
}
|
||||
}
|
||||
|
||||
private async readDesktopReleaseState(channel: DesktopChannel, version: string): Promise<DesktopReleaseState> {
|
||||
const descriptorKey = `${DESKTOP_BUCKET_PREFIX}/${channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${version}.json`;
|
||||
const markerKey = `${DESKTOP_BUCKET_PREFIX}/${channel}/${GITHUB_RELEASE_MARKER_DIRECTORY}/${version}.ready.json`;
|
||||
const [descriptorText, markerText] = await Promise.all([
|
||||
this.readOptionalTextFromStorage(descriptorKey),
|
||||
this.readOptionalTextFromStorage(markerKey),
|
||||
]);
|
||||
if (descriptorText == null) {
|
||||
return {kind: 'untracked'};
|
||||
}
|
||||
if (markerText == null) {
|
||||
return {kind: 'unpublished'};
|
||||
}
|
||||
const readiness = parseDesktopReleaseReadiness(parseJsonRecord(markerText));
|
||||
if (
|
||||
!readiness ||
|
||||
readiness.channel !== channel ||
|
||||
readiness.version !== version ||
|
||||
readiness.release_tag !== `fluxer-desktop-${channel}@${version}` ||
|
||||
readiness.descriptor_sha256 !== createHash('sha256').update(descriptorText).digest('hex')
|
||||
) {
|
||||
Logger.error({key: markerKey}, 'Invalid GitHub desktop release readiness marker');
|
||||
return {kind: 'unpublished'};
|
||||
}
|
||||
return {kind: 'published', descriptorText};
|
||||
}
|
||||
|
||||
private async isReleasableDesktopVersion(
|
||||
params: {channel: DesktopChannel; test?: boolean},
|
||||
version: string,
|
||||
releasability: Map<string, boolean>,
|
||||
): Promise<boolean> {
|
||||
if (params.test || Config.instance.selfHosted) {
|
||||
return true;
|
||||
}
|
||||
const checked = releasability.get(version);
|
||||
if (checked !== undefined) {
|
||||
return checked;
|
||||
}
|
||||
let releasable = true;
|
||||
try {
|
||||
releasable = (await this.readDesktopReleaseState(params.channel, version)).kind !== 'unpublished';
|
||||
} catch (error) {
|
||||
Logger.error({error, channel: params.channel, version}, 'Failed to read desktop release readiness');
|
||||
}
|
||||
releasability.set(version, releasable);
|
||||
return releasable;
|
||||
}
|
||||
|
||||
private async findNewestReleasableDesktopVersion<T extends {version: string}>(
|
||||
params: {channel: DesktopChannel; test?: boolean},
|
||||
candidates: ReadonlyArray<T>,
|
||||
releasability: Map<string, boolean>,
|
||||
): Promise<T | null> {
|
||||
const newestCandidates = candidates.slice(0, MAX_DESKTOP_RELEASE_CANDIDATES);
|
||||
for (const candidate of newestCandidates) {
|
||||
if (await this.isReleasableDesktopVersion(params, candidate.version, releasability)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
const [newest] = newestCandidates;
|
||||
if (!newest) {
|
||||
return null;
|
||||
}
|
||||
Logger.error(
|
||||
{channel: params.channel, version: newest.version},
|
||||
'No recent desktop version has a published release',
|
||||
);
|
||||
return newest;
|
||||
}
|
||||
|
||||
private isValidSha256(value: string): boolean {
|
||||
return /^[a-f0-9]{64}$/u.test(value);
|
||||
}
|
||||
@@ -789,7 +735,13 @@ export class DownloadService {
|
||||
) {
|
||||
return manifestFilename;
|
||||
}
|
||||
return this.findLatestFilenameForRequestedArch(params);
|
||||
for (const entry of await this.listDesktopVersionFiles(params)) {
|
||||
const file = entry.files.get(params.format);
|
||||
if (file) {
|
||||
return file.filename;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async listDesktopArtifacts(prefix: string): Promise<ReadonlyArray<{key: string; lastModified?: Date}>> {
|
||||
@@ -826,44 +778,145 @@ export class DownloadService {
|
||||
return params.filename.toLowerCase().endsWith('.exe');
|
||||
}
|
||||
|
||||
private async findLatestFilenameForRequestedArch(params: LatestFilenameLookupParams): Promise<string | null> {
|
||||
private async listDesktopVersionFiles(params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
arch: DesktopArch;
|
||||
test?: boolean;
|
||||
}): Promise<Array<ListedDesktopVersion>> {
|
||||
const basePrefix = desktopArtifactPrefix(params);
|
||||
if (!basePrefix) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
const prefix = `${basePrefix}/`;
|
||||
const objects = await this.listDesktopArtifacts(prefix);
|
||||
if (objects.length === 0) {
|
||||
return null;
|
||||
try {
|
||||
const objects = await this.listDesktopArtifacts(prefix);
|
||||
const versionMap = new Map<string, ListedDesktopVersion>();
|
||||
const sha256Files = new Set<string>();
|
||||
for (const obj of objects) {
|
||||
if (obj.key.endsWith('.sha256')) {
|
||||
sha256Files.add(obj.key);
|
||||
}
|
||||
}
|
||||
for (const obj of objects) {
|
||||
const filename = obj.key.slice(prefix.length);
|
||||
if (filename.includes('/') || filename.endsWith('.sha256') || filename === 'manifest.json') {
|
||||
continue;
|
||||
}
|
||||
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
|
||||
if (!parsed) {
|
||||
continue;
|
||||
}
|
||||
const {version, format} = parsed;
|
||||
const sha256Key = sha256Files.has(`${obj.key}.sha256`) ? `${obj.key}.sha256` : null;
|
||||
if (!versionMap.has(version)) {
|
||||
versionMap.set(version, {
|
||||
version,
|
||||
pub_date: obj.lastModified ?? new Date(),
|
||||
files: new Map(),
|
||||
});
|
||||
}
|
||||
const entry = versionMap.get(version);
|
||||
if (entry) {
|
||||
if (!entry.files.has(format)) {
|
||||
entry.files.set(format, {filename, sha256Key});
|
||||
}
|
||||
if (obj.lastModified && obj.lastModified > entry.pub_date) {
|
||||
entry.pub_date = obj.lastModified;
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(versionMap.values()).sort((left, right) => this.compareVersions(left.version, right.version));
|
||||
} catch (error) {
|
||||
if (error instanceof S3ServiceException && (error.name === 'NoSuchKey' || error.name === 'NotFound')) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
let latestFilename: string | null = null;
|
||||
let latestVersion: string | null = null;
|
||||
for (const obj of objects) {
|
||||
const filename = obj.key.slice(prefix.length);
|
||||
if (filename.length === 0) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
filename.includes('/') ||
|
||||
filename.endsWith('.sha256') ||
|
||||
filename.endsWith('.blockmap') ||
|
||||
filename.endsWith('.yml') ||
|
||||
filename === 'manifest.json' ||
|
||||
filename === 'RELEASES.json' ||
|
||||
filename === 'releases.json'
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const parsed = this.parseVersionFromFilename(filename, params.channel, params.plat, params.arch);
|
||||
if (!parsed || parsed.format !== params.format) {
|
||||
continue;
|
||||
}
|
||||
if (!latestVersion || this.compareVersions(parsed.version, latestVersion) < 0) {
|
||||
latestVersion = parsed.version;
|
||||
latestFilename = filename;
|
||||
}
|
||||
|
||||
private async buildListedDesktopVersions(
|
||||
params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
arch: DesktopArch;
|
||||
baseUrl?: string;
|
||||
test?: boolean;
|
||||
},
|
||||
listedVersions: ReadonlyArray<ListedDesktopVersion>,
|
||||
): Promise<Array<VersionInfo>> {
|
||||
const sha256Promises: Array<
|
||||
Promise<{
|
||||
key: string;
|
||||
hash: string | null;
|
||||
}>
|
||||
> = [];
|
||||
for (const entry of listedVersions) {
|
||||
for (const [, fileInfo] of entry.files) {
|
||||
if (fileInfo.sha256Key) {
|
||||
sha256Promises.push(
|
||||
(async () => {
|
||||
try {
|
||||
const streamResult = await this.storageService.streamObject({
|
||||
bucket: Config.s3.buckets.downloads,
|
||||
key: fileInfo.sha256Key as string,
|
||||
});
|
||||
if (streamResult) {
|
||||
const body = Readable.toWeb(streamResult.body);
|
||||
const text = await new Response(body as ReadableStream).text();
|
||||
return {key: fileInfo.sha256Key as string, hash: text.trim().split(/\s+/u)[0]};
|
||||
}
|
||||
} catch {
|
||||
return {key: fileInfo.sha256Key as string, hash: null};
|
||||
}
|
||||
return {key: fileInfo.sha256Key as string, hash: null};
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return latestFilename;
|
||||
const sha256Results = await Promise.all(sha256Promises);
|
||||
const sha256Map = new Map<string, string | null>();
|
||||
for (const result of sha256Results) {
|
||||
sha256Map.set(result.key, result.hash);
|
||||
}
|
||||
const versions: Array<VersionInfo> = [];
|
||||
for (const entry of listedVersions) {
|
||||
const files: Record<string, VersionFile> = {};
|
||||
for (const [format, fileInfo] of entry.files) {
|
||||
const sha256 = fileInfo.sha256Key ? (sha256Map.get(fileInfo.sha256Key) ?? null) : null;
|
||||
const validSha256 = sha256 && this.isValidSha256(sha256) ? sha256 : null;
|
||||
files[format] = {
|
||||
url: this.buildDesktopVersionUrl({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
version: entry.version,
|
||||
format,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
}),
|
||||
sha256: validSha256,
|
||||
checksum_url: validSha256
|
||||
? this.buildDesktopVersionChecksumUrl({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
version: entry.version,
|
||||
format,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
})
|
||||
: null,
|
||||
};
|
||||
}
|
||||
versions.push({
|
||||
version: entry.version,
|
||||
pub_date: entry.pub_date.toISOString(),
|
||||
files,
|
||||
});
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
|
||||
private escapeRegex(str: string): string {
|
||||
@@ -1030,16 +1083,25 @@ export class DownloadService {
|
||||
return key;
|
||||
}
|
||||
|
||||
private async resolveLatestDesktopKeyFromObjects(params: LatestFilenameLookupParams): Promise<string | null> {
|
||||
const filename = await this.findLatestFilenameForRequestedArch(params);
|
||||
if (!filename) {
|
||||
private async resolveLatestDesktopKeyFromObjects(
|
||||
params: LatestFilenameLookupParams,
|
||||
releasability: Map<string, boolean>,
|
||||
): Promise<string | null> {
|
||||
const listedVersions = await this.listDesktopVersionFiles(params);
|
||||
const latest = await this.findNewestReleasableDesktopVersion(
|
||||
params,
|
||||
listedVersions.filter((entry) => entry.files.has(params.format)),
|
||||
releasability,
|
||||
);
|
||||
const file = latest?.files.get(params.format);
|
||||
if (!file) {
|
||||
return null;
|
||||
}
|
||||
return this.buildDesktopArtifactKey({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
filename,
|
||||
filename: file.filename,
|
||||
test: params.test,
|
||||
});
|
||||
}
|
||||
@@ -1189,22 +1251,23 @@ export class DownloadService {
|
||||
};
|
||||
}
|
||||
|
||||
private async getLatestDesktopVersionFromObjects(params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
arch: DesktopArch;
|
||||
baseUrl?: string;
|
||||
test?: boolean;
|
||||
}): Promise<VersionInfo | null> {
|
||||
const {versions} = await this.listDesktopVersions({
|
||||
channel: params.channel,
|
||||
plat: params.plat,
|
||||
arch: params.arch,
|
||||
limit: 1,
|
||||
baseUrl: params.baseUrl,
|
||||
test: params.test,
|
||||
});
|
||||
return versions[0] ?? null;
|
||||
private async getLatestDesktopVersionFromObjects(
|
||||
params: {
|
||||
channel: DesktopChannel;
|
||||
plat: DesktopPlatform;
|
||||
arch: DesktopArch;
|
||||
baseUrl?: string;
|
||||
test?: boolean;
|
||||
},
|
||||
releasability: Map<string, boolean>,
|
||||
): Promise<VersionInfo | null> {
|
||||
const listedVersions = await this.listDesktopVersionFiles(params);
|
||||
const latest = await this.findNewestReleasableDesktopVersion(params, listedVersions, releasability);
|
||||
if (!latest) {
|
||||
return null;
|
||||
}
|
||||
const [versionInfo] = await this.buildListedDesktopVersions(params, [latest]);
|
||||
return versionInfo ?? null;
|
||||
}
|
||||
|
||||
private async resolveDesktopFileSha256(params: {
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import {Readable} from 'node:stream';
|
||||
import {S3ServiceException} from '@aws-sdk/client-s3';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {getConfig} from '../../Config';
|
||||
import type {IStorageService} from '../../infrastructure/IStorageService';
|
||||
import {DownloadService} from '../DownloadService';
|
||||
|
||||
const PREFIX = 'desktop/canary/linux/x64';
|
||||
const TEST_PREFIX = 'desktop-test/canary/linux/x64';
|
||||
const RELEASES_PREFIX = 'desktop/canary/github-releases';
|
||||
const SOURCE_SHA = 'b'.repeat(40);
|
||||
const V904 = '2026.904.135113';
|
||||
const V908 = '2026.908.173325';
|
||||
const V909 = '2026.909.202036';
|
||||
|
||||
const LATEST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64'} as const;
|
||||
const APPIMAGE_PARAMS = {...LATEST_PARAMS, format: 'appimage'} as const;
|
||||
|
||||
const RELEASE_ROUTES: ReadonlyArray<readonly [string, string, number]> = [
|
||||
['darwin', 'arm64', 4],
|
||||
['darwin', 'x64', 4],
|
||||
['linux', 'arm64', 4],
|
||||
['linux', 'x64', 4],
|
||||
['win32', 'arm64', 6],
|
||||
['win32', 'x64', 6],
|
||||
];
|
||||
|
||||
type StoredObjects = Map<string, string>;
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function appImageFilename(version: string): string {
|
||||
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
|
||||
}
|
||||
|
||||
function uploadBuild(objects: StoredObjects, version: string, options: {prefix?: string; checksum?: boolean} = {}) {
|
||||
const prefix = options.prefix ?? PREFIX;
|
||||
const filename = appImageFilename(version);
|
||||
objects.set(`${prefix}/${filename}`, filename);
|
||||
if (options.checksum !== false) {
|
||||
objects.set(`${prefix}/${filename}.sha256`, `${sha256Hex(filename)} ${filename}`);
|
||||
}
|
||||
objects.set(
|
||||
`${prefix}/manifest.json`,
|
||||
JSON.stringify({
|
||||
channel: 'canary',
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
version,
|
||||
pub_date: '2026-09-08T18:06:00Z',
|
||||
files: {appimage: {filename, sha256: sha256Hex(filename)}},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function publishDescriptor(objects: StoredObjects, version: string, routes = RELEASE_ROUTES): string {
|
||||
const assets = routes.flatMap(([plat, arch, count]) =>
|
||||
Array.from({length: count}, (_, index) => {
|
||||
const filename =
|
||||
plat === 'linux' && arch === 'x64' && index === 0
|
||||
? appImageFilename(version)
|
||||
: `Fluxer-Canary-${version}-${plat}-${arch}-${index}.bin`;
|
||||
return {
|
||||
storage_key: `desktop/canary/${plat}/${arch}/${filename}`,
|
||||
release_asset: filename,
|
||||
sha256: sha256Hex(filename),
|
||||
size: 1,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const descriptor = JSON.stringify({
|
||||
schema_version: 1,
|
||||
channel: 'canary',
|
||||
version,
|
||||
release_tag: `fluxer-desktop-canary@${version}`,
|
||||
source_sha: SOURCE_SHA,
|
||||
assets,
|
||||
});
|
||||
objects.set(`${RELEASES_PREFIX}/${version}.json`, descriptor);
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
function publishMarker(objects: StoredObjects, version: string, descriptor: string) {
|
||||
objects.set(
|
||||
`${RELEASES_PREFIX}/${version}.ready.json`,
|
||||
JSON.stringify({
|
||||
schema_version: 1,
|
||||
channel: 'canary',
|
||||
version,
|
||||
release_tag: `fluxer-desktop-canary@${version}`,
|
||||
source_sha: SOURCE_SHA,
|
||||
descriptor_sha256: sha256Hex(descriptor),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function releaseBuild(objects: StoredObjects, version: string) {
|
||||
const descriptor = publishDescriptor(objects, version);
|
||||
uploadBuild(objects, version);
|
||||
publishMarker(objects, version, descriptor);
|
||||
}
|
||||
|
||||
function incidentObjects(): StoredObjects {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908);
|
||||
return objects;
|
||||
}
|
||||
|
||||
function createService(objects: StoredObjects, onRead?: (key: string) => void) {
|
||||
const reads: Array<string> = [];
|
||||
const listings: Array<string> = [];
|
||||
const storageService = {
|
||||
streamObject: async (params: {key: string}) => {
|
||||
reads.push(params.key);
|
||||
onRead?.(params.key);
|
||||
const body = objects.get(params.key);
|
||||
if (body == null) {
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.from(body, 'utf8');
|
||||
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
|
||||
},
|
||||
listObjects: async (params: {prefix: string}) => {
|
||||
listings.push(params.prefix);
|
||||
return Array.from(objects.keys())
|
||||
.filter((key) => key.startsWith(params.prefix))
|
||||
.sort()
|
||||
.map((key) => ({key}));
|
||||
},
|
||||
getObjectMetadata: async (_bucket: string, key: string) =>
|
||||
objects.has(key) ? {contentLength: 1, contentType: 'application/octet-stream'} : null,
|
||||
} as unknown as IStorageService;
|
||||
return {service: new DownloadService(storageService), reads, listings};
|
||||
}
|
||||
|
||||
async function resolveLatest(service: DownloadService, test?: boolean) {
|
||||
const metadata = await service.getLatestDesktopVersion({...LATEST_PARAMS, test});
|
||||
const key = await service.resolveLatestDesktopKey({...APPIMAGE_PARAMS, test});
|
||||
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS, test});
|
||||
return {version: metadata?.version, key, checksum: checksum?.body};
|
||||
}
|
||||
|
||||
function latestOf(version: string, prefix = PREFIX) {
|
||||
const filename = appImageFilename(version);
|
||||
return {version, key: `${prefix}/${filename}`, checksum: `${sha256Hex(filename)} ${filename}\n`};
|
||||
}
|
||||
|
||||
describe('desktop release readiness', () => {
|
||||
it('offers a published manifest version after reading only its release state', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
releaseBuild(objects, V909);
|
||||
const {service, reads, listings} = createService(objects);
|
||||
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V909});
|
||||
expect(reads).toEqual([
|
||||
`${PREFIX}/manifest.json`,
|
||||
`${RELEASES_PREFIX}/${V909}.json`,
|
||||
`${RELEASES_PREFIX}/${V909}.ready.json`,
|
||||
]);
|
||||
expect(listings).toEqual([]);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
|
||||
});
|
||||
|
||||
it('falls back to the newest published version while the manifest version awaits its release', async () => {
|
||||
const {service} = createService(incidentObjects());
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
});
|
||||
|
||||
it('reads each release state once and one checksum while the manifest version awaits its release', async () => {
|
||||
const {service, reads, listings} = createService(incidentObjects());
|
||||
await expect(service.getLatestDesktopVersion({...LATEST_PARAMS})).resolves.toMatchObject({version: V904});
|
||||
expect(reads).toEqual([
|
||||
`${PREFIX}/manifest.json`,
|
||||
`${RELEASES_PREFIX}/${V908}.json`,
|
||||
`${RELEASES_PREFIX}/${V908}.ready.json`,
|
||||
`${RELEASES_PREFIX}/${V904}.json`,
|
||||
`${RELEASES_PREFIX}/${V904}.ready.json`,
|
||||
`${PREFIX}/${appImageFilename(V904)}.sha256`,
|
||||
]);
|
||||
expect(listings).toEqual([`${PREFIX}/`]);
|
||||
});
|
||||
|
||||
it('offers a manifest version that has no release descriptor', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
uploadBuild(objects, V904);
|
||||
uploadBuild(objects, V908);
|
||||
const {service} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
});
|
||||
|
||||
it('treats a readiness marker that does not match the stored descriptor as unpublished', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908);
|
||||
publishMarker(objects, V908, 'another descriptor');
|
||||
const {service} = createService(objects);
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
|
||||
kind: 'awaiting_release',
|
||||
});
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
});
|
||||
|
||||
it('offers a version whose descriptor the parser rejects when its readiness marker matches', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
const descriptor = publishDescriptor(
|
||||
objects,
|
||||
V908,
|
||||
RELEASE_ROUTES.map(([plat, arch, count]) => [plat, arch, plat === 'linux' ? count - 1 : count] as const),
|
||||
);
|
||||
uploadBuild(objects, V908);
|
||||
publishMarker(objects, V908, descriptor);
|
||||
const {service} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).rejects.toThrow(
|
||||
'Invalid GitHub desktop release descriptor',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['descriptor', `${RELEASES_PREFIX}/${V908}.json`],
|
||||
['readiness marker', `${RELEASES_PREFIX}/${V908}.ready.json`],
|
||||
])('offers the manifest version when reading its release %s fails with a storage error', async (_name, failingKey) => {
|
||||
const {service} = createService(incidentObjects(), (key) => {
|
||||
if (key === failingKey) {
|
||||
throw new S3ServiceException({
|
||||
name: 'SlowDown',
|
||||
$fault: 'server',
|
||||
$metadata: {httpStatusCode: 503},
|
||||
message: 'Please reduce your request rate.',
|
||||
});
|
||||
}
|
||||
});
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
});
|
||||
|
||||
it('still resolves the unpublished version through versioned routes', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908, {checksum: false});
|
||||
const {service} = createService(objects);
|
||||
const params = {...APPIMAGE_PARAMS, version: V908};
|
||||
const filename = appImageFilename(V908);
|
||||
await expect(service.resolveVersionedDesktopKey(params)).resolves.toBe(`${PREFIX}/${filename}`);
|
||||
await expect(service.resolveVersionedDesktopChecksumFile(params)).resolves.toMatchObject({
|
||||
sha256: sha256Hex(filename),
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps offering the manifest version on self-hosted instances', async () => {
|
||||
const config = getConfig();
|
||||
const originalSelfHosted = config.instance.selfHosted;
|
||||
config.instance.selfHosted = true;
|
||||
try {
|
||||
const {service, reads} = createService(incidentObjects());
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V908));
|
||||
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
|
||||
} finally {
|
||||
config.instance.selfHosted = originalSelfHosted;
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps offering the newest test build', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
publishDescriptor(objects, V908);
|
||||
uploadBuild(objects, V908, {prefix: TEST_PREFIX});
|
||||
const {service, reads} = createService(objects);
|
||||
await expect(resolveLatest(service, true)).resolves.toEqual(latestOf(V908, TEST_PREFIX));
|
||||
expect(reads.filter((key) => key.startsWith(RELEASES_PREFIX))).toEqual([]);
|
||||
});
|
||||
|
||||
it('offers 904 while 908 awaits its release, then 909 once its marker lands', async () => {
|
||||
const objects = incidentObjects();
|
||||
const {service} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V908)}`)).resolves.toEqual({
|
||||
kind: 'awaiting_release',
|
||||
});
|
||||
const descriptor = publishDescriptor(objects, V909);
|
||||
uploadBuild(objects, V909);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
publishMarker(objects, V909, descriptor);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V909));
|
||||
await expect(service.resolveGitHubDesktopRelease(`${PREFIX}/${appImageFilename(V909)}`)).resolves.toEqual({
|
||||
kind: 'ready',
|
||||
location: `https://github.com/fluxerapp/fluxer/releases/download/${encodeURIComponent(`fluxer-desktop-canary@${V909}`)}/${appImageFilename(V909)}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('offers the newest version when ten unpublished versions hide a published one', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
releaseBuild(objects, V904);
|
||||
const newest = '2026.908.170009';
|
||||
for (let build = 0; build < 10; build++) {
|
||||
const version = `2026.908.${170000 + build}`;
|
||||
publishDescriptor(objects, version);
|
||||
uploadBuild(objects, version);
|
||||
}
|
||||
const {service, reads} = createService(objects);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(newest));
|
||||
expect(reads).not.toContain(`${RELEASES_PREFIX}/${V904}.ready.json`);
|
||||
});
|
||||
|
||||
it('pairs the latest checksum with the filename of the same version when a marker lands mid-request', async () => {
|
||||
const objects = incidentObjects();
|
||||
const descriptor = publishDescriptor(objects, V909);
|
||||
uploadBuild(objects, V909);
|
||||
let manifestReads = 0;
|
||||
const {service} = createService(objects, (key) => {
|
||||
if (key !== `${PREFIX}/manifest.json`) {
|
||||
return;
|
||||
}
|
||||
manifestReads += 1;
|
||||
if (manifestReads === 2) {
|
||||
publishMarker(objects, V909, descriptor);
|
||||
}
|
||||
});
|
||||
const checksum = await service.resolveLatestDesktopChecksumFile({...APPIMAGE_PARAMS});
|
||||
expect(checksum?.body).toBe(latestOf(V904).checksum);
|
||||
});
|
||||
|
||||
it('lists an unpublished version while latest skips it', async () => {
|
||||
const {service} = createService(incidentObjects());
|
||||
const listed = await service.listDesktopVersions({...LATEST_PARAMS, limit: 10});
|
||||
expect(listed.versions.map((entry) => entry.version)).toEqual([V908, V904]);
|
||||
await expect(resolveLatest(service)).resolves.toEqual(latestOf(V904));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Readable} from 'node:stream';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import type {IStorageService} from '../../infrastructure/IStorageService';
|
||||
import {DownloadService} from '../DownloadService';
|
||||
|
||||
const PREFIX = 'desktop/canary/linux/x64';
|
||||
const BASE_URL = 'https://api.example.test';
|
||||
const V1 = '2026.901.100000';
|
||||
const V2 = '2026.902.100000';
|
||||
const V3 = '2026.903.100000';
|
||||
const V4 = '2026.904.100000';
|
||||
const V5 = '2026.905.100000';
|
||||
|
||||
const LIST_PARAMS = {channel: 'canary', plat: 'linux', arch: 'x64', baseUrl: BASE_URL} as const;
|
||||
|
||||
type StoredObject = {body?: string; lastModified?: Date};
|
||||
type StoredObjects = Map<string, StoredObject>;
|
||||
|
||||
function appImageFilename(version: string): string {
|
||||
return `Fluxer-Canary-${version}-linux-x86_64.AppImage`;
|
||||
}
|
||||
|
||||
function debFilename(version: string): string {
|
||||
return `Fluxer-Canary-${version}-linux-amd64.deb`;
|
||||
}
|
||||
|
||||
function addArtifact(objects: StoredObjects, filename: string, options: {sha256?: string; lastModified?: Date} = {}) {
|
||||
objects.set(`${PREFIX}/${filename}`, {lastModified: options.lastModified});
|
||||
if (options.sha256 !== undefined) {
|
||||
objects.set(`${PREFIX}/${filename}.sha256`, {body: `${options.sha256} ${filename}\n`});
|
||||
}
|
||||
}
|
||||
|
||||
function createService(objects: StoredObjects) {
|
||||
const reads: Array<string> = [];
|
||||
const storageService = {
|
||||
streamObject: async (params: {key: string}) => {
|
||||
reads.push(params.key);
|
||||
const object = objects.get(params.key);
|
||||
if (object?.body == null) {
|
||||
return null;
|
||||
}
|
||||
const buffer = Buffer.from(object.body, 'utf8');
|
||||
return {body: Readable.from([buffer]), contentLength: buffer.byteLength};
|
||||
},
|
||||
listObjects: async (params: {prefix: string}) =>
|
||||
Array.from(objects.entries())
|
||||
.filter(([key]) => key.startsWith(params.prefix))
|
||||
.sort(([left], [right]) => (left < right ? -1 : 1))
|
||||
.map(([key, object]) => ({key, lastModified: object.lastModified})),
|
||||
getObjectMetadata: async () => null,
|
||||
} as unknown as IStorageService;
|
||||
return {service: new DownloadService(storageService), reads};
|
||||
}
|
||||
|
||||
function versionNumbers(versions: Array<{version: string}>): Array<string> {
|
||||
return versions.map((entry) => entry.version);
|
||||
}
|
||||
|
||||
describe('desktop version listing', () => {
|
||||
it('lists versions newest first with the files of each version', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
addArtifact(objects, appImageFilename(V1), {lastModified: new Date('2026-09-01T10:00:00Z')});
|
||||
addArtifact(objects, appImageFilename(V3), {lastModified: new Date('2026-09-03T10:00:00Z')});
|
||||
addArtifact(objects, debFilename(V3), {lastModified: new Date('2026-09-03T12:00:00Z')});
|
||||
addArtifact(objects, appImageFilename(V5), {lastModified: new Date('2026-09-05T10:00:00Z')});
|
||||
const {service} = createService(objects);
|
||||
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
|
||||
expect(versionNumbers(listed.versions)).toEqual([V5, V3, V1]);
|
||||
expect(listed.hasMore).toBe(false);
|
||||
expect(Object.keys(listed.versions[1].files).sort()).toEqual(['appimage', 'deb']);
|
||||
expect(listed.versions[1].pub_date).toBe('2026-09-03T12:00:00.000Z');
|
||||
expect(listed.versions[0].files.appimage.url).toBe(`${BASE_URL}/dl/desktop/canary/linux/x64/${V5}/appimage`);
|
||||
});
|
||||
|
||||
it('excludes names that are not artefacts for the requested coordinate', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
addArtifact(objects, appImageFilename(V3), {sha256: 'a'.repeat(64)});
|
||||
objects.set(`${PREFIX}/nested/${appImageFilename(V5)}`, {});
|
||||
objects.set(`${PREFIX}/manifest.json`, {body: '{}'});
|
||||
objects.set(`${PREFIX}/RELEASES.json`, {body: '{}'});
|
||||
objects.set(`${PREFIX}/releases.json`, {body: '{}'});
|
||||
objects.set(`${PREFIX}/latest-linux.yml`, {body: 'version: 1'});
|
||||
objects.set(`${PREFIX}/${appImageFilename(V4)}.blockmap`, {});
|
||||
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-linux-aarch64.AppImage`, {});
|
||||
objects.set(`${PREFIX}/Fluxer-Canary-${V4}-mac-universal.dmg`, {});
|
||||
const {service} = createService(objects);
|
||||
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
|
||||
expect(versionNumbers(listed.versions)).toEqual([V3]);
|
||||
expect(Object.keys(listed.versions[0].files)).toEqual(['appimage']);
|
||||
});
|
||||
|
||||
it('pages with limit, before and after and reports whether more remain', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
for (const version of [V1, V2, V3, V4, V5]) {
|
||||
addArtifact(objects, appImageFilename(version));
|
||||
}
|
||||
const {service} = createService(objects);
|
||||
const firstPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
|
||||
expect(versionNumbers(firstPage.versions)).toEqual([V5, V4]);
|
||||
expect(firstPage.hasMore).toBe(true);
|
||||
const olderPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, before: V3});
|
||||
expect(versionNumbers(olderPage.versions)).toEqual([V2, V1]);
|
||||
expect(olderPage.hasMore).toBe(false);
|
||||
const newerPage = await service.listDesktopVersions({...LIST_PARAMS, limit: 2, after: V3});
|
||||
expect(versionNumbers(newerPage.versions)).toEqual([V5, V4]);
|
||||
expect(newerPage.hasMore).toBe(false);
|
||||
const between = await service.listDesktopVersions({...LIST_PARAMS, limit: 1, before: V5, after: V1});
|
||||
expect(versionNumbers(between.versions)).toEqual([V4]);
|
||||
expect(between.hasMore).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the sibling hash and treats a missing or malformed one as absent', async () => {
|
||||
const hash = 'b'.repeat(64);
|
||||
const objects: StoredObjects = new Map();
|
||||
addArtifact(objects, appImageFilename(V3), {sha256: hash});
|
||||
addArtifact(objects, appImageFilename(V2));
|
||||
addArtifact(objects, appImageFilename(V1), {sha256: 'C'.repeat(64)});
|
||||
const {service} = createService(objects);
|
||||
const listed = await service.listDesktopVersions({...LIST_PARAMS, limit: 10});
|
||||
expect(listed.versions[0].files.appimage).toEqual({
|
||||
url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage`,
|
||||
sha256: hash,
|
||||
checksum_url: `${BASE_URL}/dl/desktop/canary/linux/x64/${V3}/appimage.sha256`,
|
||||
});
|
||||
expect(listed.versions[1].files.appimage.sha256).toBeNull();
|
||||
expect(listed.versions[1].files.appimage.checksum_url).toBeNull();
|
||||
expect(listed.versions[2].files.appimage.sha256).toBeNull();
|
||||
expect(listed.versions[2].files.appimage.checksum_url).toBeNull();
|
||||
});
|
||||
|
||||
it('reads a checksum only for the versions it returns', async () => {
|
||||
const objects: StoredObjects = new Map();
|
||||
for (const version of [V1, V2, V3, V4, V5]) {
|
||||
addArtifact(objects, appImageFilename(version), {sha256: 'd'.repeat(64)});
|
||||
}
|
||||
const {service, reads} = createService(objects);
|
||||
await service.listDesktopVersions({...LIST_PARAMS, limit: 2});
|
||||
expect(reads).toEqual([`${PREFIX}/${appImageFilename(V5)}.sha256`, `${PREFIX}/${appImageFilename(V4)}.sha256`]);
|
||||
});
|
||||
});
|
||||
@@ -401,7 +401,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
const language =
|
||||
params.primaryLanguage && isValidDiscoveryLanguage(params.primaryLanguage) ? params.primaryLanguage : undefined;
|
||||
const tag = params.tag && params.tag.trim().length > 0 ? normalizeDiscoveryTag(params.tag) : undefined;
|
||||
const sortBy = params.sortBy === 'member_count' ? 'memberCount' : 'relevance';
|
||||
const sortBy = params.sortBy === 'relevance' ? 'relevance' : 'memberCount';
|
||||
const filters: GuildSearchFilters = {
|
||||
isDiscoverable: true,
|
||||
discoveryCategory: params.categoryId,
|
||||
@@ -444,7 +444,6 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
for (const guild of guilds) {
|
||||
const counts = freshCounts.get(BigInt(guild.id) as GuildID);
|
||||
if (counts) {
|
||||
guild.member_count = counts.memberCount;
|
||||
guild.online_count = counts.onlineCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import type {UserCacheService} from '../../infrastructure/UserCacheService';
|
||||
import {Logger} from '../../Logger';
|
||||
import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
|
||||
import type {GuildBan} from '../../models/GuildBan';
|
||||
import {hasHighCgnatBlastRadiusRisk, isSingleIpBanCandidate} from '../../risk/IpBanCgnatGuard';
|
||||
import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '../../risk/IpBanCgnatGuard';
|
||||
import {isIpBanExempt} from '../../risk/IpBanExemptions';
|
||||
import type {IUserRepository} from '../../user/IUserRepository';
|
||||
import type {WorkerTaskName} from '../../worker/WorkerLaneConfig';
|
||||
@@ -237,19 +237,20 @@ export class GuildModerationService {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const highRisk = await hasHighCgnatBlastRadiusRisk(userIp, this.ipInfoService, {
|
||||
const {cgnat, sharedAccess} = await getIpBanBlastRadiusVerdict(userIp, this.ipInfoService, {
|
||||
source: 'guild.ip_ban',
|
||||
reason: 'join_cgnat_guard',
|
||||
});
|
||||
const highRisk = cgnat || sharedAccess;
|
||||
if (highRisk) {
|
||||
Logger.warn(
|
||||
{userIp, bannedIp},
|
||||
'Skipping guild IP ban match because IPInfo indicates high CGNAT blast-radius risk',
|
||||
'Skipping guild IP ban match because IPInfo indicates high shared-network blast-radius risk',
|
||||
);
|
||||
}
|
||||
return !highRisk;
|
||||
} catch (error) {
|
||||
Logger.warn({error, userIp, bannedIp}, 'IPInfo CGNAT guard failed while checking guild IP ban');
|
||||
Logger.warn({error, userIp, bannedIp}, 'IPInfo blast-radius guard failed while checking guild IP ban');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,23 +68,10 @@ export class GuildSearchService {
|
||||
const includeNsfwRequested = searchParams.include_nsfw ?? false;
|
||||
const canUserAccessNsfw =
|
||||
guildIsAgeRestricted || includeNsfwRequested ? await this.getCanUserAccessNsfw(userId) : false;
|
||||
if (guildIsAgeRestricted) {
|
||||
if (!canUserAccessNsfw) {
|
||||
throw new NsfwContentRequiresAgeVerificationError();
|
||||
}
|
||||
if (!includeNsfwRequested) {
|
||||
const hitsPerPage = searchParams.hits_per_page ?? 25;
|
||||
const page = searchParams.page ?? 1;
|
||||
return {
|
||||
channels: [],
|
||||
messages: [],
|
||||
total: 0,
|
||||
hits_per_page: hitsPerPage,
|
||||
page,
|
||||
};
|
||||
}
|
||||
if (guildIsAgeRestricted && !canUserAccessNsfw) {
|
||||
throw new NsfwContentRequiresAgeVerificationError();
|
||||
}
|
||||
const canIncludeNsfw = includeNsfwRequested && canUserAccessNsfw;
|
||||
const canIncludeNsfw = canUserAccessNsfw && (includeNsfwRequested || guildIsAgeRestricted);
|
||||
const guildNsfw = guildData?.nsfw ?? false;
|
||||
const channels = await this.channelRepository.listChannels(channelIds);
|
||||
const channelMap = new Map<string, Channel>();
|
||||
@@ -169,7 +156,7 @@ export class GuildSearchService {
|
||||
page,
|
||||
cursor,
|
||||
});
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result.messages, userId, requestCache);
|
||||
return {
|
||||
messages: mappedResponses.messages,
|
||||
channels: mappedResponses.channels,
|
||||
@@ -251,7 +238,7 @@ export class GuildSearchService {
|
||||
page,
|
||||
cursor,
|
||||
});
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result.messages, userId, requestCache);
|
||||
return {
|
||||
messages: mappedResponses.messages,
|
||||
channels: mappedResponses.channels,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
|
||||
import {ALL_PERMISSIONS, ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ContentWarningLevel, GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {ContentWarningLevel, GuildFeatures, resolveVoiceChannelBitrate} from '@fluxer/constants/src/GuildConstants';
|
||||
import {
|
||||
MAX_CHANNELS_PER_CATEGORY,
|
||||
MAX_GUILD_CHANNELS,
|
||||
@@ -119,12 +119,16 @@ export class ChannelOperationsService {
|
||||
);
|
||||
}
|
||||
let channelName = params.data.name;
|
||||
if (params.data.type === ChannelTypes.GUILD_TEXT) {
|
||||
let guildFeatures: Array<string> | null = null;
|
||||
if (params.data.type === ChannelTypes.GUILD_TEXT || params.data.type === ChannelTypes.GUILD_VOICE) {
|
||||
const guildData = await this.gatewayService.getGuildData({
|
||||
guildId: params.guildId,
|
||||
userId: params.userId,
|
||||
});
|
||||
const hasFlexibleNamesEnabled = guildData.features.includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES);
|
||||
guildFeatures = guildData.features;
|
||||
}
|
||||
if (params.data.type === ChannelTypes.GUILD_TEXT) {
|
||||
const hasFlexibleNamesEnabled = (guildFeatures ?? []).includes(GuildFeatures.TEXT_CHANNEL_FLEXIBLE_NAMES);
|
||||
if (!hasFlexibleNamesEnabled) {
|
||||
channelName = ChannelNameType.parse(channelName);
|
||||
}
|
||||
@@ -156,7 +160,10 @@ export class ChannelOperationsService {
|
||||
content_warning_level: requestedContentWarningLevel,
|
||||
content_warning_text: requestedContentWarningText,
|
||||
rate_limit_per_user: params.data.rate_limit_per_user ?? 0,
|
||||
bitrate: params.data.type === ChannelTypes.GUILD_VOICE ? (params.data.bitrate ?? 64000) : null,
|
||||
bitrate:
|
||||
params.data.type === ChannelTypes.GUILD_VOICE
|
||||
? resolveVoiceChannelBitrate(params.data.bitrate, guildFeatures)
|
||||
: null,
|
||||
user_limit: params.data.type === ChannelTypes.GUILD_VOICE ? (params.data.user_limit ?? 0) : null,
|
||||
voice_connection_limit:
|
||||
params.data.type === ChannelTypes.GUILD_VOICE
|
||||
|
||||
@@ -10,11 +10,13 @@ import {
|
||||
GuildSplashCardAlignment,
|
||||
GuildVerificationLevel,
|
||||
JoinSourceTypes,
|
||||
resolveVoiceChannelBitrate,
|
||||
SystemChannelFlags,
|
||||
} from '@fluxer/constants/src/GuildConstants';
|
||||
import {
|
||||
MAX_GUILD_CHANNELS,
|
||||
MAX_GUILD_ROLES,
|
||||
VOICE_CHANNEL_BITRATE_DEFAULT,
|
||||
VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
import {DEFAULT_GUILD_FOLDER_ICON} from '@fluxer/constants/src/UserConstants';
|
||||
@@ -934,7 +936,14 @@ export class GuildOperationsService {
|
||||
addChannel(textCategoryId, ChannelTypes.GUILD_CATEGORY, DEFAULT_TEXT_CATEGORY_NAME, null, 0);
|
||||
addChannel(voiceCategoryId, ChannelTypes.GUILD_CATEGORY, DEFAULT_VOICE_CATEGORY_NAME, null, 1);
|
||||
addChannel(generalChannelId, ChannelTypes.GUILD_TEXT, DEFAULT_TEXT_CHANNEL_NAME, textCategoryId, 0);
|
||||
addChannel(generalVoiceId, ChannelTypes.GUILD_VOICE, DEFAULT_VOICE_CHANNEL_NAME, voiceCategoryId, 0, 64000);
|
||||
addChannel(
|
||||
generalVoiceId,
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
DEFAULT_VOICE_CHANNEL_NAME,
|
||||
voiceCategoryId,
|
||||
0,
|
||||
VOICE_CHANNEL_BITRATE_DEFAULT,
|
||||
);
|
||||
batch.addPrepared(
|
||||
GuildRoles.insert({
|
||||
guild_id: guildId,
|
||||
@@ -1105,7 +1114,7 @@ export class GuildOperationsService {
|
||||
content_warning_level: null,
|
||||
content_warning_text: null,
|
||||
rate_limit_per_user: channel.rate_limit_per_user ?? 0,
|
||||
bitrate: isVoice ? (channel.bitrate ?? 64000) : null,
|
||||
bitrate: isVoice ? resolveVoiceChannelBitrate(channel.bitrate, null) : null,
|
||||
user_limit: isVoice ? (channel.user_limit ?? 0) : null,
|
||||
voice_connection_limit: isVoice
|
||||
? (channel.voice_connection_limit ?? VOICE_CHANNEL_CONNECTION_LIMIT_DEFAULT)
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {GuildID, RoleID, UserID} from '../../../BrandedTypes';
|
||||
import {guildIdToRoleId} from '../../../BrandedTypes';
|
||||
import {Logger} from '../../../Logger';
|
||||
import type {GuildMember} from '../../../models/GuildMember';
|
||||
import {hasHighCgnatBlastRadiusRisk, isSingleIpBanCandidate} from '../../../risk/IpBanCgnatGuard';
|
||||
import {getIpBanBlastRadiusVerdict, isSingleIpBanCandidate} from '../../../risk/IpBanCgnatGuard';
|
||||
import {isIpBanExempt} from '../../../risk/IpBanExemptions';
|
||||
import type {IUserRepository} from '../../../user/IUserRepository';
|
||||
import type {IGuildRepositoryAggregate} from '../../repositories/IGuildRepositoryAggregate';
|
||||
@@ -119,14 +119,15 @@ export class GuildMemberValidationService {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const highRisk = await hasHighCgnatBlastRadiusRisk(userIp, this.ipInfoService, {
|
||||
const {cgnat, sharedAccess} = await getIpBanBlastRadiusVerdict(userIp, this.ipInfoService, {
|
||||
source: 'guild.member_ip_ban',
|
||||
reason: 'join_cgnat_guard',
|
||||
});
|
||||
const highRisk = cgnat || sharedAccess;
|
||||
if (highRisk) {
|
||||
Logger.warn(
|
||||
{userIp, bannedIp},
|
||||
'Skipping guild member IP ban match because IPInfo indicates high CGNAT blast-radius risk',
|
||||
'Skipping guild member IP ban match because IPInfo indicates high shared-network blast-radius risk',
|
||||
);
|
||||
}
|
||||
return !highRisk;
|
||||
|
||||
@@ -8,13 +8,21 @@ import type {
|
||||
DiscoveryCategoryResponse,
|
||||
DiscoveryGuildListResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
|
||||
import type {GuildID} from '../../BrandedTypes';
|
||||
import {createTestBotAccount} from '../../bot/tests/BotTestUtils';
|
||||
import {setInjectedGatewayService} from '../../middleware/ServiceRegistry';
|
||||
import {getGuildRepository} from '../../middleware/ServiceSingletons';
|
||||
import {banUser} from '../../moderation/tests/ModerationTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {NoopLogger} from '../../test/mocks/NoopLogger';
|
||||
import {NoopGatewayService} from '../../test/NoopGatewayService';
|
||||
import {HTTP_STATUS, TEST_IDS} from '../../test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
import syncDiscoveryIndex from '../../worker/tasks/SyncDiscoveryIndex';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '../../worker/WorkerContext';
|
||||
import {createGuild, getUserGuilds} from './GuildTestUtils';
|
||||
|
||||
async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> {
|
||||
@@ -24,6 +32,30 @@ async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, mem
|
||||
.execute();
|
||||
}
|
||||
|
||||
interface LiveGuildCounts {
|
||||
memberCount: number;
|
||||
onlineCount: number;
|
||||
}
|
||||
|
||||
const WORKER_HELPERS = {logger: new NoopLogger()} as unknown as WorkerTaskHelpers;
|
||||
|
||||
class LiveCountsGatewayService extends NoopGatewayService {
|
||||
constructor(private readonly liveCounts: Map<string, LiveGuildCounts>) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async getDiscoveryGuildCounts(guildIds: Array<GuildID>): Promise<Map<GuildID, LiveGuildCounts>> {
|
||||
const counts = new Map<GuildID, LiveGuildCounts>();
|
||||
for (const guildId of guildIds) {
|
||||
const live = this.liveCounts.get(guildId.toString());
|
||||
if (live) {
|
||||
counts.set(guildId, live);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyAndApprove(
|
||||
harness: ApiTestHarness,
|
||||
ownerToken: string,
|
||||
@@ -44,12 +76,39 @@ async function applyAndApprove(
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function createApprovedDiscoveryGuild(
|
||||
harness: ApiTestHarness,
|
||||
adminToken: string,
|
||||
name: string,
|
||||
memberCount: number,
|
||||
): Promise<string> {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, name);
|
||||
await setGuildMemberCount(harness, guild.id, memberCount);
|
||||
await applyAndApprove(
|
||||
harness,
|
||||
owner.token,
|
||||
adminToken,
|
||||
guild.id,
|
||||
`${name} welcomes everyone`,
|
||||
DiscoveryCategories.GAMING,
|
||||
);
|
||||
return guild.id;
|
||||
}
|
||||
|
||||
function expectNonIncreasing(counts: Array<number>): void {
|
||||
for (let index = 1; index < counts.length; index++) {
|
||||
expect(counts[index]).toBeLessThanOrEqual(counts[index - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Discovery Search and Join', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness({search: 'enabled'});
|
||||
});
|
||||
afterEach(async () => {
|
||||
clearWorkerDependencies();
|
||||
await harness?.shutdown();
|
||||
});
|
||||
describe('categories', () => {
|
||||
@@ -257,6 +316,82 @@ describe('Discovery Search and Join', () => {
|
||||
.execute();
|
||||
expect(results.guilds.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
test('should order results by the member count it reports back', async () => {
|
||||
const liveCounts = new Map<string, LiveGuildCounts>();
|
||||
setInjectedGatewayService(new LiveCountsGatewayService(liveCounts));
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
const guildIds: Array<string> = [];
|
||||
for (const memberCount of [50, 40, 30, 20, 10]) {
|
||||
guildIds.push(
|
||||
await createApprovedDiscoveryGuild(harness, admin.token, `Ordered Guild ${memberCount}`, memberCount),
|
||||
);
|
||||
}
|
||||
liveCounts.set(guildIds[0], {memberCount: 5, onlineCount: 3});
|
||||
liveCounts.set(guildIds[4], {memberCount: 500, onlineCount: 7});
|
||||
const searcher = await createTestAccount(harness);
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?sort_by=member_count&limit=48')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(results.guilds.map((guild) => guild.id)).toEqual(guildIds);
|
||||
expectNonIncreasing(results.guilds.map((guild) => guild.member_count));
|
||||
expect(results.guilds[0].online_count).toBe(3);
|
||||
expect(results.guilds[4].online_count).toBe(7);
|
||||
});
|
||||
test('should rank by member count when the client omits sort_by', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
const guildsByCount = new Map<number, string>();
|
||||
for (const memberCount of [30, 10, 20]) {
|
||||
guildsByCount.set(
|
||||
memberCount,
|
||||
await createApprovedDiscoveryGuild(harness, admin.token, `Unsorted Guild ${memberCount}`, memberCount),
|
||||
);
|
||||
}
|
||||
const searcher = await createTestAccount(harness);
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?limit=48')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(results.guilds.map((guild) => guild.id)).toEqual([
|
||||
guildsByCount.get(30),
|
||||
guildsByCount.get(20),
|
||||
guildsByCount.get(10),
|
||||
]);
|
||||
expectNonIncreasing(results.guilds.map((guild) => guild.member_count));
|
||||
});
|
||||
test('should not repeat guilds across pages when the discovery index is resynced', async () => {
|
||||
const liveCounts = new Map<string, LiveGuildCounts>();
|
||||
const gatewayService = new LiveCountsGatewayService(liveCounts);
|
||||
setInjectedGatewayService(gatewayService);
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
const guildIds: Array<string> = [];
|
||||
for (const [index, memberCount] of [60, 50, 40, 40, 30, 30].entries()) {
|
||||
guildIds.push(await createApprovedDiscoveryGuild(harness, admin.token, `Paged Guild ${index}`, memberCount));
|
||||
}
|
||||
const searcher = await createTestAccount(harness);
|
||||
const firstPage = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?sort_by=member_count&limit=2&offset=0')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(firstPage.guilds.map((guild) => guild.id)).toEqual([guildIds[0], guildIds[1]]);
|
||||
liveCounts.set(guildIds[0], {memberCount: 5, onlineCount: 0});
|
||||
setWorkerDependenciesForTest({guildRepository: getGuildRepository(), gatewayService});
|
||||
await syncDiscoveryIndex({}, WORKER_HELPERS);
|
||||
const secondPage = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?sort_by=member_count&limit=2&offset=2')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const thirdPage = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?sort_by=member_count&limit=2&offset=4')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const paged = [...firstPage.guilds, ...secondPage.guilds, ...thirdPage.guilds].map((guild) => guild.id);
|
||||
expect(new Set(paged).size).toBe(paged.length);
|
||||
expect([...paged].sort()).toEqual([...guildIds].sort());
|
||||
});
|
||||
test('should require login to search', async () => {
|
||||
await createBuilderWithoutAuth(harness).get('/discovery/guilds').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount} from '../../auth/tests/AuthTestUtils';
|
||||
@@ -25,6 +26,14 @@ describe('Guild Channel Management', () => {
|
||||
afterEach(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
async function addGuildFeaturesForTesting(guildId: string, features: Array<string>): Promise<void> {
|
||||
await createBuilder<{
|
||||
success: boolean;
|
||||
}>(harness, '')
|
||||
.post(`/test/guilds/${guildId}/features`)
|
||||
.body({add_features: features})
|
||||
.execute();
|
||||
}
|
||||
describe('Channel Name Updates', () => {
|
||||
test('should normalize channel name with spaces to hyphens', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
@@ -582,7 +591,7 @@ describe('Guild Channel Management', () => {
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
test('should reject bitrate above maximum (320000)', async () => {
|
||||
test('should reject bitrate above maximum (384000)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
@@ -594,7 +603,7 @@ describe('Guild Channel Management', () => {
|
||||
);
|
||||
await createBuilder(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 320001})
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384001})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST)
|
||||
.execute();
|
||||
});
|
||||
@@ -678,7 +687,7 @@ describe('Guild Channel Management', () => {
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(8000);
|
||||
});
|
||||
test('should accept maximum bitrate (320000)', async () => {
|
||||
test('should clamp bitrate to 96000 without an audio bitrate feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
@@ -690,9 +699,74 @@ describe('Guild Channel Management', () => {
|
||||
);
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 320000})
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(320000);
|
||||
expect(data.bitrate).toBe(96000);
|
||||
});
|
||||
test('should clamp bitrate to the feature the guild holds', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_256_KBPS]);
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(256000);
|
||||
});
|
||||
test('should accept maximum bitrate (384000) with the 384 kbps feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_384_KBPS]);
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, bitrate: 384000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(384000);
|
||||
});
|
||||
test('should store the default bitrate on a new voice channel', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
guild.id,
|
||||
'voice-channel',
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
);
|
||||
expect(voiceChannel.bitrate).toBe(64000);
|
||||
});
|
||||
test('should clamp bitrate on create without an audio bitrate feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, name: 'loud-channel', bitrate: 384000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(96000);
|
||||
});
|
||||
test('should keep bitrate on create with the 128 kbps feature', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_128_KBPS]);
|
||||
const data = await createBuilder<ChannelResponse>(harness, account.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({type: ChannelTypes.GUILD_VOICE, name: 'loud-channel', bitrate: 128000})
|
||||
.execute();
|
||||
expect(data.bitrate).toBe(128000);
|
||||
});
|
||||
test('should accept maximum user limit (99)', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
@@ -734,6 +808,7 @@ describe('Guild Channel Management', () => {
|
||||
test('should update both bitrate and user limit together', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, account.token, 'Test Guild');
|
||||
await addGuildFeaturesForTesting(guild.id, [GuildFeatures.AUDIO_BITRATE_128_KBPS]);
|
||||
const voiceChannel = await createChannel(
|
||||
harness,
|
||||
account.token,
|
||||
|
||||
@@ -27,25 +27,51 @@ interface EmailDnsValidationServiceOptions {
|
||||
enforceInTestMode?: boolean;
|
||||
positiveTtlMs?: number;
|
||||
negativeTtlMs?: number;
|
||||
lookupTimeoutMs?: number;
|
||||
maxCachedDomains?: number;
|
||||
isEmailEnabled?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
type DnsResolutionResult = 'valid' | 'invalid' | 'fallback' | 'transient_error';
|
||||
type DomainVerdict = 'valid' | 'invalid' | 'unverified';
|
||||
|
||||
const DOMAIN_NOT_FOUND_CODES = new Set(['ENOTFOUND', 'ENONAME', 'EAI_NONAME', 'NXDOMAIN']);
|
||||
const DOMAIN_NO_RECORD_CODES = new Set(['ENODATA', 'ENOENT', 'NODATA']);
|
||||
const DNS_LOOKUP_TIMEOUT_MS = 2000;
|
||||
const DNS_LOOKUP_TRIES = 1;
|
||||
const MAX_CACHED_DOMAINS = 10000;
|
||||
|
||||
async function isInstanceEmailEnabled(): Promise<boolean> {
|
||||
const {getInstanceConfigRepository} = await import('../middleware/ServiceSingletons');
|
||||
return getInstanceConfigRepository().isEmailEnabled();
|
||||
}
|
||||
|
||||
function createLookupTimeoutError(): NodeJS.ErrnoException {
|
||||
const error: NodeJS.ErrnoException = new Error('Email DNS lookup timed out');
|
||||
error.code = 'ETIMEOUT';
|
||||
return error;
|
||||
}
|
||||
|
||||
export class EmailDnsValidationService implements IEmailDnsValidationService {
|
||||
private readonly resolver: IDnsResolver;
|
||||
private readonly enforceInTestMode: boolean;
|
||||
private readonly positiveTtlMs: number;
|
||||
private readonly negativeTtlMs: number;
|
||||
private readonly lookupTimeoutMs: number;
|
||||
private readonly maxCachedDomains: number;
|
||||
private readonly isEmailEnabled: () => Promise<boolean>;
|
||||
private readonly domainCache = new Map<string, DomainValidationCacheEntry>();
|
||||
|
||||
constructor(options: EmailDnsValidationServiceOptions = {}) {
|
||||
this.resolver = options.resolver ?? new Resolver();
|
||||
this.lookupTimeoutMs = options.lookupTimeoutMs ?? DNS_LOOKUP_TIMEOUT_MS;
|
||||
this.resolver =
|
||||
options.resolver ??
|
||||
new Resolver({timeout: this.lookupTimeoutMs, tries: DNS_LOOKUP_TRIES, maxTimeout: this.lookupTimeoutMs});
|
||||
this.enforceInTestMode = options.enforceInTestMode ?? false;
|
||||
this.positiveTtlMs = options.positiveTtlMs ?? ms('30 minutes');
|
||||
this.negativeTtlMs = options.negativeTtlMs ?? ms('5 minutes');
|
||||
this.maxCachedDomains = options.maxCachedDomains ?? MAX_CACHED_DOMAINS;
|
||||
this.isEmailEnabled = options.isEmailEnabled ?? isInstanceEmailEnabled;
|
||||
}
|
||||
|
||||
async hasValidDnsRecords(email: string): Promise<boolean> {
|
||||
@@ -56,13 +82,19 @@ export class EmailDnsValidationService implements IEmailDnsValidationService {
|
||||
if (!domain) {
|
||||
return false;
|
||||
}
|
||||
if (!(await this.isEmailEnabled())) {
|
||||
return true;
|
||||
}
|
||||
const cached = this.getCachedDomainResult(domain);
|
||||
if (cached !== null) {
|
||||
return cached;
|
||||
}
|
||||
const isValid = await this.resolveDomain(domain);
|
||||
this.setCachedDomainResult(domain, isValid);
|
||||
return isValid;
|
||||
const verdict = await this.resolveDomain(domain);
|
||||
if (verdict === 'invalid') {
|
||||
Logger.warn({domain}, 'Email domain publishes no mail exchange or address records, rejecting the address');
|
||||
}
|
||||
this.setCachedDomainResult(domain, verdict);
|
||||
return verdict !== 'invalid';
|
||||
}
|
||||
|
||||
private extractDomain(email: string): string | null {
|
||||
@@ -82,41 +114,49 @@ export class EmailDnsValidationService implements IEmailDnsValidationService {
|
||||
this.domainCache.delete(domain);
|
||||
return null;
|
||||
}
|
||||
this.domainCache.delete(domain);
|
||||
this.domainCache.set(domain, cached);
|
||||
return cached.valid;
|
||||
}
|
||||
|
||||
private setCachedDomainResult(domain: string, isValid: boolean): void {
|
||||
const ttlMs = isValid ? this.positiveTtlMs : this.negativeTtlMs;
|
||||
private setCachedDomainResult(domain: string, verdict: DomainVerdict): void {
|
||||
const ttlMs = verdict === 'valid' ? this.positiveTtlMs : this.negativeTtlMs;
|
||||
if (this.domainCache.size >= this.maxCachedDomains && !this.domainCache.has(domain)) {
|
||||
const oldestDomain = this.domainCache.keys().next().value;
|
||||
if (oldestDomain !== undefined) {
|
||||
this.domainCache.delete(oldestDomain);
|
||||
}
|
||||
}
|
||||
this.domainCache.set(domain, {
|
||||
valid: isValid,
|
||||
valid: verdict !== 'invalid',
|
||||
expiresAtMs: Date.now() + ttlMs,
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveDomain(domain: string): Promise<boolean> {
|
||||
private async resolveDomain(domain: string): Promise<DomainVerdict> {
|
||||
const mxResult = await this.resolveMx(domain);
|
||||
if (mxResult === 'valid') {
|
||||
return true;
|
||||
return 'valid';
|
||||
}
|
||||
if (mxResult === 'invalid') {
|
||||
return false;
|
||||
return 'invalid';
|
||||
}
|
||||
if (mxResult === 'transient_error') {
|
||||
return true;
|
||||
return 'unverified';
|
||||
}
|
||||
const addressResult = await this.resolveAddressRecords(domain);
|
||||
if (addressResult === 'valid') {
|
||||
return true;
|
||||
return 'valid';
|
||||
}
|
||||
if (addressResult === 'invalid') {
|
||||
return false;
|
||||
return 'invalid';
|
||||
}
|
||||
return true;
|
||||
return 'unverified';
|
||||
}
|
||||
|
||||
private async resolveMx(domain: string): Promise<DnsResolutionResult> {
|
||||
try {
|
||||
const records = await this.resolver.resolveMx(domain);
|
||||
const records = await this.withLookupDeadline(this.resolver.resolveMx(domain));
|
||||
if (records.length > 0) {
|
||||
return 'valid';
|
||||
}
|
||||
@@ -128,8 +168,8 @@ export class EmailDnsValidationService implements IEmailDnsValidationService {
|
||||
|
||||
private async resolveAddressRecords(domain: string): Promise<DnsResolutionResult> {
|
||||
const [ipv4Result, ipv6Result] = await Promise.allSettled([
|
||||
this.resolver.resolve4(domain),
|
||||
this.resolver.resolve6(domain),
|
||||
this.withLookupDeadline(this.resolver.resolve4(domain)),
|
||||
this.withLookupDeadline(this.resolver.resolve6(domain)),
|
||||
]);
|
||||
if (ipv4Result.status === 'fulfilled' && ipv4Result.value.length > 0) {
|
||||
return 'valid';
|
||||
@@ -147,6 +187,20 @@ export class EmailDnsValidationService implements IEmailDnsValidationService {
|
||||
return 'invalid';
|
||||
}
|
||||
|
||||
private async withLookupDeadline<T>(lookup: Promise<T>): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
lookup,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(createLookupTimeoutError()), this.lookupTimeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private classifyResolverError(
|
||||
error: unknown,
|
||||
domain: string,
|
||||
|
||||
@@ -324,9 +324,6 @@ export class LiveKitService extends ILiveKitService {
|
||||
participants: participants.map((participant) => ({identity: participant.identity})),
|
||||
};
|
||||
} catch (error) {
|
||||
if (LiveKitService.isHttp404(error)) {
|
||||
return {status: 'ok', participants: []};
|
||||
}
|
||||
Logger.warn({error, regionId, serverId, roomName}, 'LiveKit listParticipants failed');
|
||||
const status = LiveKitService.getHttpStatus(error);
|
||||
const isRetryable = status != null && status >= 500;
|
||||
|
||||
@@ -143,6 +143,32 @@ describe('StorageService.getPresignedUploadURL', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('gives both clients the configured addressing rather than pinning one to path style', async () => {
|
||||
await withS3Config(
|
||||
{
|
||||
endpoint: 'https://s3.example.test',
|
||||
presignedUrlBase: '',
|
||||
forcePathStyle: false,
|
||||
region: 'eu-central-1',
|
||||
accessKeyId: 'fluxer',
|
||||
secretAccessKey: 'fluxer-secret',
|
||||
buckets: {uploads: 'fluxer-uploads'},
|
||||
},
|
||||
async () => {
|
||||
const service = new StorageService();
|
||||
const probe = service as unknown as {
|
||||
client: {config: {forcePathStyle?: unknown}};
|
||||
presignClient: {config: {forcePathStyle?: unknown}};
|
||||
};
|
||||
const resolve = async (value: unknown): Promise<unknown> =>
|
||||
typeof value === 'function' ? await (value as () => Promise<unknown>)() : value;
|
||||
|
||||
expect(await resolve(probe.client.config.forcePathStyle)).toBe(false);
|
||||
expect(await resolve(probe.presignClient.config.forcePathStyle)).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StorageService.copyObjectWithMetadataStripping', () => {
|
||||
|
||||
@@ -127,7 +127,7 @@ export class StorageService implements IStorageService {
|
||||
region: this.provider.region,
|
||||
accessKeyId: this.provider.accessKeyId,
|
||||
secretAccessKey: this.provider.secretAccessKey,
|
||||
forcePathStyle: true,
|
||||
forcePathStyle: this.provider.forcePathStyle,
|
||||
});
|
||||
this.presignClient = buildPooledS3Client({
|
||||
endpoint: this.resolvePresignEndpoint(),
|
||||
@@ -136,6 +136,15 @@ export class StorageService implements IStorageService {
|
||||
secretAccessKey: this.provider.secretAccessKey,
|
||||
forcePathStyle: this.provider.forcePathStyle,
|
||||
});
|
||||
Logger.info(
|
||||
{
|
||||
endpoint: this.provider.endpoint,
|
||||
presignEndpoint: this.resolvePresignEndpoint(),
|
||||
region: this.provider.region,
|
||||
addressing: this.provider.forcePathStyle ? 'path' : 'virtual-host',
|
||||
},
|
||||
'Object storage client ready',
|
||||
);
|
||||
}
|
||||
|
||||
private resolvePresignEndpoint(): string {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ms} from 'itty-time';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {Config} from '../../Config';
|
||||
import {getInstanceConfigRepository} from '../../middleware/ServiceSingletons';
|
||||
import {EmailDnsValidationService} from '../EmailDnsValidationService';
|
||||
|
||||
interface MxRecord {
|
||||
exchange: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
function dnsError(code: string): NodeJS.ErrnoException {
|
||||
const error: NodeJS.ErrnoException = new Error(`dns lookup failed with ${code}`);
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
class FakeDnsResolver {
|
||||
readonly lookups: Array<string> = [];
|
||||
mxRecords: Array<MxRecord> = [{exchange: 'mx.example.com', priority: 10}];
|
||||
mxErrorCode: string | null = null;
|
||||
addressErrorCode: string | null = 'ENOTFOUND';
|
||||
addresses: Array<string> = [];
|
||||
stall = false;
|
||||
|
||||
async resolveMx(domain: string): Promise<Array<MxRecord>> {
|
||||
this.lookups.push(`mx:${domain}`);
|
||||
if (this.stall) {
|
||||
return new Promise<Array<MxRecord>>(() => {});
|
||||
}
|
||||
if (this.mxErrorCode) {
|
||||
throw dnsError(this.mxErrorCode);
|
||||
}
|
||||
return this.mxRecords;
|
||||
}
|
||||
|
||||
async resolve4(domain: string): Promise<Array<string>> {
|
||||
this.lookups.push(`a:${domain}`);
|
||||
if (this.addressErrorCode) {
|
||||
throw dnsError(this.addressErrorCode);
|
||||
}
|
||||
return this.addresses;
|
||||
}
|
||||
|
||||
async resolve6(domain: string): Promise<Array<string>> {
|
||||
this.lookups.push(`aaaa:${domain}`);
|
||||
if (this.addressErrorCode) {
|
||||
throw dnsError(this.addressErrorCode);
|
||||
}
|
||||
return this.addresses;
|
||||
}
|
||||
}
|
||||
|
||||
describe('EmailDnsValidationService', () => {
|
||||
it('skips the lookup when the instance sends no mail', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ENOTFOUND';
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => false,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual([]);
|
||||
});
|
||||
|
||||
it('looks the domain up when the instance sends mail', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual(['mx:gmail.com']);
|
||||
});
|
||||
|
||||
it('follows the env email flag when no gate is supplied and the operator set nothing', async () => {
|
||||
expect(Config.email.enabled).toBe(true);
|
||||
const resolver = new FakeDnsResolver();
|
||||
const service = new EmailDnsValidationService({resolver, enforceInTestMode: true});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual(['mx:gmail.com']);
|
||||
});
|
||||
|
||||
it('follows the runtime instance email setting over the env flag', async () => {
|
||||
expect(Config.email.enabled).toBe(true);
|
||||
await getInstanceConfigRepository().setInstanceIntegrationsConfig({email: {enabled: false}});
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ENOTFOUND';
|
||||
const service = new EmailDnsValidationService({resolver, enforceInTestMode: true});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual([]);
|
||||
});
|
||||
|
||||
it('still rejects a syntactically broken address when the instance sends no mail', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => false,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('probe@')).toBe(false);
|
||||
expect(resolver.lookups).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects a domain that does not exist', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ENOTFOUND';
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(resolver.lookups).toEqual(['mx:asdf.asdf']);
|
||||
});
|
||||
|
||||
it('accepts a domain that publishes address records but no mail records', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ENODATA';
|
||||
resolver.addressErrorCode = null;
|
||||
resolver.addresses = ['198.51.100.10'];
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual(['mx:mail-less.test', 'a:mail-less.test', 'aaaa:mail-less.test']);
|
||||
});
|
||||
|
||||
it('rejects a domain that publishes neither mail nor address records', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ENODATA';
|
||||
resolver.addressErrorCode = 'ENODATA';
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(resolver.lookups).toEqual(['mx:no-records.test', 'a:no-records.test', 'aaaa:no-records.test']);
|
||||
});
|
||||
|
||||
it('allows the address when the resolver fails transiently', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ESERVFAIL';
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not cache a transient failure as a verified domain', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ESERVFAIL';
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
positiveTtlMs: ms('30 minutes'),
|
||||
negativeTtlMs: 0,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual(['mx:asdf.asdf', 'mx:asdf.asdf']);
|
||||
});
|
||||
|
||||
it('caches a verified domain for the positive ttl', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
positiveTtlMs: ms('30 minutes'),
|
||||
negativeTtlMs: 0,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(resolver.lookups).toEqual(['mx:gmail.com']);
|
||||
});
|
||||
|
||||
it('gives up on a stalled resolver instead of hanging', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.stall = true;
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
lookupTimeoutMs: 10,
|
||||
});
|
||||
const startedAtMs = Date.now();
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(true);
|
||||
expect(Date.now() - startedAtMs).toBeLessThan(ms('5 seconds'));
|
||||
expect(resolver.lookups).toEqual(['mx:stalled.test']);
|
||||
});
|
||||
|
||||
it('bounds the domain cache', async () => {
|
||||
const resolver = new FakeDnsResolver();
|
||||
resolver.mxErrorCode = 'ENOTFOUND';
|
||||
const service = new EmailDnsValidationService({
|
||||
resolver,
|
||||
enforceInTestMode: true,
|
||||
isEmailEnabled: async () => true,
|
||||
maxCachedDomains: 2,
|
||||
});
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(resolver.lookups).toEqual(['mx:first.test', 'mx:second.test', 'mx:third.test']);
|
||||
expect(await service.hasValidDnsRecords('[email protected]')).toBe(false);
|
||||
expect(resolver.lookups).toEqual(['mx:first.test', 'mx:second.test', 'mx:third.test', 'mx:first.test']);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import {AccessToken, TrackSource} from 'livekit-server-sdk';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {computeLiveKitPublishSources, VOICE_TOKEN_TTL_SECONDS} from '../LiveKitService';
|
||||
import {createChannelID, createGuildID} from '../../BrandedTypes';
|
||||
import {computeLiveKitPublishSources, LiveKitService, VOICE_TOKEN_TTL_SECONDS} from '../LiveKitService';
|
||||
|
||||
function decodeJwtPayload(token: string): Record<string, unknown> {
|
||||
const [, payload] = token.split('.');
|
||||
@@ -57,3 +58,78 @@ describe('LiveKitService publish permissions', () => {
|
||||
expect(exp - nowSeconds).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
class FakeTwirpError extends Error {
|
||||
status: number;
|
||||
code?: string;
|
||||
constructor(message: string, status: number, code?: string) {
|
||||
super(message);
|
||||
this.name = 'TwirpError';
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function createServiceWithRoomServiceClient(roomServiceClient: unknown): LiveKitService {
|
||||
const service = Object.create(LiveKitService.prototype) as LiveKitService;
|
||||
Reflect.set(
|
||||
service,
|
||||
'serverClients',
|
||||
new Map([
|
||||
[
|
||||
'region-1',
|
||||
new Map([
|
||||
[
|
||||
'region-1-server-1',
|
||||
{
|
||||
endpoint: 'ws://livekit.test/livekit',
|
||||
apiKey: 'test-key',
|
||||
apiSecret: 'test-secret',
|
||||
isActive: true,
|
||||
roomServiceClient,
|
||||
},
|
||||
],
|
||||
]),
|
||||
],
|
||||
]),
|
||||
);
|
||||
return service;
|
||||
}
|
||||
|
||||
describe('LiveKitService listParticipants', () => {
|
||||
const params = {
|
||||
guildId: createGuildID(1n),
|
||||
channelId: createChannelID(2n),
|
||||
regionId: 'region-1',
|
||||
serverId: 'region-1-server-1',
|
||||
};
|
||||
|
||||
it('reports a 404 as an unreadable room instead of an empty one', async () => {
|
||||
const service = createServiceWithRoomServiceClient({
|
||||
listParticipants: async () => {
|
||||
throw new FakeTwirpError('not_found', 404, 'not_found');
|
||||
},
|
||||
});
|
||||
const result = await service.listParticipants(params);
|
||||
expect(result.status).toBe('error');
|
||||
});
|
||||
|
||||
it('reports a bad_route 404 as an unreadable room instead of an empty one', async () => {
|
||||
const service = createServiceWithRoomServiceClient({
|
||||
listParticipants: async () => {
|
||||
throw new FakeTwirpError('invalid path prefix', 404, 'bad_route');
|
||||
},
|
||||
});
|
||||
const result = await service.listParticipants(params);
|
||||
expect(result.status).toBe('error');
|
||||
expect(result.status === 'error' && result.retryable).toBe(false);
|
||||
});
|
||||
|
||||
it('still reports a genuinely empty room as empty', async () => {
|
||||
const service = createServiceWithRoomServiceClient({
|
||||
listParticipants: async () => [],
|
||||
});
|
||||
const result = await service.listParticipants(params);
|
||||
expect(result).toEqual({status: 'ok', participants: []});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Config} from '../Config';
|
||||
|
||||
let cachedCollectDateOfBirth: boolean | null = null;
|
||||
|
||||
export function getDefaultDateOfBirthCollection(): boolean {
|
||||
return !Config.instance.selfHosted;
|
||||
}
|
||||
|
||||
export function instanceCollectsDateOfBirth(): boolean {
|
||||
return cachedCollectDateOfBirth ?? getDefaultDateOfBirthCollection();
|
||||
}
|
||||
|
||||
export function setCachedDateOfBirthCollection(collect: boolean): void {
|
||||
cachedCollectDateOfBirth = collect;
|
||||
}
|
||||
@@ -63,8 +63,6 @@ describe('GatewayRolloutConfigPublisher', () => {
|
||||
gateway_dispatch_relay_shards: 32,
|
||||
gateway_dispatch_relay_max_queue: 50000,
|
||||
voice_e2ee_scope: 'guild_feature_only',
|
||||
voice_reconciliation_v3_percentage: 100,
|
||||
voice_reconciliation_v3_interval_ms: 2000,
|
||||
};
|
||||
|
||||
await publisher.publish(config);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {resolveDeferredPhoneGateEnabled, setCachedDeferredPhoneGateEnabled} from
|
||||
import {InstanceConfiguration} from '../Tables';
|
||||
import {DEFAULT_DECAY_CONSTANTS, DEFAULT_RENEWAL_CONSTANTS} from '../utils/AttachmentDecay';
|
||||
import {isJsonRecord, parseJsonArray, parseJsonRecord} from '../utils/JsonBoundaryUtils';
|
||||
import {getDefaultDateOfBirthCollection, setCachedDateOfBirthCollection} from './DateOfBirthCollectionCache';
|
||||
import {normalizeSsoAllowedEmailDomains} from './SsoConfigValidation';
|
||||
|
||||
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
|
||||
@@ -41,8 +42,6 @@ const DEFAULT_GATEWAY_ROLLOUT_CONFIG: GatewayRolloutConfig = {
|
||||
gateway_dispatch_relay_shards: 32,
|
||||
gateway_dispatch_relay_max_queue: 50000,
|
||||
voice_e2ee_scope: 'guild_feature_only',
|
||||
voice_reconciliation_v3_percentage: 100,
|
||||
voice_reconciliation_v3_interval_ms: 2000,
|
||||
};
|
||||
export type InstanceRegistrationMode = 'open' | 'approval' | 'closed';
|
||||
export interface InstanceRegistrationConfig {
|
||||
@@ -357,11 +356,21 @@ function getDefaultAppPublicConfig(): InstanceAppPublicConfig {
|
||||
privacy_url: null,
|
||||
},
|
||||
registration: {
|
||||
collect_date_of_birth: !Config.instance.selfHosted,
|
||||
collect_date_of_birth: getDefaultDateOfBirthCollection(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseAppPublicConfig(raw: string): InstanceAppPublicConfig {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return normalizeAppPublicConfig(parsed);
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Invalid app public config JSON, returning defaults');
|
||||
return getDefaultAppPublicConfig();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAppPublicConfig(value: unknown): InstanceAppPublicConfig {
|
||||
const defaults = getDefaultAppPublicConfig();
|
||||
if (!isJsonRecord(value)) {
|
||||
@@ -939,6 +948,7 @@ export class InstanceConfigRepository {
|
||||
this.configCache = await this.fetchAllConfigsFromDatabase();
|
||||
} while (this.refreshRequested);
|
||||
this.syncDeferredPhoneGateCache(this.configCache.get(INSTANCE_POLICY_CONFIG_KEY) ?? null);
|
||||
this.syncDateOfBirthCollectionCache(this.configCache.get(APP_PUBLIC_CONFIG_KEY) ?? null);
|
||||
})().finally(() => {
|
||||
this.refreshPromise = null;
|
||||
});
|
||||
@@ -950,6 +960,11 @@ export class InstanceConfigRepository {
|
||||
setCachedDeferredPhoneGateEnabled(resolveDeferredPhoneGateEnabled(policy));
|
||||
}
|
||||
|
||||
private syncDateOfBirthCollectionCache(raw: string | null): void {
|
||||
const appPublic = raw ? normalizeAppPublicConfig(parseJsonRecord(raw)) : getDefaultAppPublicConfig();
|
||||
setCachedDateOfBirthCollection(appPublic.registration.collect_date_of_birth);
|
||||
}
|
||||
|
||||
private updateCachedConfigs(entries: Array<[string, string]>): void {
|
||||
if (!this.configCache) {
|
||||
return;
|
||||
@@ -1067,16 +1082,9 @@ export class InstanceConfigRepository {
|
||||
|
||||
async getAppPublicConfig(): Promise<InstanceAppPublicConfig> {
|
||||
const raw = await this.getConfig(APP_PUBLIC_CONFIG_KEY);
|
||||
if (!raw) {
|
||||
return getDefaultAppPublicConfig();
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return normalizeAppPublicConfig(parsed);
|
||||
} catch (error) {
|
||||
Logger.warn({error}, 'Invalid app public config JSON, returning defaults');
|
||||
return getDefaultAppPublicConfig();
|
||||
}
|
||||
const config = raw ? parseAppPublicConfig(raw) : getDefaultAppPublicConfig();
|
||||
setCachedDateOfBirthCollection(config.registration.collect_date_of_birth);
|
||||
return config;
|
||||
}
|
||||
|
||||
async setAppPublicConfig(config: {
|
||||
@@ -1105,6 +1113,7 @@ export class InstanceConfigRepository {
|
||||
},
|
||||
});
|
||||
await this.setConfig(APP_PUBLIC_CONFIG_KEY, JSON.stringify(next));
|
||||
setCachedDateOfBirthCollection(next.registration.collect_date_of_birth);
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {HonoEnv} from '../types/HonoEnv';
|
||||
import {parseJsonRecord} from '../utils/JsonBoundaryUtils';
|
||||
import {ipBanCache} from './IpBanMiddleware';
|
||||
import {getIpInfoService} from './ServiceMiddleware';
|
||||
import {getKVClient} from './ServiceRegistry';
|
||||
import {getCacheService} from './ServiceSingletons';
|
||||
|
||||
type IpClass = 'datacenter' | 'anonymous' | 'mobile' | 'residential' | 'unknown';
|
||||
@@ -51,6 +52,22 @@ interface AbuseSignalOptions {
|
||||
weight?: number;
|
||||
}
|
||||
|
||||
interface PeerIpClassHint {
|
||||
ipClass: IpClass;
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
interface OutboundIpClass {
|
||||
lookupIp: string;
|
||||
ipClass: IpClass;
|
||||
}
|
||||
|
||||
interface ResolvedBanClass {
|
||||
ipClass: IpClass;
|
||||
authoritative: boolean;
|
||||
blocked: boolean;
|
||||
}
|
||||
|
||||
const WINDOW_MS = positiveNumberFromEnv('FLUXER_ABUSE_WINDOW_MS', 60_000);
|
||||
const THRESHOLD_DATACENTER = positiveNumberFromEnv('FLUXER_ABUSE_THRESHOLD_DATACENTER', 20);
|
||||
const THRESHOLD_ANONYMOUS = positiveNumberFromEnv('FLUXER_ABUSE_THRESHOLD_ANONYMOUS', 500);
|
||||
@@ -73,6 +90,14 @@ const REQUIRED_SCORE_WINDOWS_FOR_AUTO_BAN = positiveNumberFromEnv(
|
||||
3,
|
||||
);
|
||||
const REPLICATION_CHANNEL = 'abuse_tracker:ticks';
|
||||
const IP_CLASS_CHANNEL = 'abuse_tracker:ipclass';
|
||||
const IP_CLASS_CLAIM_PREFIX = 'abuse:ipclass:claim:';
|
||||
const IP_CLASS_CLAIM_ENABLED = process.env.FLUXER_ABUSE_IP_CLASS_CLAIM_ENABLED !== '0';
|
||||
const IP_CLASS_CLAIM_TTL_SECONDS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_CLAIM_TTL_SEC', 15);
|
||||
const DEFAULT_IP_CLASS_PENDING_TTL_MS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_PENDING_TTL_MS', 20_000);
|
||||
const DEFAULT_IP_CLASS_NEGATIVE_TTL_MS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_NEGATIVE_TTL_MS', 300_000);
|
||||
const DEFAULT_IP_CLASS_HINT_TTL_MS = positiveNumberFromEnv('FLUXER_ABUSE_IP_CLASS_HINT_TTL_MS', 600_000);
|
||||
const IP_CLASSES = ['datacenter', 'anonymous', 'mobile', 'residential', 'unknown'] as const;
|
||||
const POD_ID = process.env.HOSTNAME ?? randomUUID();
|
||||
|
||||
type ReplicatedTick = [banKey: string, scoreDelta: number, tokenHashes: Array<string>, lookupIp: string];
|
||||
@@ -83,11 +108,23 @@ interface ReplicationMessage {
|
||||
ts: number;
|
||||
}
|
||||
|
||||
type IpClassEntry = [banKey: string, lookupIp: string, ipClass: IpClass];
|
||||
|
||||
interface IpClassMessage {
|
||||
sender: string;
|
||||
entries: Array<IpClassEntry>;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
const records = new Map<string, AbuseRecord>();
|
||||
const outboundDeltas = new Map<string, OutboundEntry>();
|
||||
const persistentScoreWindows = new Map<string, PersistentScoreState>();
|
||||
const ipClassCache = new Map<string, IpClass>();
|
||||
const ipClassPending = new Set<string>();
|
||||
const ipClassPending = new Map<string, number>();
|
||||
const ipClassNegativeUntil = new Map<string, number>();
|
||||
const peerIpClassHints = new Map<string, PeerIpClassHint>();
|
||||
const outboundIpClasses = new Map<string, OutboundIpClass>();
|
||||
const pendingIpClassTasks = new Set<Promise<void>>();
|
||||
const recordedClientErrorRequests = new WeakSet<Request>();
|
||||
const pendingAutoBanTasks = new Set<Promise<void>>();
|
||||
const adminRepository = new AdminRepository();
|
||||
@@ -97,6 +134,9 @@ let flushTimer: NodeJS.Timeout | null = null;
|
||||
let kvSubscription: IKVSubscription | null = null;
|
||||
let messageHandler: ((channel: string, message: string) => void) | null = null;
|
||||
let errorHandler: ((error: Error) => void) | null = null;
|
||||
let ipClassPendingTtlMs = DEFAULT_IP_CLASS_PENDING_TTL_MS;
|
||||
let ipClassNegativeTtlMs = DEFAULT_IP_CLASS_NEGATIVE_TTL_MS;
|
||||
let ipClassHintTtlMs = DEFAULT_IP_CLASS_HINT_TTL_MS;
|
||||
|
||||
function positiveNumberFromEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
@@ -164,13 +204,99 @@ function shouldSkipAutoBanForIpClass(ipClass: IpClass): boolean {
|
||||
return ipClass === 'mobile';
|
||||
}
|
||||
|
||||
function isIpClass(value: unknown): value is IpClass {
|
||||
return typeof value === 'string' && (IP_CLASSES as ReadonlyArray<string>).includes(value);
|
||||
}
|
||||
|
||||
function getOwnIpClass(key: string, now: number): IpClass | null {
|
||||
const cached = ipClassCache.get(key);
|
||||
if (cached === undefined) return null;
|
||||
const negativeUntilMs = ipClassNegativeUntil.get(key);
|
||||
if (negativeUntilMs !== undefined && negativeUntilMs <= now) {
|
||||
ipClassCache.delete(key);
|
||||
ipClassNegativeUntil.delete(key);
|
||||
return null;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
function isOwnIpClassNegative(key: string, now: number): boolean {
|
||||
const negativeUntilMs = ipClassNegativeUntil.get(key);
|
||||
return negativeUntilMs !== undefined && negativeUntilMs > now;
|
||||
}
|
||||
|
||||
function setOwnIpClass(key: string, lookupIp: string, ipClass: IpClass, negative: boolean): void {
|
||||
ipClassCache.set(key, ipClass);
|
||||
if (negative) {
|
||||
ipClassNegativeUntil.set(key, Date.now() + ipClassNegativeTtlMs);
|
||||
} else {
|
||||
ipClassNegativeUntil.delete(key);
|
||||
peerIpClassHints.delete(key);
|
||||
}
|
||||
ipClassPending.delete(key);
|
||||
if (!negative && ipClass !== 'unknown') {
|
||||
queueOutboundIpClass(key, lookupIp, ipClass);
|
||||
}
|
||||
const rec = records.get(key);
|
||||
if (rec) maybeFireAutoBan(key, rec);
|
||||
}
|
||||
|
||||
function isIpClassPending(key: string, now: number): boolean {
|
||||
const expiresAtMs = ipClassPending.get(key);
|
||||
if (expiresAtMs === undefined) return false;
|
||||
if (expiresAtMs <= now) {
|
||||
ipClassPending.delete(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getPeerClassHint(key: string, now: number): IpClass | null {
|
||||
const hint = peerIpClassHints.get(key);
|
||||
if (!hint) return null;
|
||||
if (hint.expiresAtMs <= now) {
|
||||
peerIpClassHints.delete(key);
|
||||
return null;
|
||||
}
|
||||
return hint.ipClass;
|
||||
}
|
||||
|
||||
function recordPeerClassHint(key: string, ipClass: IpClass): void {
|
||||
peerIpClassHints.set(key, {ipClass, expiresAtMs: Date.now() + ipClassHintTtlMs});
|
||||
}
|
||||
|
||||
function isStricterThanUnknown(ipClass: IpClass): boolean {
|
||||
return (
|
||||
scoreThresholdFor(ipClass) <= scoreThresholdFor('unknown') &&
|
||||
tokenDiversityThresholdFor(ipClass) <= tokenDiversityThresholdFor('unknown')
|
||||
);
|
||||
}
|
||||
|
||||
function resolveClassForBan(key: string, now: number): ResolvedBanClass {
|
||||
const own = getOwnIpClass(key, now);
|
||||
if (own !== null && !isOwnIpClassNegative(key, now)) return {ipClass: own, authoritative: true, blocked: false};
|
||||
const hint = getPeerClassHint(key, now);
|
||||
if (hint !== null && isStricterThanUnknown(hint)) return {ipClass: hint, authoritative: true, blocked: false};
|
||||
if (hint !== null) return {ipClass: 'unknown', authoritative: false, blocked: true};
|
||||
if (own !== null) return {ipClass: own, authoritative: true, blocked: false};
|
||||
return {ipClass: 'unknown', authoritative: false, blocked: isIpClassPending(key, now)};
|
||||
}
|
||||
|
||||
function pruneIfNeeded(now: number): void {
|
||||
if (records.size < MAX_TRACKED_IPS) return;
|
||||
for (const [key, expiresAtMs] of ipClassPending) {
|
||||
if (expiresAtMs <= now) ipClassPending.delete(key);
|
||||
}
|
||||
for (const [key, hint] of peerIpClassHints) {
|
||||
if (hint.expiresAtMs <= now) peerIpClassHints.delete(key);
|
||||
}
|
||||
for (const [key, rec] of records) {
|
||||
if (rec.windowStartMs + WINDOW_MS < now) {
|
||||
if (rec.windowStartMs + WINDOW_MS < now && !ipClassPending.has(key)) {
|
||||
records.delete(key);
|
||||
ipClassCache.delete(key);
|
||||
ipClassPending.delete(key);
|
||||
ipClassNegativeUntil.delete(key);
|
||||
peerIpClassHints.delete(key);
|
||||
outboundIpClasses.delete(key);
|
||||
}
|
||||
if (records.size < MAX_TRACKED_IPS * 0.9) return;
|
||||
}
|
||||
@@ -222,9 +348,11 @@ function queueOutboundDelta(
|
||||
}
|
||||
}
|
||||
|
||||
function shouldEnsureIpClassLookup(key: string, rec: AbuseRecord): boolean {
|
||||
if (ipClassCache.has(key) || ipClassPending.has(key)) return false;
|
||||
return rec.score >= MIN_SCORE_FOR_IP_LOOKUP || rec.distinctTokenHashes.size >= MIN_TOKENS_FOR_IP_LOOKUP;
|
||||
function shouldEnsureIpClassLookup(key: string, rec: AbuseRecord, now: number): boolean {
|
||||
if (getOwnIpClass(key, now) !== null || isIpClassPending(key, now)) return false;
|
||||
if (getPeerClassHint(key, now) !== null) return false;
|
||||
if (rec.score < MIN_SCORE_FOR_IP_LOOKUP && rec.distinctTokenHashes.size < MIN_TOKENS_FOR_IP_LOOKUP) return false;
|
||||
return ipBanCache.getMatch(rec.lookupIp) === null;
|
||||
}
|
||||
|
||||
function markScoreThresholdWindow(key: string, rec: AbuseRecord, now: number): number {
|
||||
@@ -247,33 +375,53 @@ function markScoreThresholdWindow(key: string, rec: AbuseRecord, now: number): n
|
||||
return state.count;
|
||||
}
|
||||
|
||||
async function claimIpClassLookup(key: string): Promise<boolean> {
|
||||
if (!IP_CLASS_CLAIM_ENABLED) return true;
|
||||
if (!kvPublisher) return true;
|
||||
try {
|
||||
return await getKVClient().setnx(`${IP_CLASS_CLAIM_PREFIX}${key}`, POD_ID, IP_CLASS_CLAIM_TTL_SECONDS);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function runIpClassLookup(key: string, lookupIp: string): Promise<void> {
|
||||
try {
|
||||
if (!(await claimIpClassLookup(key))) return;
|
||||
const result = await getIpInfoService().lookup(lookupIp, {source: 'AbusiveIpAutoBanner', reason: 'classify'});
|
||||
setOwnIpClass(key, lookupIp, classifyIpInfo(result), !result.available);
|
||||
} catch (err) {
|
||||
setOwnIpClass(key, lookupIp, 'unknown', true);
|
||||
Logger.warn({err, ip: lookupIp}, '[abuse-auto-ban] IP classification lookup failed');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureIpClassLookup(key: string, lookupIp: string): void {
|
||||
if (ipClassCache.has(key) || ipClassPending.has(key)) return;
|
||||
ipClassPending.add(key);
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await getIpInfoService().lookup(lookupIp, {source: 'AbusiveIpAutoBanner', reason: 'classify'});
|
||||
ipClassCache.set(key, classifyIpInfo(result));
|
||||
} catch (err) {
|
||||
ipClassCache.set(key, 'unknown');
|
||||
Logger.warn({err, ip: lookupIp}, '[abuse-auto-ban] IP classification lookup failed');
|
||||
} finally {
|
||||
ipClassPending.delete(key);
|
||||
const rec = records.get(key);
|
||||
if (rec) maybeFireAutoBan(key, rec);
|
||||
}
|
||||
})();
|
||||
const now = Date.now();
|
||||
if (getOwnIpClass(key, now) !== null || isIpClassPending(key, now)) return;
|
||||
ipClassPending.set(key, now + ipClassPendingTtlMs);
|
||||
const task = runIpClassLookup(key, lookupIp);
|
||||
pendingIpClassTasks.add(task);
|
||||
void task.finally(() => {
|
||||
pendingIpClassTasks.delete(task);
|
||||
});
|
||||
}
|
||||
|
||||
function maybeFireAutoBan(key: string, rec: AbuseRecord): void {
|
||||
if (rec.autoBanFired) return;
|
||||
const ipClass = ipClassCache.get(key) ?? 'unknown';
|
||||
const now = Date.now();
|
||||
if (ipBanCache.getMatch(rec.lookupIp) !== null) {
|
||||
rec.autoBanFired = true;
|
||||
return;
|
||||
}
|
||||
const resolved = resolveClassForBan(key, now);
|
||||
const ipClass = resolved.ipClass;
|
||||
const scoreThreshold = scoreThresholdFor(ipClass);
|
||||
const tokenThreshold = tokenDiversityThresholdFor(ipClass);
|
||||
const overScore = rec.score >= scoreThreshold;
|
||||
const overTokenDiversity = rec.distinctTokenHashes.size >= tokenThreshold;
|
||||
if (!overScore && !overTokenDiversity) return;
|
||||
if (!ipClassCache.has(key) && ipClassPending.has(key)) {
|
||||
if (resolved.blocked) {
|
||||
return;
|
||||
}
|
||||
if (shouldSkipAutoBanForIpClass(ipClass)) {
|
||||
@@ -369,7 +517,7 @@ export function recordAbuseSignal(ip: string | null, reason: string, opts: Abuse
|
||||
queuedTokenHash = opts.tokenHash;
|
||||
}
|
||||
queueOutboundDelta(signalIp, weight, queuedTokenHash, hadToken);
|
||||
if (shouldEnsureIpClassLookup(signalIp.banKey, rec)) {
|
||||
if (shouldEnsureIpClassLookup(signalIp.banKey, rec, now)) {
|
||||
ensureIpClassLookup(signalIp.banKey, signalIp.lookupIp);
|
||||
}
|
||||
maybeFireAutoBan(signalIp.banKey, rec);
|
||||
@@ -404,7 +552,7 @@ function applyReplicatedTick(tick: ReplicatedTick): void {
|
||||
if (rec.distinctTokenHashes.size >= MAX_TOKEN_HASHES_PER_IP) break;
|
||||
rec.distinctTokenHashes.add(tokenHash);
|
||||
}
|
||||
if (shouldEnsureIpClassLookup(banKey, rec)) {
|
||||
if (shouldEnsureIpClassLookup(banKey, rec, now)) {
|
||||
ensureIpClassLookup(banKey, lookupIp);
|
||||
}
|
||||
maybeFireAutoBan(banKey, rec);
|
||||
@@ -435,7 +583,56 @@ async function flushOutbound(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function queueOutboundIpClass(key: string, lookupIp: string, ipClass: IpClass): void {
|
||||
if (!kvPublisher) return;
|
||||
if (!outboundIpClasses.has(key) && outboundIpClasses.size >= MAX_TRACKED_IPS) return;
|
||||
outboundIpClasses.set(key, {lookupIp, ipClass});
|
||||
}
|
||||
|
||||
async function flushOutboundIpClasses(): Promise<void> {
|
||||
if (!kvPublisher || outboundIpClasses.size === 0) return;
|
||||
const entries: Array<IpClassEntry> = [];
|
||||
const selectedKeys: Array<string> = [];
|
||||
for (const [key, entry] of outboundIpClasses) {
|
||||
entries.push([key, entry.lookupIp, entry.ipClass]);
|
||||
selectedKeys.push(key);
|
||||
if (entries.length >= MAX_BATCH_TICKS) break;
|
||||
}
|
||||
const message: IpClassMessage = {sender: POD_ID, entries, ts: Date.now()};
|
||||
try {
|
||||
await kvPublisher.publish(IP_CLASS_CHANNEL, JSON.stringify(message));
|
||||
for (const key of selectedKeys) {
|
||||
outboundIpClasses.delete(key);
|
||||
}
|
||||
} catch (err) {
|
||||
Logger.warn({err, entryCount: entries.length}, '[abuse-auto-ban] Failed to publish abuse IP class batch');
|
||||
}
|
||||
}
|
||||
|
||||
function handleIpClassMessage(message: string): void {
|
||||
const msg = parseJsonRecord(message);
|
||||
if (!msg || msg.sender === POD_ID || !Array.isArray(msg.entries)) return;
|
||||
for (const rawEntry of msg.entries) {
|
||||
if (!Array.isArray(rawEntry) || rawEntry.length < 3) continue;
|
||||
const [banKey, lookupIp, ipClass] = rawEntry;
|
||||
if (typeof banKey !== 'string' || typeof lookupIp !== 'string' || !isIpClass(ipClass)) continue;
|
||||
if (ipClass === 'unknown') continue;
|
||||
const signalIp = normalizeSignalIp(lookupIp);
|
||||
if (!signalIp || signalIp.banKey !== banKey) continue;
|
||||
const rec = records.get(banKey);
|
||||
if (!rec) continue;
|
||||
const now = Date.now();
|
||||
if (getOwnIpClass(banKey, now) !== null && !isOwnIpClassNegative(banKey, now)) continue;
|
||||
recordPeerClassHint(banKey, ipClass);
|
||||
maybeFireAutoBan(banKey, rec);
|
||||
}
|
||||
}
|
||||
|
||||
function handleReplicationMessage(channel: string, message: string): void {
|
||||
if (channel === IP_CLASS_CHANNEL) {
|
||||
handleIpClassMessage(message);
|
||||
return;
|
||||
}
|
||||
if (channel !== REPLICATION_CHANNEL) return;
|
||||
const msg = parseJsonRecord(message);
|
||||
if (!msg || msg.sender === POD_ID || !Array.isArray(msg.ticks)) return;
|
||||
@@ -469,11 +666,12 @@ export async function startAbuseReplicationSubscriber(kvClient: IKVProvider | nu
|
||||
};
|
||||
try {
|
||||
await subscription.connect();
|
||||
await subscription.subscribe(REPLICATION_CHANNEL);
|
||||
await subscription.subscribe(REPLICATION_CHANNEL, IP_CLASS_CHANNEL);
|
||||
subscription.on('message', messageHandler);
|
||||
subscription.on('error', errorHandler);
|
||||
flushTimer = setInterval(() => {
|
||||
void flushOutbound();
|
||||
void flushOutboundIpClasses();
|
||||
}, BATCH_FLUSH_MS);
|
||||
if (typeof flushTimer === 'object' && flushTimer && 'unref' in flushTimer) {
|
||||
(flushTimer as {unref(): void}).unref();
|
||||
@@ -514,11 +712,28 @@ export async function drainAbuseAutoBanTasksForTests(): Promise<void> {
|
||||
await Promise.all([...pendingAutoBanTasks]);
|
||||
}
|
||||
|
||||
export async function drainAbuseIpClassLookupsForTests(): Promise<void> {
|
||||
await Promise.all([...pendingIpClassTasks]);
|
||||
}
|
||||
|
||||
export function setAbuseIpClassTtlsForTests(opts: {negativeMs?: number; hintMs?: number; pendingMs?: number}): void {
|
||||
if (opts.negativeMs !== undefined) ipClassNegativeTtlMs = opts.negativeMs;
|
||||
if (opts.hintMs !== undefined) ipClassHintTtlMs = opts.hintMs;
|
||||
if (opts.pendingMs !== undefined) ipClassPendingTtlMs = opts.pendingMs;
|
||||
}
|
||||
|
||||
export function resetAbuseTrackingForTests(): void {
|
||||
records.clear();
|
||||
outboundDeltas.clear();
|
||||
persistentScoreWindows.clear();
|
||||
ipClassCache.clear();
|
||||
ipClassPending.clear();
|
||||
ipClassNegativeUntil.clear();
|
||||
peerIpClassHints.clear();
|
||||
outboundIpClasses.clear();
|
||||
pendingIpClassTasks.clear();
|
||||
pendingAutoBanTasks.clear();
|
||||
ipClassPendingTtlMs = DEFAULT_IP_CLASS_PENDING_TTL_MS;
|
||||
ipClassNegativeTtlMs = DEFAULT_IP_CLASS_NEGATIVE_TTL_MS;
|
||||
ipClassHintTtlMs = DEFAULT_IP_CLASS_HINT_TTL_MS;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import {lookupAsnByIp, lookupGeoipByIp} from '@pkgs/geoip/src/GeoipLookup';
|
||||
import {createIpInfoService, createUnavailableIpInfoService, type IpInfoService} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {createMiddleware} from 'hono/factory';
|
||||
import type {ApiContext} from '../ApiContext';
|
||||
@@ -58,8 +59,14 @@ import {createIpInfoChecker} from '../risk/adapters/IpInfoAdapter';
|
||||
import {createReverseDnsLookup} from '../risk/adapters/ReverseDnsAdapter';
|
||||
import {DeterministicRiskEngine} from '../risk/DeterministicRiskEngine';
|
||||
import {CassandraHistoricalOutcomeRepository} from '../risk/HistoricalOutcomeRepository';
|
||||
import {createKvIpInfoLookupBudget} from '../risk/IpInfoBudget';
|
||||
import {buildIpInfoCache, buildIpInfoRequestAuditLogger} from '../risk/IpInfoCacheFactory';
|
||||
import {CassandraRegistrationEventsRepository} from '../risk/RegistrationEventsRepository';
|
||||
import {
|
||||
type IpInfoPrescreenVerdict,
|
||||
ipInfoPrescreenOptionsFromEnv,
|
||||
prescreenIpInfoLookup,
|
||||
} from '../risk/RegistrationIpPrescreen';
|
||||
import {CassandraRiskAssessmentRepository} from '../risk/RiskAssessmentRepository';
|
||||
import {createRiskToolbox} from '../risk/RiskToolboxFactory';
|
||||
import {CassandraSuspiciousIpRepository} from '../risk/SuspiciousIpRepository';
|
||||
@@ -268,6 +275,7 @@ export function getIpInfoService(): IpInfoService {
|
||||
apiKey: Config.risk.ipinfoApiKey,
|
||||
cache,
|
||||
auditLogger: buildIpInfoRequestAuditLogger(),
|
||||
budget: createKvIpInfoLookupBudget({getKvClient: getKVClient}),
|
||||
});
|
||||
return _ipInfoService;
|
||||
}
|
||||
@@ -293,7 +301,14 @@ function getRegistrationRiskEvaluator(): IRegistrationRiskEvaluator {
|
||||
return _registrationRiskEvaluator;
|
||||
}
|
||||
const ipInfoService = getIpInfoService();
|
||||
const ipInfoChecker = Config.risk.ipinfoApiKey ? createIpInfoChecker({ipInfoService}) : undefined;
|
||||
const lookupLocalCity = (ip: string) => lookupGeoipByIp(ip, Config.geoip.maxmindDbPath);
|
||||
const lookupLocalAsn = (ip: string) => lookupAsnByIp(ip, Config.geoip.maxmindAsnDbPath);
|
||||
const prescreenOptions = ipInfoPrescreenOptionsFromEnv();
|
||||
const prescreen = async (ip: string): Promise<IpInfoPrescreenVerdict> => {
|
||||
const [city, asn] = await Promise.all([lookupLocalCity(ip), lookupLocalAsn(ip)]);
|
||||
return prescreenIpInfoLookup({countryIso: city.countryCode, asn: asn.asn, asnOrg: asn.asnOrg}, prescreenOptions);
|
||||
};
|
||||
const ipInfoChecker = Config.risk.ipinfoApiKey ? createIpInfoChecker({ipInfoService, prescreen}) : undefined;
|
||||
const cacheService = getCacheService();
|
||||
const reverseDnsLookup = createReverseDnsLookup({cacheService});
|
||||
const toolbox = createRiskToolbox({
|
||||
@@ -305,6 +320,8 @@ function getRegistrationRiskEvaluator(): IRegistrationRiskEvaluator {
|
||||
historicalOutcomeRepository: getHistoricalOutcomeRepository(),
|
||||
suspiciousIpRepository: getSuspiciousIpRepository(),
|
||||
cacheService,
|
||||
lookupLocalCity,
|
||||
lookupLocalAsn,
|
||||
});
|
||||
const engine = new DeterministicRiskEngine(toolbox, {
|
||||
logger: Logger,
|
||||
|
||||
@@ -28,6 +28,7 @@ import {setInjectedSearchProvider} from '../SearchFactory';
|
||||
import type {ISearchProvider} from '../search/ISearchProvider';
|
||||
import {VoiceAvailabilityService} from '../voice/VoiceAvailabilityService';
|
||||
import {VoiceRepository} from '../voice/VoiceRepository';
|
||||
import {VoiceServerLoadTracker} from '../voice/VoiceServerLoad';
|
||||
import {VoiceTopology} from '../voice/VoiceTopology';
|
||||
import type {WorkerTaskName} from '../worker/WorkerLaneConfig';
|
||||
|
||||
@@ -289,7 +290,10 @@ export async function ensureVoiceResourcesInitialized(): Promise<void> {
|
||||
const topology = new VoiceTopology(voiceRepository, voiceConfigSubscriber);
|
||||
await topology.initialize();
|
||||
voiceTopology = topology;
|
||||
voiceAvailabilityService = new VoiceAvailabilityService(topology);
|
||||
voiceAvailabilityService = new VoiceAvailabilityService(
|
||||
topology,
|
||||
new VoiceServerLoadTracker({gatewayService: getGatewayService()}),
|
||||
);
|
||||
liveKitServiceInstance = new LiveKitService(topology);
|
||||
voiceRoomStoreInstance = new VoiceRoomStore(getKVClient());
|
||||
})().finally(() => {
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {AdminRepository} from '../../admin/AdminRepository';
|
||||
import type {ApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import type {MockKVProvider} from '../../test/mocks/MockKVProvider';
|
||||
import {
|
||||
drainAbuseAutoBanTasksForTests,
|
||||
drainAbuseIpClassLookupsForTests,
|
||||
hashAuthToken,
|
||||
recordAbuseSignal,
|
||||
resetAbuseTrackingForTests,
|
||||
setAbuseIpClassTtlsForTests,
|
||||
startAbuseReplicationSubscriber,
|
||||
stopAbuseReplicationSubscriber,
|
||||
} from '../AbusiveIpAutoBanner';
|
||||
import {ipBanCache} from '../IpBanMiddleware';
|
||||
import {setInjectedIpInfoService} from '../ServiceMiddleware';
|
||||
@@ -65,6 +71,11 @@ function ipInfoResult(ip: string, overrides: Partial<IpInfoLookupResult> = {}):
|
||||
};
|
||||
}
|
||||
|
||||
function claimCallCount(harness: ApiTestHarness, banKey: string): number {
|
||||
const kvProvider = harness.kvProvider as MockKVProvider;
|
||||
return kvProvider.setnxSpy.mock.calls.filter(([key]) => key === `abuse:ipclass:claim:${banKey}`).length;
|
||||
}
|
||||
|
||||
async function waitForAssertion(assertion: () => void): Promise<void> {
|
||||
const deadline = Date.now() + 1000;
|
||||
let lastError: unknown;
|
||||
@@ -84,6 +95,7 @@ async function waitForAssertion(assertion: () => void): Promise<void> {
|
||||
describe('AbusiveIpAutoBanner', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let adminRepository: AdminRepository;
|
||||
let lookupCount = 0;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
adminRepository = new AdminRepository();
|
||||
@@ -92,13 +104,18 @@ describe('AbusiveIpAutoBanner', () => {
|
||||
await harness.reset();
|
||||
resetAbuseTrackingForTests();
|
||||
ipBanCache.resetCaches();
|
||||
lookupCount = 0;
|
||||
setInjectedIpInfoService({
|
||||
async lookup(ip: string) {
|
||||
lookupCount += 1;
|
||||
return ipInfoResult(ip);
|
||||
},
|
||||
});
|
||||
await stopAbuseReplicationSubscriber();
|
||||
await startAbuseReplicationSubscriber(harness.kvProvider);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await stopAbuseReplicationSubscriber();
|
||||
setInjectedIpInfoService(undefined);
|
||||
await harness.shutdown();
|
||||
});
|
||||
@@ -117,7 +134,7 @@ describe('AbusiveIpAutoBanner', () => {
|
||||
it('does not auto-ban after a single score-only spike', async () => {
|
||||
const ip = '8.8.4.4';
|
||||
recordAbuseSignal(ip, 'http_429', {weight: 150});
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
await drainAbuseIpClassLookupsForTests();
|
||||
await drainAbuseAutoBanTasksForTests();
|
||||
expect(ipBanCache.isBanned(ip)).toBe(false);
|
||||
await expect(adminRepository.isIpBanned(ip)).resolves.toBe(false);
|
||||
@@ -135,7 +152,7 @@ describe('AbusiveIpAutoBanner', () => {
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
recordAbuseSignal(ip, 'auth_failure:session', {tokenHash: hashAuthToken(`mobile-invalid-${i}`)});
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
await drainAbuseIpClassLookupsForTests();
|
||||
await drainAbuseAutoBanTasksForTests();
|
||||
expect(ipBanCache.isBanned(ip)).toBe(false);
|
||||
await expect(adminRepository.isIpBanned(ip)).resolves.toBe(false);
|
||||
@@ -153,7 +170,7 @@ describe('AbusiveIpAutoBanner', () => {
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
recordAbuseSignal(ip, 'auth_failure:session', {tokenHash: hashAuthToken(`mobile-threshold-${i}`)});
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
await drainAbuseIpClassLookupsForTests();
|
||||
await drainAbuseAutoBanTasksForTests();
|
||||
expect(ipBanCache.isBanned(ip)).toBe(false);
|
||||
await expect(adminRepository.isIpBanned(ip)).resolves.toBe(false);
|
||||
@@ -168,4 +185,68 @@ describe('AbusiveIpAutoBanner', () => {
|
||||
await expect(adminRepository.isIpBanned(ip)).resolves.toBe(false);
|
||||
}
|
||||
});
|
||||
it('claims the class lookup exactly once for a burst on the same IP', async () => {
|
||||
const ip = '8.8.8.8';
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
recordAbuseSignal(ip, 'auth_failure:session', {tokenHash: hashAuthToken(`claim-${i}`)});
|
||||
}
|
||||
await drainAbuseIpClassLookupsForTests();
|
||||
await drainAbuseAutoBanTasksForTests();
|
||||
expect(claimCallCount(harness, ip)).toBe(1);
|
||||
expect(lookupCount).toBe(1);
|
||||
});
|
||||
it('does not pay for a lookup or ban when another pod owns the class claim', async () => {
|
||||
const ip = '8.8.8.8';
|
||||
await harness.kvProvider.setnx(`abuse:ipclass:claim:${ip}`, 'other-pod', 60);
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
recordAbuseSignal(ip, 'auth_failure:session', {tokenHash: hashAuthToken(`claim-loser-${i}`)});
|
||||
}
|
||||
await drainAbuseIpClassLookupsForTests();
|
||||
await drainAbuseAutoBanTasksForTests();
|
||||
expect(lookupCount).toBe(0);
|
||||
expect(ipBanCache.isBanned(ip)).toBe(false);
|
||||
});
|
||||
it('does not classify an IPv4 address that is already banned', async () => {
|
||||
const ip = '8.8.8.8';
|
||||
ipBanCache.banTemp(ip, 3600);
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
recordAbuseSignal(ip, 'auth_failure:session', {tokenHash: hashAuthToken(`already-banned-${i}`)});
|
||||
}
|
||||
await drainAbuseIpClassLookupsForTests();
|
||||
await drainAbuseAutoBanTasksForTests();
|
||||
expect(lookupCount).toBe(0);
|
||||
expect(claimCallCount(harness, ip)).toBe(0);
|
||||
});
|
||||
it('does not classify an IPv6 address inside an already banned /64', async () => {
|
||||
const ip = '2606:4700:4700::1111';
|
||||
const banKey = getSameIpDecisionKey(ip) ?? ip;
|
||||
ipBanCache.banTemp(banKey, 3600);
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
recordAbuseSignal(ip, 'auth_failure:session', {tokenHash: hashAuthToken(`already-banned-v6-${i}`)});
|
||||
}
|
||||
await drainAbuseIpClassLookupsForTests();
|
||||
await drainAbuseAutoBanTasksForTests();
|
||||
expect(lookupCount).toBe(0);
|
||||
expect(claimCallCount(harness, banKey)).toBe(0);
|
||||
});
|
||||
it('retries a failed classification once the negative TTL elapses', async () => {
|
||||
const ip = '9.9.9.9';
|
||||
setAbuseIpClassTtlsForTests({negativeMs: 50});
|
||||
setInjectedIpInfoService({
|
||||
async lookup(candidateIp: string) {
|
||||
lookupCount += 1;
|
||||
return ipInfoResult(candidateIp, {available: false});
|
||||
},
|
||||
});
|
||||
recordAbuseSignal(ip, 'http_429', {weight: 25});
|
||||
await drainAbuseIpClassLookupsForTests();
|
||||
await drainAbuseAutoBanTasksForTests();
|
||||
expect(lookupCount).toBe(1);
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await harness.kvProvider.del(`abuse:ipclass:claim:${ip}`);
|
||||
recordAbuseSignal(ip, 'http_429', {weight: 25});
|
||||
await drainAbuseIpClassLookupsForTests();
|
||||
await drainAbuseAutoBanTasksForTests();
|
||||
expect(lookupCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
import type {ApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import type {MockKVProvider} from '../../test/mocks/MockKVProvider';
|
||||
import {
|
||||
drainAbuseAutoBanTasksForTests,
|
||||
drainAbuseIpClassLookupsForTests,
|
||||
hashAuthToken,
|
||||
recordAbuseSignal,
|
||||
resetAbuseTrackingForTests,
|
||||
setAbuseIpClassTtlsForTests,
|
||||
startAbuseReplicationSubscriber,
|
||||
stopAbuseReplicationSubscriber,
|
||||
} from '../AbusiveIpAutoBanner';
|
||||
import {ipBanCache} from '../IpBanMiddleware';
|
||||
import {setInjectedIpInfoService} from '../ServiceMiddleware';
|
||||
|
||||
function ipInfoResult(ip: string, overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
|
||||
return {
|
||||
ip,
|
||||
available: true,
|
||||
riskNote: 'test',
|
||||
geo: {
|
||||
countryCode: 'US',
|
||||
countryName: 'United States',
|
||||
continent: 'North America',
|
||||
continentCode: 'NA',
|
||||
region: null,
|
||||
regionCode: null,
|
||||
city: null,
|
||||
postalCode: null,
|
||||
timezone: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
accuracyRadiusKm: null,
|
||||
},
|
||||
asn: {
|
||||
asn: 'AS64500',
|
||||
number: 64500,
|
||||
name: 'Test ISP',
|
||||
domain: null,
|
||||
type: null,
|
||||
},
|
||||
mobile: {
|
||||
name: null,
|
||||
mcc: null,
|
||||
mnc: null,
|
||||
},
|
||||
anonymous: {
|
||||
isAnonymous: false,
|
||||
providerName: null,
|
||||
isVpn: false,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
isTor: false,
|
||||
isRelay: false,
|
||||
percentDaysSeen: null,
|
||||
},
|
||||
flags: {
|
||||
isAnycast: false,
|
||||
isHosting: false,
|
||||
isMobile: false,
|
||||
isSatellite: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function publishPeerIpClasses(harness: ApiTestHarness, entries: Array<[string, string, unknown]>): void {
|
||||
const kvProvider = harness.kvProvider as MockKVProvider;
|
||||
kvProvider
|
||||
.getSubscription()
|
||||
.simulateMessage('abuse_tracker:ipclass', JSON.stringify({sender: 'other-pod', entries, ts: Date.now()}));
|
||||
}
|
||||
|
||||
function claimCallCount(harness: ApiTestHarness, banKey: string): number {
|
||||
const kvProvider = harness.kvProvider as MockKVProvider;
|
||||
return kvProvider.setnxSpy.mock.calls.filter(([key]) => key === `abuse:ipclass:claim:${banKey}`).length;
|
||||
}
|
||||
|
||||
function recordTokenSignals(ip: string, prefix: string, from: number, to: number): void {
|
||||
for (let i = from; i < to; i += 1) {
|
||||
recordAbuseSignal(ip, 'auth_failure:session', {tokenHash: hashAuthToken(`${prefix}-${i}`)});
|
||||
}
|
||||
}
|
||||
|
||||
async function drainAbuseWork(): Promise<void> {
|
||||
await drainAbuseIpClassLookupsForTests();
|
||||
await drainAbuseAutoBanTasksForTests();
|
||||
}
|
||||
|
||||
describe('AbusiveIpAutoBanner IP class replication', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let lookupCount = 0;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
resetAbuseTrackingForTests();
|
||||
ipBanCache.resetCaches();
|
||||
lookupCount = 0;
|
||||
setInjectedIpInfoService({
|
||||
async lookup(ip: string) {
|
||||
lookupCount += 1;
|
||||
return ipInfoResult(ip);
|
||||
},
|
||||
});
|
||||
await stopAbuseReplicationSubscriber();
|
||||
await startAbuseReplicationSubscriber(harness.kvProvider);
|
||||
});
|
||||
afterAll(async () => {
|
||||
await stopAbuseReplicationSubscriber();
|
||||
setInjectedIpInfoService(undefined);
|
||||
await harness.shutdown();
|
||||
});
|
||||
it('adopts a datacenter class from a peer without paying for its own lookup', async () => {
|
||||
const ip = '8.8.8.8';
|
||||
recordTokenSignals(ip, 'peer-datacenter', 0, 3);
|
||||
publishPeerIpClasses(harness, [[ip, ip, 'datacenter']]);
|
||||
recordTokenSignals(ip, 'peer-datacenter', 3, 10);
|
||||
await drainAbuseWork();
|
||||
expect(lookupCount).toBe(0);
|
||||
expect(claimCallCount(harness, ip)).toBe(0);
|
||||
expect(ipBanCache.isBanned(ip)).toBe(true);
|
||||
});
|
||||
it('never adopts a mobile class from a peer and leaves the IP bannable', async () => {
|
||||
const ip = '8.8.4.4';
|
||||
recordTokenSignals(ip, 'peer-mobile', 0, 3);
|
||||
publishPeerIpClasses(harness, [[ip, ip, 'mobile']]);
|
||||
recordTokenSignals(ip, 'peer-mobile', 3, 10);
|
||||
await drainAbuseWork();
|
||||
expect(lookupCount).toBe(0);
|
||||
expect(ipBanCache.isBanned(ip)).toBe(false);
|
||||
publishPeerIpClasses(harness, [[ip, ip, 'datacenter']]);
|
||||
await drainAbuseWork();
|
||||
expect(ipBanCache.isBanned(ip)).toBe(true);
|
||||
});
|
||||
it('ignores an unrecognised class from a peer and classifies the IP itself', async () => {
|
||||
const ip = '4.4.4.4';
|
||||
recordTokenSignals(ip, 'peer-invalid', 0, 3);
|
||||
publishPeerIpClasses(harness, [
|
||||
[ip, ip, 'datacentre'],
|
||||
[ip, ip, 42],
|
||||
]);
|
||||
recordTokenSignals(ip, 'peer-invalid', 3, 10);
|
||||
await drainAbuseWork();
|
||||
expect(lookupCount).toBe(1);
|
||||
expect(ipBanCache.isBanned(ip)).toBe(true);
|
||||
});
|
||||
it('ignores an unknown class from a peer and classifies the IP itself', async () => {
|
||||
const ip = '9.9.9.9';
|
||||
recordTokenSignals(ip, 'peer-unknown', 0, 3);
|
||||
publishPeerIpClasses(harness, [[ip, ip, 'unknown']]);
|
||||
recordTokenSignals(ip, 'peer-unknown', 3, 10);
|
||||
await drainAbuseWork();
|
||||
expect(lookupCount).toBe(1);
|
||||
expect(ipBanCache.isBanned(ip)).toBe(true);
|
||||
});
|
||||
it('ignores a peer class for an IP it is not tracking', async () => {
|
||||
const ip = '208.67.222.222';
|
||||
publishPeerIpClasses(harness, [[ip, ip, 'datacenter']]);
|
||||
recordTokenSignals(ip, 'peer-untracked', 0, 10);
|
||||
await drainAbuseWork();
|
||||
expect(lookupCount).toBe(1);
|
||||
expect(claimCallCount(harness, ip)).toBe(1);
|
||||
});
|
||||
it('accepts a peer mobile class after its own lookup failed and keeps the IP unbanned', async () => {
|
||||
const ip = '199.85.126.10';
|
||||
setInjectedIpInfoService({
|
||||
async lookup(lookupIp: string) {
|
||||
lookupCount += 1;
|
||||
return ipInfoResult(lookupIp, {available: false});
|
||||
},
|
||||
});
|
||||
recordAbuseSignal(ip, 'http_429', {weight: 25});
|
||||
await drainAbuseWork();
|
||||
expect(lookupCount).toBe(1);
|
||||
publishPeerIpClasses(harness, [[ip, ip, 'mobile']]);
|
||||
recordTokenSignals(ip, 'negative-then-mobile', 0, 10);
|
||||
await drainAbuseWork();
|
||||
expect(lookupCount).toBe(1);
|
||||
expect(ipBanCache.isBanned(ip)).toBe(false);
|
||||
});
|
||||
it('resumes classifying once a peer class hint expires', async () => {
|
||||
const ip = '77.88.8.8';
|
||||
setAbuseIpClassTtlsForTests({hintMs: 50});
|
||||
recordAbuseSignal(ip, 'http_429', {weight: 3});
|
||||
publishPeerIpClasses(harness, [[ip, ip, 'datacenter']]);
|
||||
recordAbuseSignal(ip, 'http_429', {weight: 25});
|
||||
await drainAbuseWork();
|
||||
expect(lookupCount).toBe(0);
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
recordAbuseSignal(ip, 'http_429', {weight: 25});
|
||||
await drainAbuseWork();
|
||||
expect(lookupCount).toBe(1);
|
||||
expect(claimCallCount(harness, ip)).toBe(1);
|
||||
expect(ipBanCache.isBanned(ip)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -197,7 +197,7 @@ export class UserSettings {
|
||||
return {
|
||||
user_id: userId,
|
||||
locale,
|
||||
theme: theme ?? ThemeTypes.SYSTEM,
|
||||
theme: theme ?? ThemeTypes.DARK,
|
||||
status: 'online',
|
||||
status_resets_at: null,
|
||||
status_resets_to: null,
|
||||
|
||||
@@ -13342,13 +13342,10 @@
|
||||
"name": "country_code",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {"type": "string", "description": "Two-letter country code for regional pricing"}
|
||||
},
|
||||
{
|
||||
"name": "pricing_mode",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {"$ref": "#/components/schemas/PricingModeEnum"}
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "Two-letter country code for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13623,11 +13620,81 @@
|
||||
"name": "country_code",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {"type": "string", "description": "Two-letter country code for regional pricing"}
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"description": "Two-letter country code for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins."
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/premium/switch-to-list-price": {
|
||||
"post": {
|
||||
"operationId": "switch_subscription_to_list_price",
|
||||
"summary": "Switch subscription to the current list price",
|
||||
"tags": ["Premium"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/SwitchToListPriceResponse"}}}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"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": "Switch subscription to the current list price"}},
|
||||
"description": "Moves the authenticated user's grandfathered premium subscription down to the current list price for the same currency and billing cycle, effective at the end of the current billing period. The target price is resolved on the server and the switch is refused unless it lowers the amount charged.",
|
||||
"security": [{"sessionToken": []}]
|
||||
}
|
||||
},
|
||||
"/premium/visionary/rejoin": {
|
||||
"post": {
|
||||
"operationId": "rejoin_visionary_guild",
|
||||
@@ -27281,8 +27348,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -27375,8 +27442,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -27459,8 +27526,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -27543,8 +27610,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -27974,6 +28041,9 @@
|
||||
"x-enumNames": [
|
||||
"ANIMATED_ICON",
|
||||
"ANIMATED_BANNER",
|
||||
"AUDIO_BITRATE_128_KBPS",
|
||||
"AUDIO_BITRATE_256_KBPS",
|
||||
"AUDIO_BITRATE_384_KBPS",
|
||||
"BANNER",
|
||||
"CLONE_EMOJI_DISABLED",
|
||||
"CLONE_STICKER_DISABLED",
|
||||
@@ -28004,6 +28074,9 @@
|
||||
"x-enumDescriptions": [
|
||||
"Guild can have an animated icon",
|
||||
"Guild can have an animated banner",
|
||||
"Guild can set a voice channel bitrate of up to 128 kbps",
|
||||
"Guild can set a voice channel bitrate of up to 256 kbps",
|
||||
"Guild can set a voice channel bitrate of up to 384 kbps",
|
||||
"Guild can have a banner",
|
||||
"Guild has the in-app one-click emoji clone shortcut disabled for non-members",
|
||||
"Guild has the in-app one-click sticker clone shortcut disabled for non-members",
|
||||
@@ -28031,7 +28104,7 @@
|
||||
"Guild has large guild overrides enabled",
|
||||
"Guild has increased member capacity enabled"
|
||||
],
|
||||
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
|
||||
"description": "A guild feature flag Known values: ANIMATED_ICON, ANIMATED_BANNER, AUDIO_BITRATE_128_KBPS, AUDIO_BITRATE_256_KBPS, AUDIO_BITRATE_384_KBPS, BANNER, CLONE_EMOJI_DISABLED, CLONE_STICKER_DISABLED, DETACHED_BANNER, INVITE_SPLASH, INVITES_DISABLED, RAID_DETECTED, TEXT_CHANNEL_FLEXIBLE_NAMES, HIDE_OWNER_CROWN, MORE_EMOJI, MORE_STICKERS, UNLIMITED_EMOJI, UNLIMITED_STICKERS, EXPRESSION_PURGE_ALLOWED, VANITY_URL, DISCOVERABLE, PARTNERED, VERIFIED, VIP_VOICE, VOICE_E2EE, UNAVAILABLE_FOR_EVERYONE, UNAVAILABLE_FOR_EVERYONE_BUT_STAFF, UNAVAILABLE_HIDDEN, VISIONARY, LARGE_GUILD_OVERRIDE, VERY_LARGE_GUILD (other values allowed)"
|
||||
},
|
||||
"ChannelPartialResponse": {
|
||||
"type": "object",
|
||||
@@ -30262,8 +30335,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -30330,8 +30403,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -30398,8 +30471,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -30466,8 +30539,8 @@
|
||||
"description": "ID of the parent category for this channel"
|
||||
},
|
||||
"bitrate": {
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 320000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-320000)"
|
||||
"anyOf": [{"type": "integer", "minimum": 8000, "maximum": 384000, "format": "int32"}, {"type": "null"}],
|
||||
"description": "Voice channel bitrate in bits per second (8000-384000), clamped to 96000 unless the guild holds an AUDIO_BITRATE feature"
|
||||
},
|
||||
"user_limit": {
|
||||
"anyOf": [{"type": "integer", "minimum": 0, "maximum": 99, "format": "int32"}, {"type": "null"}],
|
||||
@@ -31997,9 +32070,9 @@
|
||||
"description": "The amount the user is actually charged, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency of the charged amount"
|
||||
},
|
||||
"billing_cycle": {
|
||||
@@ -32194,6 +32267,7 @@
|
||||
"pending_subscription_change": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/PendingSubscriptionChangeResponse"}, {"type": "null"}]
|
||||
},
|
||||
"list_price_switch": {"$ref": "#/components/schemas/ListPriceSwitchState"},
|
||||
"subscription": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/PremiumBillingSubscriptionResponse"}, {"type": "null"}]
|
||||
},
|
||||
@@ -32209,6 +32283,7 @@
|
||||
"stripe_customer_id",
|
||||
"current_subscription_price",
|
||||
"pending_subscription_change",
|
||||
"list_price_switch",
|
||||
"subscription",
|
||||
"invoices",
|
||||
"invoices_has_more",
|
||||
@@ -32241,6 +32316,12 @@
|
||||
"type": "string",
|
||||
"description": "Stripe subscription schedule ID managing the pending change"
|
||||
},
|
||||
"change_kind": {
|
||||
"enum": ["billing_cycle", "price"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["billing_cycle", "price"],
|
||||
"description": "Whether the pending change moves the billing cycle or only the price within the same cycle"
|
||||
},
|
||||
"current_billing_cycle": {
|
||||
"anyOf": [
|
||||
{"enum": ["monthly", "yearly"], "type": "string", "x-enumNames": ["monthly", "yearly"]},
|
||||
@@ -32263,12 +32344,16 @@
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Stripe price ID that will be used after the change"
|
||||
},
|
||||
"target_amount_minor": {
|
||||
"anyOf": [{"type": "integer", "format": "int53"}, {"type": "null"}],
|
||||
"description": "Unit amount of the price that will be used after the change, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"]
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"]
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
@@ -32289,11 +32374,13 @@
|
||||
},
|
||||
"required": [
|
||||
"schedule_id",
|
||||
"change_kind",
|
||||
"current_billing_cycle",
|
||||
"target_billing_cycle",
|
||||
"effective_at",
|
||||
"current_price_id",
|
||||
"target_price_id",
|
||||
"target_amount_minor",
|
||||
"currency",
|
||||
"initial_amount_minor",
|
||||
"recurring_amount_minor",
|
||||
@@ -32303,6 +32390,104 @@
|
||||
{"type": "null"}
|
||||
]
|
||||
},
|
||||
"ListPriceSwitchState": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"available": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the authenticated user can move their subscription down to the current list price right now"
|
||||
},
|
||||
"reason": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/ListPriceSwitchIneligibilityReason"}, {"type": "null"}],
|
||||
"description": "Why the switch is unavailable, when available is false"
|
||||
},
|
||||
"pending": {
|
||||
"type": "boolean",
|
||||
"description": "Whether a switch to the current list price is already scheduled"
|
||||
},
|
||||
"current_price_id": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Stripe price ID the subscription is billed against today"
|
||||
},
|
||||
"current_amount_minor": {
|
||||
"anyOf": [{"type": "integer", "format": "int53"}, {"type": "null"}],
|
||||
"description": "Amount the subscription is billed today, in the currency minor unit"
|
||||
},
|
||||
"list_price_id": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "Current list Stripe price ID for the same cycle and currency"
|
||||
},
|
||||
"list_amount_minor": {
|
||||
"anyOf": [{"type": "integer", "format": "int53"}, {"type": "null"}],
|
||||
"description": "Current list price for the same cycle and currency, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"]
|
||||
},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "Currency of both the current and the list amount"
|
||||
},
|
||||
"billing_cycle": {
|
||||
"anyOf": [
|
||||
{"enum": ["monthly", "yearly"], "type": "string", "x-enumNames": ["monthly", "yearly"]},
|
||||
{"type": "null"}
|
||||
],
|
||||
"description": "Recurring billing cycle the switch applies to"
|
||||
},
|
||||
"effective_at": {
|
||||
"anyOf": [{"type": "string"}, {"type": "null"}],
|
||||
"description": "ISO timestamp the switch takes effect, which is the end of the current billing period"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"available",
|
||||
"reason",
|
||||
"pending",
|
||||
"current_price_id",
|
||||
"current_amount_minor",
|
||||
"list_price_id",
|
||||
"list_amount_minor",
|
||||
"currency",
|
||||
"billing_cycle",
|
||||
"effective_at"
|
||||
]
|
||||
},
|
||||
"ListPriceSwitchIneligibilityReason": {
|
||||
"enum": [
|
||||
"feature_unavailable",
|
||||
"no_active_subscription",
|
||||
"subscription_not_chargeable",
|
||||
"unsupported_subscription",
|
||||
"no_list_price",
|
||||
"already_on_list_price",
|
||||
"not_a_price_decrease",
|
||||
"subscription_cancelling",
|
||||
"cancellation_managed_by_schedule",
|
||||
"conflicting_pending_change",
|
||||
"missing_period_end",
|
||||
"switch_in_progress"
|
||||
],
|
||||
"type": "string",
|
||||
"x-enumNames": [
|
||||
"feature_unavailable",
|
||||
"no_active_subscription",
|
||||
"subscription_not_chargeable",
|
||||
"unsupported_subscription",
|
||||
"no_list_price",
|
||||
"already_on_list_price",
|
||||
"not_a_price_decrease",
|
||||
"subscription_cancelling",
|
||||
"cancellation_managed_by_schedule",
|
||||
"conflicting_pending_change",
|
||||
"missing_period_end",
|
||||
"switch_in_progress"
|
||||
]
|
||||
},
|
||||
"PremiumBillingSubscriptionResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -32443,13 +32628,9 @@
|
||||
"localized": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/PriceIdsResponse"}, {"type": "null"}],
|
||||
"description": "Localized checkout prices resolved from mirrored billing data"
|
||||
},
|
||||
"base": {
|
||||
"anyOf": [{"$ref": "#/components/schemas/PriceIdsResponse"}, {"type": "null"}],
|
||||
"description": "Standard USD/EUR checkout prices resolved from mirrored billing data"
|
||||
}
|
||||
},
|
||||
"required": ["country_code", "localized", "base"]
|
||||
"required": ["country_code", "localized"]
|
||||
},
|
||||
"PriceIdsResponse": {
|
||||
"type": "object",
|
||||
@@ -32487,15 +32668,15 @@
|
||||
"description": "Gift 1 year price amount in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency for the prices"
|
||||
},
|
||||
"gift_currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "INR", "PLN", "TRY"],
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency for gift prices"
|
||||
}
|
||||
},
|
||||
@@ -32508,7 +32689,6 @@
|
||||
},
|
||||
"required": ["disabled"]
|
||||
},
|
||||
"PricingModeEnum": {"enum": ["localized", "base"], "type": "string", "x-enumNames": ["localized", "base"]},
|
||||
"SelfServeRefundResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -32544,6 +32724,134 @@
|
||||
"status"
|
||||
]
|
||||
},
|
||||
"SwitchToListPriceResponse": {
|
||||
"oneOf": [
|
||||
{"$ref": "#/components/schemas/ScheduledSwitchToListPriceResponse"},
|
||||
{"$ref": "#/components/schemas/AlreadyScheduledSwitchToListPriceResponse"},
|
||||
{"$ref": "#/components/schemas/IneligibleSwitchToListPriceResponse"}
|
||||
]
|
||||
},
|
||||
"ScheduledSwitchToListPriceResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["scheduled"],
|
||||
"description": "The switch was scheduled for the end of the current billing period"
|
||||
},
|
||||
"effective_at": {"type": "string", "description": "ISO timestamp the switch takes effect"},
|
||||
"target_price_id": {
|
||||
"type": "string",
|
||||
"description": "Stripe price ID the subscription will be billed against after the switch"
|
||||
},
|
||||
"target_amount_minor": {
|
||||
"type": "integer",
|
||||
"format": "int53",
|
||||
"description": "Amount billed after the switch, in the currency minor unit"
|
||||
},
|
||||
"current_amount_minor": {
|
||||
"type": "integer",
|
||||
"format": "int53",
|
||||
"description": "Amount billed before the switch, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency of both amounts"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"effective_at",
|
||||
"target_price_id",
|
||||
"target_amount_minor",
|
||||
"current_amount_minor",
|
||||
"currency"
|
||||
]
|
||||
},
|
||||
"AlreadyScheduledSwitchToListPriceResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["already_scheduled"],
|
||||
"description": "The switch was already scheduled by an earlier request"
|
||||
},
|
||||
"effective_at": {"type": "string", "description": "ISO timestamp the switch takes effect"},
|
||||
"target_price_id": {
|
||||
"type": "string",
|
||||
"description": "Stripe price ID the subscription will be billed against after the switch"
|
||||
},
|
||||
"target_amount_minor": {
|
||||
"type": "integer",
|
||||
"format": "int53",
|
||||
"description": "Amount billed after the switch, in the currency minor unit"
|
||||
},
|
||||
"current_amount_minor": {
|
||||
"type": "integer",
|
||||
"format": "int53",
|
||||
"description": "Amount billed before the switch, in the currency minor unit"
|
||||
},
|
||||
"currency": {
|
||||
"enum": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"type": "string",
|
||||
"x-enumNames": ["USD", "EUR", "BRL", "DKK", "INR", "NOK", "PLN", "SEK", "TRY"],
|
||||
"description": "Currency of both amounts"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"effective_at",
|
||||
"target_price_id",
|
||||
"target_amount_minor",
|
||||
"current_amount_minor",
|
||||
"currency"
|
||||
]
|
||||
},
|
||||
"IneligibleSwitchToListPriceResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": ["ineligible"],
|
||||
"description": "The subscription cannot be moved to the current list price"
|
||||
},
|
||||
"reason": {
|
||||
"enum": [
|
||||
"feature_unavailable",
|
||||
"no_active_subscription",
|
||||
"subscription_not_chargeable",
|
||||
"unsupported_subscription",
|
||||
"no_list_price",
|
||||
"already_on_list_price",
|
||||
"not_a_price_decrease",
|
||||
"subscription_cancelling",
|
||||
"cancellation_managed_by_schedule",
|
||||
"conflicting_pending_change",
|
||||
"missing_period_end",
|
||||
"switch_in_progress"
|
||||
],
|
||||
"type": "string",
|
||||
"x-enumNames": [
|
||||
"feature_unavailable",
|
||||
"no_active_subscription",
|
||||
"subscription_not_chargeable",
|
||||
"unsupported_subscription",
|
||||
"no_list_price",
|
||||
"already_on_list_price",
|
||||
"not_a_price_decrease",
|
||||
"subscription_cancelling",
|
||||
"cancellation_managed_by_schedule",
|
||||
"conflicting_pending_change",
|
||||
"missing_period_end",
|
||||
"switch_in_progress"
|
||||
],
|
||||
"description": "Why the switch was refused"
|
||||
}
|
||||
},
|
||||
"required": ["status", "reason"]
|
||||
},
|
||||
"ReadStateAckResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -33709,7 +34017,10 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"price_id": {"type": "string", "description": "The Stripe price ID for the subscription plan"},
|
||||
"country_code": {"type": "string", "description": "Two-letter country code used for regional pricing"},
|
||||
"country_code": {
|
||||
"type": "string",
|
||||
"description": "Two-letter country code used for regional pricing. Only used when the server cannot geolocate the request; otherwise the request GeoIP country wins."
|
||||
},
|
||||
"client_geoip_country_code": {
|
||||
"type": "string",
|
||||
"description": "Two-letter country code observed by the client GeoIP store before checkout"
|
||||
@@ -33718,7 +34029,6 @@
|
||||
"type": "boolean",
|
||||
"description": "Whether the EU/EEA digital content withdrawal waiver was expressly accepted before checkout"
|
||||
},
|
||||
"pricing_mode": {"$ref": "#/components/schemas/PricingModeEnum"},
|
||||
"payment_method": {"$ref": "#/components/schemas/CheckoutPaymentMethodEnum"},
|
||||
"is_business": {
|
||||
"type": "boolean",
|
||||
@@ -36529,7 +36839,7 @@
|
||||
"description": "Controls who sees the full profile: all guild members, only small-guild members, or only friends"
|
||||
},
|
||||
"synced_preferences": {
|
||||
"anyOf": [{"type": "string", "maxLength": 349528}, {"type": "null"}],
|
||||
"anyOf": [{"type": "string", "maxLength": 699052}, {"type": "null"}],
|
||||
"description": "Account-wide client preferences as a base64-encoded protobuf snapshot. Replaces the entire stored snapshot; pass null to clear it."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {RateLimitMiddleware} from '../middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
|
||||
import {RateLimitConfigs} from '../RateLimitConfig';
|
||||
import type {HonoApp} from '../types/HonoEnv';
|
||||
import {lookupGeoip} from '../utils/IpUtils';
|
||||
import {Validator} from '../Validator';
|
||||
|
||||
export function PremiumController(app: HonoApp) {
|
||||
@@ -32,7 +33,8 @@ export function PremiumController(app: HonoApp) {
|
||||
async (ctx) => {
|
||||
const userId = ctx.get('user').id;
|
||||
const {country_code} = ctx.req.valid('query');
|
||||
const state = await ctx.get('stripeService').getPremiumState(userId, country_code);
|
||||
const geoip = await lookupGeoip(ctx.req.raw);
|
||||
const state = await ctx.get('stripeService').getPremiumState(userId, geoip.countryCode ?? country_code);
|
||||
return ctx.json(state);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -343,7 +343,7 @@ export class ReportService {
|
||||
const normalizedEmail = this.normalizeEmail(email);
|
||||
const hasValidDns = await this.emailDnsValidationService.hasValidDnsRecords(normalizedEmail);
|
||||
if (!hasValidDns) {
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
|
||||
throw InputValidationError.fromCode('email', ValidationErrorCodes.EMAIL_DOMAIN_CANNOT_RECEIVE_MAIL);
|
||||
}
|
||||
const verificationCode = this.generateDsaVerificationCode();
|
||||
const expiresAt = new Date(Date.now() + ms('10 minutes'));
|
||||
|
||||
@@ -7,9 +7,14 @@ import {isTrustedCommercialPrivacyProvider} from './TrustedPrivacyProviders';
|
||||
|
||||
const VERDICT_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
interface IpBanBlastRadiusVerdict {
|
||||
cgnat: boolean;
|
||||
sharedAccess: boolean;
|
||||
}
|
||||
|
||||
interface CachedVerdict {
|
||||
expiresAtMs: number;
|
||||
highRisk: boolean;
|
||||
verdict: IpBanBlastRadiusVerdict;
|
||||
}
|
||||
|
||||
const verdictCache = new Map<string, CachedVerdict>();
|
||||
@@ -48,7 +53,7 @@ export function getSuspiciousIpSkipReason(result: IpInfoLookupResult): Suspiciou
|
||||
return null;
|
||||
}
|
||||
|
||||
function isHighSharedAccessBlastRadiusRisk(result: IpInfoLookupResult): boolean {
|
||||
export function isHighSharedAccessBlastRadiusRisk(result: IpInfoLookupResult): boolean {
|
||||
if (result.flags.isHosting || isAnonymousAccess(result)) {
|
||||
return false;
|
||||
}
|
||||
@@ -60,29 +65,32 @@ export function isSingleIpBanCandidate(value: string): boolean {
|
||||
return parseIpBanEntry(value)?.type === 'single';
|
||||
}
|
||||
|
||||
export async function hasHighCgnatBlastRadiusRisk(
|
||||
export async function getIpBanBlastRadiusVerdict(
|
||||
ip: string,
|
||||
ipInfoService: IpInfoService,
|
||||
context: {
|
||||
source: string;
|
||||
reason: string;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
): Promise<IpBanBlastRadiusVerdict> {
|
||||
const now = Date.now();
|
||||
const cacheKey = getSameIpDecisionKey(ip) ?? ip;
|
||||
const cached = verdictCache.get(cacheKey);
|
||||
if (cached && cached.expiresAtMs > now) {
|
||||
return cached.highRisk;
|
||||
return cached.verdict;
|
||||
}
|
||||
const result = await ipInfoService.lookup(ip, {
|
||||
source: context.source,
|
||||
reason: context.reason,
|
||||
metadata: {policy: 'ip_ban_cgnat_guard'},
|
||||
});
|
||||
const highRisk = isHighCgnatBlastRadiusRisk(result);
|
||||
const verdict: IpBanBlastRadiusVerdict = {
|
||||
cgnat: isHighCgnatBlastRadiusRisk(result),
|
||||
sharedAccess: isHighSharedAccessBlastRadiusRisk(result),
|
||||
};
|
||||
verdictCache.set(cacheKey, {
|
||||
highRisk,
|
||||
verdict,
|
||||
expiresAtMs: now + VERDICT_CACHE_TTL_MS,
|
||||
});
|
||||
return highRisk;
|
||||
return verdict;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IpInfoLookupBudget, IpInfoLookupPriority} from '@pkgs/geoip/src/IpInfoService';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
import {Logger} from '../Logger';
|
||||
|
||||
const BURST_KEY_PREFIX = 'ipinfo:budget:burst:';
|
||||
const MONTH_KEY_PREFIX = 'ipinfo:budget:month:';
|
||||
const MONTH_KEY_TTL_SECONDS = 40 * 24 * 3600;
|
||||
const BURST_REFILL_INTERVAL_MS = 60_000;
|
||||
const BUDGET_LOG_INTERVAL_MS = 60_000;
|
||||
|
||||
let lastBudgetErrorLogMs = 0;
|
||||
|
||||
function positiveNumberFromEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (!raw) return fallback;
|
||||
const parsed = Number(raw);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function budgetEnabled(): boolean {
|
||||
return process.env.FLUXER_IPINFO_BUDGET_ENABLED !== '0';
|
||||
}
|
||||
|
||||
function burstConfigFor(priority: IpInfoLookupPriority): {maxTokens: number; refillPerMin: number} {
|
||||
if (priority === 'critical') {
|
||||
return {
|
||||
maxTokens: positiveNumberFromEnv('FLUXER_IPINFO_BUDGET_CRITICAL_BURST', 60),
|
||||
refillPerMin: positiveNumberFromEnv('FLUXER_IPINFO_BUDGET_CRITICAL_REFILL_PER_MIN', 60),
|
||||
};
|
||||
}
|
||||
if (priority === 'background') {
|
||||
return {
|
||||
maxTokens: positiveNumberFromEnv('FLUXER_IPINFO_BUDGET_BACKGROUND_BURST', 120),
|
||||
refillPerMin: positiveNumberFromEnv('FLUXER_IPINFO_BUDGET_BACKGROUND_REFILL_PER_MIN', 30),
|
||||
};
|
||||
}
|
||||
return {
|
||||
maxTokens: positiveNumberFromEnv('FLUXER_IPINFO_BUDGET_STANDARD_BURST', 240),
|
||||
refillPerMin: positiveNumberFromEnv('FLUXER_IPINFO_BUDGET_STANDARD_REFILL_PER_MIN', 120),
|
||||
};
|
||||
}
|
||||
|
||||
function monthlyCeilingFor(priority: IpInfoLookupPriority): number {
|
||||
const monthlyMax = positiveNumberFromEnv('FLUXER_IPINFO_BUDGET_MONTHLY_MAX', 140000);
|
||||
if (priority === 'critical') return monthlyMax;
|
||||
const percent =
|
||||
priority === 'background'
|
||||
? positiveNumberFromEnv('FLUXER_IPINFO_BUDGET_BACKGROUND_MONTHLY_PCT', 60)
|
||||
: positiveNumberFromEnv('FLUXER_IPINFO_BUDGET_STANDARD_MONTHLY_PCT', 90);
|
||||
return Math.floor((monthlyMax * Math.min(percent, 100)) / 100);
|
||||
}
|
||||
|
||||
function currentMonthKey(): string {
|
||||
const now = new Date();
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||
return `${MONTH_KEY_PREFIX}${now.getUTCFullYear()}-${month}`;
|
||||
}
|
||||
|
||||
function logThrottled(payload: Record<string, unknown>, message: string): void {
|
||||
const now = Date.now();
|
||||
if (now - lastBudgetErrorLogMs < BUDGET_LOG_INTERVAL_MS) return;
|
||||
lastBudgetErrorLogMs = now;
|
||||
Logger.warn(payload, message);
|
||||
}
|
||||
|
||||
export function createKvIpInfoLookupBudget(opts: {getKvClient: () => IKVProvider}): IpInfoLookupBudget {
|
||||
return {
|
||||
async tryConsume(priority: IpInfoLookupPriority): Promise<boolean> {
|
||||
if (!budgetEnabled()) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const kv = opts.getKvClient();
|
||||
const burst = burstConfigFor(priority);
|
||||
const consumed = await kv.tryConsumeTokens(
|
||||
`${BURST_KEY_PREFIX}${priority}`,
|
||||
1,
|
||||
burst.maxTokens,
|
||||
burst.refillPerMin,
|
||||
BURST_REFILL_INTERVAL_MS,
|
||||
);
|
||||
if (consumed < 1) {
|
||||
logThrottled({priority, reason: 'burst'}, 'IPInfo lookup budget shed');
|
||||
return false;
|
||||
}
|
||||
const monthKey = currentMonthKey();
|
||||
const used = Number((await kv.get(monthKey)) ?? '0');
|
||||
if (used >= monthlyCeilingFor(priority)) {
|
||||
logThrottled({priority, reason: 'monthly', used}, 'IPInfo lookup budget shed');
|
||||
return false;
|
||||
}
|
||||
const value = await kv.incr(monthKey);
|
||||
if (value === 1) {
|
||||
await kv.expire(monthKey, MONTH_KEY_TTL_SECONDS);
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logThrottled({error, priority}, 'IPInfo lookup budget check failed, admitting lookup');
|
||||
return true;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import {getDefaultCassandraClient} from '@pkgs/cassandra/src/Client';
|
||||
import {createCassandraIpInfoCache} from '@pkgs/geoip/src/CassandraIpInfoCache';
|
||||
import {createCassandraIpInfoRequestAuditLogger} from '@pkgs/geoip/src/CassandraIpInfoRequestAudit';
|
||||
import type {IpInfoCache, IpInfoRequestAuditLogger} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {type IpInfoCache, type IpInfoRequestAuditLogger, isCachedIpInfoFailure} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {createPostgresIpInfoCache, createPostgresIpInfoRequestAuditLogger} from '@pkgs/geoip/src/PostgresIpInfoKv';
|
||||
import {createTieredIpInfoCache} from '@pkgs/geoip/src/TieredIpInfoCache';
|
||||
import {getDefaultPostgresClient} from '@pkgs/postgres/src/Client';
|
||||
@@ -22,11 +22,13 @@ export function buildIpInfoCache(options: BuildIpInfoCacheOptions): IpInfoCache
|
||||
getClient: getDefaultPostgresClient,
|
||||
onError: (error, operation) => Logger.warn({error, operation}, 'Postgres IPInfo cache operation failed'),
|
||||
}),
|
||||
skipColdWrite: isCachedIpInfoFailure,
|
||||
});
|
||||
}
|
||||
return createTieredIpInfoCache({
|
||||
hot: options.hot,
|
||||
cold: createCassandraIpInfoCache({getClient: getDefaultCassandraClient}),
|
||||
skipColdWrite: isCachedIpInfoFailure,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
classifyAccountPolicyReverseDnsHostname,
|
||||
isAccountPolicyEducationOrganizationName,
|
||||
isAccountPolicyTrustedCommercialPrivacyProvider,
|
||||
} from './AccountPolicyService';
|
||||
|
||||
const ASN_ENTRY_REGEX = /^\d+$/u;
|
||||
|
||||
export interface LocalIpIntel {
|
||||
countryIso: string | null;
|
||||
asn: number | null;
|
||||
asnOrg: string | null;
|
||||
}
|
||||
|
||||
export interface IpInfoPrescreenOptions {
|
||||
enabled: boolean;
|
||||
allowedAsns: ReadonlySet<number>;
|
||||
}
|
||||
|
||||
export type IpInfoPrescreenVerdict = 'consult' | 'skip';
|
||||
|
||||
export function prescreenIpInfoLookup(local: LocalIpIntel, opts: IpInfoPrescreenOptions): IpInfoPrescreenVerdict {
|
||||
if (!opts.enabled) return 'consult';
|
||||
if (opts.allowedAsns.size === 0) return 'consult';
|
||||
if (local.countryIso === null) return 'consult';
|
||||
if (local.asn === null) return 'consult';
|
||||
if (!opts.allowedAsns.has(local.asn)) return 'consult';
|
||||
if (isAccountPolicyTrustedCommercialPrivacyProvider(local.asnOrg)) return 'consult';
|
||||
if (isAccountPolicyEducationOrganizationName(local.asnOrg)) return 'consult';
|
||||
if (classifyAccountPolicyReverseDnsHostname(local.asnOrg) === 'cellular') return 'consult';
|
||||
return 'skip';
|
||||
}
|
||||
|
||||
export function ipInfoPrescreenOptionsFromEnv(): IpInfoPrescreenOptions {
|
||||
return {
|
||||
enabled: booleanFromEnv(process.env.FLUXER_RISK_IPINFO_PRESCREEN_ENABLED),
|
||||
allowedAsns: asnSetFromEnv(process.env.FLUXER_RISK_IPINFO_PRESCREEN_ALLOW_ASNS),
|
||||
};
|
||||
}
|
||||
|
||||
function booleanFromEnv(rawValue: string | undefined): boolean {
|
||||
if (!rawValue) return false;
|
||||
const normalized = rawValue.trim().toLowerCase();
|
||||
return normalized === '1' || normalized === 'true';
|
||||
}
|
||||
|
||||
function asnSetFromEnv(rawValue: string | undefined): ReadonlySet<number> {
|
||||
const allowedAsns = new Set<number>();
|
||||
if (!rawValue) return allowedAsns;
|
||||
for (const entry of rawValue.split(',')) {
|
||||
const trimmed = entry.trim();
|
||||
if (!ASN_ENTRY_REGEX.test(trimmed)) continue;
|
||||
const asn = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isSafeInteger(asn)) continue;
|
||||
allowedAsns.add(asn);
|
||||
}
|
||||
return allowedAsns;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ICacheService} from '@pkgs/cache/src/ICacheService';
|
||||
import type {GeoipAsnResult, GeoipResult} from '@pkgs/geoip/src/GeoipLookup';
|
||||
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
|
||||
import type {IAdminRepository} from '../admin/IAdminRepository';
|
||||
import {createDisposableDomainChecker} from './adapters/DisposableDomainChecker';
|
||||
@@ -9,6 +10,7 @@ import {createDomainAgeChecker} from './adapters/DomainAgeChecker';
|
||||
import {analyzeEmailSyntax} from './adapters/EmailSyntaxAnalyzer';
|
||||
import {createGeoIpAsnAdapter, createGeoIpCityAdapter} from './adapters/GeoIpAdapters';
|
||||
import {createHistoricalOutcomeAdapter} from './adapters/HistoricalOutcomeAdapter';
|
||||
import {unavailableIpInfoAnonymousResult} from './adapters/IpInfoAdapter';
|
||||
import {checkGeoVsLocale} from './adapters/LocaleGeoMatcher';
|
||||
import {analyzeRegistrationTiming} from './adapters/RegistrationTimingAnalyzer';
|
||||
import {analyzeUserAgent} from './adapters/UserAgentAnalyzer';
|
||||
@@ -29,12 +31,20 @@ interface RiskToolboxFactoryOptions {
|
||||
mxResolver?: MxResolver;
|
||||
mxCacheTtlMs?: number;
|
||||
cacheService?: ICacheService;
|
||||
lookupLocalCity?: (ip: string) => Promise<GeoipResult>;
|
||||
lookupLocalAsn?: (ip: string) => Promise<GeoipAsnResult>;
|
||||
}
|
||||
|
||||
export function createRiskToolbox(opts: RiskToolboxFactoryOptions): RiskToolbox {
|
||||
const checkDomainDisposable = createDisposableDomainChecker({adminRepository: opts.adminRepository});
|
||||
const lookupGeoIpCity = createGeoIpCityAdapter({ipInfoService: opts.ipInfoService});
|
||||
const lookupGeoIpAsn = createGeoIpAsnAdapter({ipInfoService: opts.ipInfoService});
|
||||
const lookupGeoIpCity = createGeoIpCityAdapter({
|
||||
ipInfoService: opts.ipInfoService,
|
||||
lookupLocalCity: opts.lookupLocalCity,
|
||||
});
|
||||
const lookupGeoIpAsn = createGeoIpAsnAdapter({
|
||||
ipInfoService: opts.ipInfoService,
|
||||
lookupLocalAsn: opts.lookupLocalAsn,
|
||||
});
|
||||
const checkMx = createDnsMxChecker({
|
||||
resolver: opts.mxResolver ?? new NodeDnsMxResolver(),
|
||||
cacheTtlMs: opts.mxCacheTtlMs,
|
||||
@@ -46,25 +56,7 @@ export function createRiskToolbox(opts: RiskToolboxFactoryOptions): RiskToolbox
|
||||
});
|
||||
const lookupIpInfo = opts.ipInfoChecker
|
||||
? async (args: {ip: string}) => opts.ipInfoChecker!(args.ip)
|
||||
: async (args: {ip: string}) =>
|
||||
({
|
||||
ip: args.ip,
|
||||
available: false,
|
||||
isAnonymous: false,
|
||||
providerName: null,
|
||||
isVpn: false,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
isTor: false,
|
||||
isRelay: false,
|
||||
isHosting: false,
|
||||
isMobile: false,
|
||||
asnType: null,
|
||||
asnOrg: null,
|
||||
connectionType: 'unknown',
|
||||
percentDaysSeen: null,
|
||||
riskNote: 'IPInfo not configured (no API key)',
|
||||
}) as IpInfoAnonymousResult;
|
||||
: async (args: {ip: string}) => unavailableIpInfoAnonymousResult(args.ip, 'IPInfo not configured (no API key)');
|
||||
const lookupReverseDns = opts.reverseDnsLookup
|
||||
? async (args: {ip: string}) => opts.reverseDnsLookup!(args.ip)
|
||||
: async (args: {ip: string}) => ({
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {GeoipAsnResult, GeoipResult} from '@pkgs/geoip/src/GeoipLookup';
|
||||
import type {IpInfoLookupResult, IpInfoService} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {createGeoIpAsnAdapter, createGeoIpCityAdapter} from '../adapters/GeoIpAdapters';
|
||||
|
||||
function throwingIpInfoService(): IpInfoService {
|
||||
return {
|
||||
lookup: async () => {
|
||||
throw new Error('ipinfo must not be consulted');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function countingIpInfoService(result: IpInfoLookupResult): {service: IpInfoService; calls: () => number} {
|
||||
let calls = 0;
|
||||
return {
|
||||
service: {
|
||||
lookup: async () => {
|
||||
calls += 1;
|
||||
return result;
|
||||
},
|
||||
},
|
||||
calls: () => calls,
|
||||
};
|
||||
}
|
||||
|
||||
function ipInfoResult(overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
|
||||
return {
|
||||
ip: '198.51.100.1',
|
||||
available: true,
|
||||
riskNote: 'test',
|
||||
geo: {
|
||||
countryCode: 'US',
|
||||
countryName: 'United States',
|
||||
continent: 'North America',
|
||||
continentCode: 'NA',
|
||||
region: 'California',
|
||||
regionCode: 'CA',
|
||||
city: 'San Jose',
|
||||
postalCode: null,
|
||||
timezone: 'America/Los_Angeles',
|
||||
latitude: 37.3,
|
||||
longitude: -121.9,
|
||||
accuracyRadiusKm: 20,
|
||||
},
|
||||
asn: {
|
||||
asn: 'AS64500',
|
||||
number: 64500,
|
||||
name: 'Test ISP',
|
||||
domain: null,
|
||||
type: null,
|
||||
},
|
||||
mobile: {
|
||||
name: null,
|
||||
mcc: null,
|
||||
mnc: null,
|
||||
},
|
||||
anonymous: {
|
||||
isAnonymous: false,
|
||||
providerName: null,
|
||||
isVpn: false,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
isTor: false,
|
||||
isRelay: false,
|
||||
percentDaysSeen: null,
|
||||
},
|
||||
flags: {
|
||||
isAnycast: false,
|
||||
isHosting: false,
|
||||
isMobile: false,
|
||||
isSatellite: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function geoipResult(overrides: Partial<GeoipResult> = {}): GeoipResult {
|
||||
return {
|
||||
countryCode: 'SE',
|
||||
normalizedIp: '198.51.100.7',
|
||||
city: 'Stockholm',
|
||||
region: 'Stockholm County',
|
||||
regionCode: 'AB',
|
||||
countryName: 'Sweden',
|
||||
latitude: 59.33,
|
||||
longitude: 18.06,
|
||||
accuracyRadiusKm: 5,
|
||||
timeZone: 'Europe/Stockholm',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function geoipAsnResult(overrides: Partial<GeoipAsnResult> = {}): GeoipAsnResult {
|
||||
return {
|
||||
normalizedIp: '198.51.100.7',
|
||||
asn: 64510,
|
||||
asnOrg: 'Example Broadband ISP',
|
||||
available: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('createGeoIpCityAdapter', () => {
|
||||
it('answers from the local city database without touching IPInfo', async () => {
|
||||
const lookupGeoIpCity = createGeoIpCityAdapter({
|
||||
ipInfoService: throwingIpInfoService(),
|
||||
lookupLocalCity: async () => geoipResult(),
|
||||
});
|
||||
await expect(lookupGeoIpCity({ip: '198.51.100.7'})).resolves.toEqual({
|
||||
ip: '198.51.100.7',
|
||||
available: true,
|
||||
found: true,
|
||||
countryIso: 'SE',
|
||||
country: 'Sweden',
|
||||
region: 'Stockholm County',
|
||||
city: 'Stockholm',
|
||||
latitude: 59.33,
|
||||
longitude: 18.06,
|
||||
accuracyRadiusKm: 5,
|
||||
timeZone: 'Europe/Stockholm',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes the IP before handing it to the local city database', async () => {
|
||||
const seen: Array<string> = [];
|
||||
const lookupGeoIpCity = createGeoIpCityAdapter({
|
||||
ipInfoService: throwingIpInfoService(),
|
||||
lookupLocalCity: async (ip) => {
|
||||
seen.push(ip);
|
||||
return geoipResult();
|
||||
},
|
||||
});
|
||||
await lookupGeoIpCity({ip: '[2001:0db8:0000::0001]'});
|
||||
expect(seen).toEqual(['2001:db8::1']);
|
||||
});
|
||||
|
||||
it('coalesces missing optional local fields to null', async () => {
|
||||
const lookupGeoIpCity = createGeoIpCityAdapter({
|
||||
ipInfoService: throwingIpInfoService(),
|
||||
lookupLocalCity: async () => ({
|
||||
countryCode: 'SE',
|
||||
normalizedIp: '198.51.100.7',
|
||||
city: null,
|
||||
region: null,
|
||||
countryName: null,
|
||||
}),
|
||||
});
|
||||
await expect(lookupGeoIpCity({ip: '198.51.100.7'})).resolves.toEqual({
|
||||
ip: '198.51.100.7',
|
||||
available: true,
|
||||
found: true,
|
||||
countryIso: 'SE',
|
||||
country: null,
|
||||
region: null,
|
||||
city: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
accuracyRadiusKm: null,
|
||||
timeZone: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to IPInfo when the local city database has no country', async () => {
|
||||
const counting = countingIpInfoService(ipInfoResult());
|
||||
const lookupGeoIpCity = createGeoIpCityAdapter({
|
||||
ipInfoService: counting.service,
|
||||
lookupLocalCity: async () => geoipResult({countryCode: null}),
|
||||
});
|
||||
await expect(lookupGeoIpCity({ip: '198.51.100.1'})).resolves.toEqual({
|
||||
ip: '198.51.100.1',
|
||||
available: true,
|
||||
found: true,
|
||||
countryIso: 'US',
|
||||
country: 'United States',
|
||||
region: 'California',
|
||||
city: 'San Jose',
|
||||
latitude: 37.3,
|
||||
longitude: -121.9,
|
||||
accuracyRadiusKm: 20,
|
||||
timeZone: 'America/Los_Angeles',
|
||||
});
|
||||
expect(counting.calls()).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to IPInfo when no local city lookup is wired', async () => {
|
||||
const counting = countingIpInfoService(ipInfoResult());
|
||||
const lookupGeoIpCity = createGeoIpCityAdapter({ipInfoService: counting.service});
|
||||
const result = await lookupGeoIpCity({ip: '198.51.100.1'});
|
||||
expect(result.countryIso).toBe('US');
|
||||
expect(counting.calls()).toBe(1);
|
||||
});
|
||||
|
||||
it('returns an available not-found result for an unparseable IP without any lookup', async () => {
|
||||
const lookupGeoIpCity = createGeoIpCityAdapter({
|
||||
ipInfoService: throwingIpInfoService(),
|
||||
lookupLocalCity: async () => {
|
||||
throw new Error('local city must not be consulted');
|
||||
},
|
||||
});
|
||||
await expect(lookupGeoIpCity({ip: 'not-an-ip'})).resolves.toEqual({
|
||||
ip: 'not-an-ip',
|
||||
available: true,
|
||||
found: false,
|
||||
countryIso: null,
|
||||
country: null,
|
||||
region: null,
|
||||
city: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
accuracyRadiusKm: null,
|
||||
timeZone: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGeoIpAsnAdapter', () => {
|
||||
it('answers from the local ASN database without touching IPInfo', async () => {
|
||||
const lookupGeoIpAsn = createGeoIpAsnAdapter({
|
||||
ipInfoService: throwingIpInfoService(),
|
||||
lookupLocalAsn: async () => geoipAsnResult(),
|
||||
});
|
||||
await expect(lookupGeoIpAsn({ip: '198.51.100.7'})).resolves.toEqual({
|
||||
ip: '198.51.100.7',
|
||||
available: true,
|
||||
found: true,
|
||||
asn: 64510,
|
||||
asnOrg: 'Example Broadband ISP',
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes the IP before handing it to the local ASN database', async () => {
|
||||
const seen: Array<string> = [];
|
||||
const lookupGeoIpAsn = createGeoIpAsnAdapter({
|
||||
ipInfoService: throwingIpInfoService(),
|
||||
lookupLocalAsn: async (ip) => {
|
||||
seen.push(ip);
|
||||
return geoipAsnResult();
|
||||
},
|
||||
});
|
||||
await lookupGeoIpAsn({ip: '[2001:0db8:0000::0001]'});
|
||||
expect(seen).toEqual(['2001:db8::1']);
|
||||
});
|
||||
|
||||
it('falls back to IPInfo when the local ASN database has no ASN', async () => {
|
||||
const counting = countingIpInfoService(ipInfoResult());
|
||||
const lookupGeoIpAsn = createGeoIpAsnAdapter({
|
||||
ipInfoService: counting.service,
|
||||
lookupLocalAsn: async () => geoipAsnResult({asn: null, asnOrg: null}),
|
||||
});
|
||||
await expect(lookupGeoIpAsn({ip: '198.51.100.1'})).resolves.toEqual({
|
||||
ip: '198.51.100.1',
|
||||
available: true,
|
||||
found: true,
|
||||
asn: 64500,
|
||||
asnOrg: 'Test ISP',
|
||||
});
|
||||
expect(counting.calls()).toBe(1);
|
||||
});
|
||||
|
||||
it('falls back to IPInfo when the local ASN database is unavailable', async () => {
|
||||
const counting = countingIpInfoService(ipInfoResult());
|
||||
const lookupGeoIpAsn = createGeoIpAsnAdapter({
|
||||
ipInfoService: counting.service,
|
||||
lookupLocalAsn: async () => geoipAsnResult({normalizedIp: null, asn: null, asnOrg: null, available: false}),
|
||||
});
|
||||
const result = await lookupGeoIpAsn({ip: '198.51.100.1'});
|
||||
expect(result.asn).toBe(64500);
|
||||
expect(counting.calls()).toBe(1);
|
||||
});
|
||||
|
||||
it('returns an available not-found result for an unparseable IP without any lookup', async () => {
|
||||
const lookupGeoIpAsn = createGeoIpAsnAdapter({
|
||||
ipInfoService: throwingIpInfoService(),
|
||||
lookupLocalAsn: async () => {
|
||||
throw new Error('local ASN must not be consulted');
|
||||
},
|
||||
});
|
||||
await expect(lookupGeoIpAsn({ip: 'not-an-ip'})).resolves.toEqual({
|
||||
ip: 'not-an-ip',
|
||||
available: true,
|
||||
found: false,
|
||||
asn: null,
|
||||
asnOrg: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
import type {IpInfoLookupResult} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {isHighCgnatBlastRadiusRisk, isSingleIpBanCandidate} from '../IpBanCgnatGuard';
|
||||
import {
|
||||
isHighCgnatBlastRadiusRisk,
|
||||
isHighSharedAccessBlastRadiusRisk,
|
||||
isSingleIpBanCandidate,
|
||||
} from '../IpBanCgnatGuard';
|
||||
|
||||
function ipInfoResult(overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
|
||||
return {
|
||||
@@ -96,4 +100,63 @@ describe('IpBanCgnatGuard', () => {
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it('flags satellite, anycast and education networks as high blast-radius risk', () => {
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
flags: {isAnycast: true, isHosting: false, isMobile: false, isSatellite: false},
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({asn: {asn: 'AS64500', number: 64500, name: 'Test University', domain: null, type: 'education'}}),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
it('does not flag ordinary residential networks as shared-access risk', () => {
|
||||
expect(isHighSharedAccessBlastRadiusRisk(ipInfoResult())).toBe(false);
|
||||
});
|
||||
it('does not treat shared-access networks as CGNAT risk', () => {
|
||||
expect(
|
||||
isHighCgnatBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
it('does not exempt hosting or anonymous shared-access infrastructure', () => {
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
flags: {isAnycast: true, isHosting: true, isMobile: false, isSatellite: false},
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isHighSharedAccessBlastRadiusRisk(
|
||||
ipInfoResult({
|
||||
anonymous: {
|
||||
isAnonymous: true,
|
||||
providerName: 'Example VPN',
|
||||
isVpn: true,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
isTor: false,
|
||||
isRelay: false,
|
||||
percentDaysSeen: null,
|
||||
},
|
||||
flags: {isAnycast: false, isHosting: false, isMobile: false, isSatellite: true},
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IpInfoLookupContext, IpInfoLookupResult, IpInfoService} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {createCurrentBehaviorTestAccountPolicyEvaluator} from '../../test/AccountPolicyTestEvaluator';
|
||||
import {setInjectedAccountPolicyEvaluator} from '../AccountPolicyService';
|
||||
import {createIpInfoChecker, unavailableIpInfoAnonymousResult} from '../adapters/IpInfoAdapter';
|
||||
|
||||
function ipInfoResult(overrides: Partial<IpInfoLookupResult> = {}): IpInfoLookupResult {
|
||||
return {
|
||||
ip: '198.51.100.1',
|
||||
available: true,
|
||||
riskNote: 'live lookup',
|
||||
geo: {
|
||||
countryCode: 'US',
|
||||
countryName: 'United States',
|
||||
continent: 'North America',
|
||||
continentCode: 'NA',
|
||||
region: null,
|
||||
regionCode: null,
|
||||
city: null,
|
||||
postalCode: null,
|
||||
timezone: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
accuracyRadiusKm: null,
|
||||
},
|
||||
asn: {
|
||||
asn: 'AS64500',
|
||||
number: 64500,
|
||||
name: 'Test ISP',
|
||||
domain: null,
|
||||
type: 'isp',
|
||||
},
|
||||
mobile: {
|
||||
name: null,
|
||||
mcc: null,
|
||||
mnc: null,
|
||||
},
|
||||
anonymous: {
|
||||
isAnonymous: true,
|
||||
providerName: 'Example VPN',
|
||||
isVpn: true,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
isTor: false,
|
||||
isRelay: false,
|
||||
percentDaysSeen: 42,
|
||||
},
|
||||
flags: {
|
||||
isAnycast: false,
|
||||
isHosting: false,
|
||||
isMobile: false,
|
||||
isSatellite: false,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function countingIpInfoService(result: IpInfoLookupResult): {
|
||||
service: IpInfoService;
|
||||
calls: () => number;
|
||||
contexts: () => Array<IpInfoLookupContext | undefined>;
|
||||
} {
|
||||
const contexts: Array<IpInfoLookupContext | undefined> = [];
|
||||
return {
|
||||
service: {
|
||||
lookup: async (_ip, context) => {
|
||||
contexts.push(context);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
calls: () => contexts.length,
|
||||
contexts: () => contexts,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setInjectedAccountPolicyEvaluator(createCurrentBehaviorTestAccountPolicyEvaluator());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setInjectedAccountPolicyEvaluator(undefined);
|
||||
});
|
||||
|
||||
describe('createIpInfoChecker pre-screen', () => {
|
||||
it('returns an unavailable result with zero lookups when the pre-screen skips', async () => {
|
||||
const counting = countingIpInfoService(ipInfoResult());
|
||||
const checkIpInfo = createIpInfoChecker({
|
||||
ipInfoService: counting.service,
|
||||
prescreen: async () => 'skip',
|
||||
});
|
||||
await expect(checkIpInfo('198.51.100.1')).resolves.toEqual(
|
||||
unavailableIpInfoAnonymousResult('198.51.100.1', 'IPInfo skipped (local pre-screen)'),
|
||||
);
|
||||
expect(counting.calls()).toBe(0);
|
||||
});
|
||||
|
||||
it('calls IPInfo exactly once and preserves the mapping when the pre-screen consults', async () => {
|
||||
const counting = countingIpInfoService(ipInfoResult());
|
||||
const checkIpInfo = createIpInfoChecker({
|
||||
ipInfoService: counting.service,
|
||||
prescreen: async () => 'consult',
|
||||
});
|
||||
await expect(checkIpInfo('198.51.100.1')).resolves.toEqual({
|
||||
ip: '198.51.100.1',
|
||||
available: true,
|
||||
isAnonymous: true,
|
||||
providerName: 'Example VPN',
|
||||
isVpn: true,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
isTor: false,
|
||||
isRelay: false,
|
||||
isHosting: false,
|
||||
isMobile: false,
|
||||
asnType: 'isp',
|
||||
asnOrg: 'Test ISP',
|
||||
connectionType: 'residential',
|
||||
percentDaysSeen: 42,
|
||||
riskNote: 'live lookup',
|
||||
});
|
||||
expect(counting.calls()).toBe(1);
|
||||
expect(counting.contexts()).toEqual([{source: 'risk.ipinfo_checker', reason: 'registration_risk'}]);
|
||||
});
|
||||
|
||||
it('is identical to a consulting pre-screen when no pre-screen is wired', async () => {
|
||||
const withoutPrescreen = countingIpInfoService(ipInfoResult());
|
||||
const withPrescreen = countingIpInfoService(ipInfoResult());
|
||||
const baseline = await createIpInfoChecker({ipInfoService: withoutPrescreen.service})('198.51.100.1');
|
||||
const consulted = await createIpInfoChecker({
|
||||
ipInfoService: withPrescreen.service,
|
||||
prescreen: async () => 'consult',
|
||||
})('198.51.100.1');
|
||||
expect(baseline).toEqual(consulted);
|
||||
expect(withoutPrescreen.calls()).toBe(1);
|
||||
expect(withPrescreen.calls()).toBe(1);
|
||||
});
|
||||
|
||||
it('never synthesizes a clean attestation for a skipped lookup', () => {
|
||||
const skipped = unavailableIpInfoAnonymousResult('198.51.100.1', 'IPInfo skipped (local pre-screen)');
|
||||
expect(skipped.available).toBe(false);
|
||||
expect(skipped.isAnonymous).toBe(false);
|
||||
expect(skipped.isVpn).toBe(false);
|
||||
expect(skipped.isProxy).toBe(false);
|
||||
expect(skipped.isResidentialProxy).toBe(false);
|
||||
expect(skipped.isTor).toBe(false);
|
||||
expect(skipped.isRelay).toBe(false);
|
||||
expect(skipped.isHosting).toBe(false);
|
||||
expect(skipped.isMobile).toBe(false);
|
||||
expect(skipped.asnType).toBeNull();
|
||||
expect(skipped.asnOrg).toBeNull();
|
||||
expect(skipped.connectionType).toBe('unknown');
|
||||
expect(skipped.percentDaysSeen).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {MockKVProvider} from '../../test/mocks/MockKVProvider';
|
||||
import {createKvIpInfoLookupBudget} from '../IpInfoBudget';
|
||||
|
||||
const BUDGET_ENV_KEYS = [
|
||||
'FLUXER_IPINFO_BUDGET_ENABLED',
|
||||
'FLUXER_IPINFO_BUDGET_MONTHLY_MAX',
|
||||
'FLUXER_IPINFO_BUDGET_BACKGROUND_MONTHLY_PCT',
|
||||
'FLUXER_IPINFO_BUDGET_STANDARD_MONTHLY_PCT',
|
||||
'FLUXER_IPINFO_BUDGET_CRITICAL_BURST',
|
||||
'FLUXER_IPINFO_BUDGET_STANDARD_BURST',
|
||||
'FLUXER_IPINFO_BUDGET_BACKGROUND_BURST',
|
||||
'FLUXER_IPINFO_BUDGET_CRITICAL_REFILL_PER_MIN',
|
||||
'FLUXER_IPINFO_BUDGET_STANDARD_REFILL_PER_MIN',
|
||||
'FLUXER_IPINFO_BUDGET_BACKGROUND_REFILL_PER_MIN',
|
||||
];
|
||||
|
||||
function monthKey(): string {
|
||||
const now = new Date();
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||
return `ipinfo:budget:month:${now.getUTCFullYear()}-${month}`;
|
||||
}
|
||||
|
||||
describe('IpInfoBudget', () => {
|
||||
const savedEnv = new Map<string, string | undefined>();
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of BUDGET_ENV_KEYS) {
|
||||
savedEnv.set(key, process.env[key]);
|
||||
delete process.env[key];
|
||||
}
|
||||
process.env.FLUXER_IPINFO_BUDGET_MONTHLY_MAX = '100';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const [key, value] of savedEnv) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
savedEnv.clear();
|
||||
});
|
||||
|
||||
it('sheds background lookups at the background monthly ceiling while standard still admits', async () => {
|
||||
const kv = new MockKVProvider();
|
||||
await kv.set(monthKey(), '60');
|
||||
const budget = createKvIpInfoLookupBudget({getKvClient: () => kv});
|
||||
|
||||
expect(await budget.tryConsume('background')).toBe(false);
|
||||
expect(await budget.tryConsume('standard')).toBe(true);
|
||||
});
|
||||
|
||||
it('sheds standard lookups at the standard monthly ceiling', async () => {
|
||||
const kv = new MockKVProvider();
|
||||
await kv.set(monthKey(), '90');
|
||||
const budget = createKvIpInfoLookupBudget({getKvClient: () => kv});
|
||||
|
||||
expect(await budget.tryConsume('standard')).toBe(false);
|
||||
});
|
||||
|
||||
it('admits critical lookups above the standard ceiling', async () => {
|
||||
const kv = new MockKVProvider();
|
||||
await kv.set(monthKey(), '95');
|
||||
const budget = createKvIpInfoLookupBudget({getKvClient: () => kv});
|
||||
|
||||
expect(await budget.tryConsume('critical')).toBe(true);
|
||||
});
|
||||
|
||||
it('never increments the monthly counter for a shed lookup', async () => {
|
||||
const kv = new MockKVProvider();
|
||||
await kv.set(monthKey(), '60');
|
||||
const budget = createKvIpInfoLookupBudget({getKvClient: () => kv});
|
||||
|
||||
const outcomes = [
|
||||
await budget.tryConsume('background'),
|
||||
await budget.tryConsume('background'),
|
||||
await budget.tryConsume('standard'),
|
||||
await budget.tryConsume('critical'),
|
||||
];
|
||||
|
||||
expect(outcomes).toEqual([false, false, true, true]);
|
||||
expect(kv.incrSpy).toHaveBeenCalledTimes(2);
|
||||
expect(await kv.get(monthKey())).toBe('62');
|
||||
});
|
||||
|
||||
it('sheds once the per-priority burst bucket is drained', async () => {
|
||||
process.env.FLUXER_IPINFO_BUDGET_BACKGROUND_BURST = '3';
|
||||
process.env.FLUXER_IPINFO_BUDGET_BACKGROUND_REFILL_PER_MIN = '1';
|
||||
const kv = new MockKVProvider();
|
||||
const budget = createKvIpInfoLookupBudget({getKvClient: () => kv});
|
||||
|
||||
expect(await budget.tryConsume('background')).toBe(true);
|
||||
expect(await budget.tryConsume('background')).toBe(true);
|
||||
expect(await budget.tryConsume('background')).toBe(true);
|
||||
expect(await budget.tryConsume('background')).toBe(false);
|
||||
expect(await budget.tryConsume('standard')).toBe(true);
|
||||
});
|
||||
|
||||
it('expires the month key only on the first increment', async () => {
|
||||
const kv = new MockKVProvider();
|
||||
const budget = createKvIpInfoLookupBudget({getKvClient: () => kv});
|
||||
|
||||
expect(await budget.tryConsume('standard')).toBe(true);
|
||||
expect(await budget.tryConsume('standard')).toBe(true);
|
||||
expect(await budget.tryConsume('standard')).toBe(true);
|
||||
|
||||
expect(kv.expireSpy).toHaveBeenCalledTimes(1);
|
||||
expect(kv.expireSpy).toHaveBeenCalledWith(monthKey(), 40 * 24 * 3600);
|
||||
});
|
||||
|
||||
it('fails open for every priority when the KV provider throws', async () => {
|
||||
const kv = new MockKVProvider();
|
||||
vi.spyOn(kv, 'get').mockRejectedValue(new Error('kv unavailable'));
|
||||
const budget = createKvIpInfoLookupBudget({getKvClient: () => kv});
|
||||
|
||||
expect(await budget.tryConsume('background')).toBe(true);
|
||||
expect(await budget.tryConsume('standard')).toBe(true);
|
||||
expect(await budget.tryConsume('critical')).toBe(true);
|
||||
});
|
||||
|
||||
it('admits everything when the budget is disabled', async () => {
|
||||
process.env.FLUXER_IPINFO_BUDGET_ENABLED = '0';
|
||||
const kv = new MockKVProvider();
|
||||
await kv.set(monthKey(), '1000');
|
||||
const budget = createKvIpInfoLookupBudget({getKvClient: () => kv});
|
||||
|
||||
expect(await budget.tryConsume('background')).toBe(true);
|
||||
expect(kv.tryConsumeTokensSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {
|
||||
CachedIpInfoFailure,
|
||||
IpInfoCache,
|
||||
IpInfoLookupBudget,
|
||||
IpInfoLookupPriority,
|
||||
IpInfoRequestAuditEvent,
|
||||
IpInfoRequestAuditLogger,
|
||||
} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {createIpInfoService, resolveIpInfoLookupPriority} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {delay, HttpResponse, http} from 'msw';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {server} from '../../test/msw/server';
|
||||
|
||||
interface RecordedSet {
|
||||
key: string;
|
||||
value: unknown;
|
||||
ttlSeconds: number | undefined;
|
||||
}
|
||||
|
||||
interface RecordingCache {
|
||||
cache: IpInfoCache;
|
||||
sets: Array<RecordedSet>;
|
||||
}
|
||||
|
||||
function createRecordingCache(): RecordingCache {
|
||||
const store = new Map<string, unknown>();
|
||||
const sets: Array<RecordedSet> = [];
|
||||
return {
|
||||
sets,
|
||||
cache: {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
return (store.get(key) as T | undefined) ?? null;
|
||||
},
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
store.set(key, value);
|
||||
sets.push({key, value, ttlSeconds});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createRecordingAuditLogger(): {logger: IpInfoRequestAuditLogger; events: Array<IpInfoRequestAuditEvent>} {
|
||||
const events: Array<IpInfoRequestAuditEvent> = [];
|
||||
return {
|
||||
events,
|
||||
logger: {
|
||||
async record(event: IpInfoRequestAuditEvent): Promise<void> {
|
||||
events.push(event);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function useLookupHandler(handler: () => Response | Promise<Response>): {count: () => number} {
|
||||
let calls = 0;
|
||||
server.use(
|
||||
http.get('https://api.ipinfo.io/lookup/:ip', async () => {
|
||||
calls += 1;
|
||||
return await handler();
|
||||
}),
|
||||
);
|
||||
return {count: () => calls};
|
||||
}
|
||||
|
||||
function successPayload(ip: string, anonymous: Record<string, boolean> = {}): Response {
|
||||
return HttpResponse.json({
|
||||
ip,
|
||||
geo: {country_code: 'US', country: 'United States'},
|
||||
as: {asn: 'AS64500', name: 'Test ISP'},
|
||||
anonymous,
|
||||
});
|
||||
}
|
||||
|
||||
describe('IpInfoService caching', () => {
|
||||
it('negative-caches an HTTP error and serves the second lookup without a request', async () => {
|
||||
const requests = useLookupHandler(() => new HttpResponse(null, {status: 500}));
|
||||
const {cache, sets} = createRecordingCache();
|
||||
const service = createIpInfoService({apiKey: 'token', cache});
|
||||
|
||||
const first = await service.lookup('203.0.113.1');
|
||||
const second = await service.lookup('203.0.113.1');
|
||||
|
||||
expect(first.available).toBe(false);
|
||||
expect(second.available).toBe(false);
|
||||
expect(requests.count()).toBe(1);
|
||||
expect(sets).toHaveLength(1);
|
||||
expect(sets[0]?.ttlSeconds).toBe(300);
|
||||
});
|
||||
|
||||
it('negative-caches a request failure for a short window', async () => {
|
||||
useLookupHandler(async () => {
|
||||
await delay(5000);
|
||||
return successPayload('203.0.113.2');
|
||||
});
|
||||
const {cache, sets} = createRecordingCache();
|
||||
const service = createIpInfoService({apiKey: 'token', cache});
|
||||
|
||||
const result = await service.lookup('203.0.113.2');
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
expect(sets[0]?.ttlSeconds).toBe(60);
|
||||
expect((sets[0]?.value as CachedIpInfoFailure).failureOutcome).toBe('request_failed');
|
||||
});
|
||||
|
||||
it('negative-caches a schema mismatch', async () => {
|
||||
useLookupHandler(() => HttpResponse.json({}));
|
||||
const {cache, sets} = createRecordingCache();
|
||||
const service = createIpInfoService({apiKey: 'token', cache});
|
||||
|
||||
const result = await service.lookup('203.0.113.3');
|
||||
|
||||
expect(result.available).toBe(false);
|
||||
expect(sets[0]?.ttlSeconds).toBe(600);
|
||||
expect((sets[0]?.value as CachedIpInfoFailure).failureOutcome).toBe('schema_mismatch');
|
||||
expect((sets[0]?.value as CachedIpInfoFailure).failureHttpStatus).toBe(200);
|
||||
});
|
||||
|
||||
it('negative-caches a quota rejection for longer', async () => {
|
||||
useLookupHandler(() => new HttpResponse(null, {status: 429}));
|
||||
const {cache, sets} = createRecordingCache();
|
||||
const service = createIpInfoService({apiKey: 'token', cache});
|
||||
|
||||
await service.lookup('203.0.113.4');
|
||||
|
||||
expect(sets[0]?.ttlSeconds).toBe(900);
|
||||
});
|
||||
|
||||
it('caps the quota rejection TTL for background lookups', async () => {
|
||||
useLookupHandler(() => new HttpResponse(null, {status: 429}));
|
||||
const {cache, sets} = createRecordingCache();
|
||||
const service = createIpInfoService({apiKey: 'token', cache});
|
||||
|
||||
await service.lookup('203.0.113.5', {source: 'AbusiveIpAutoBanner', reason: 'classify'});
|
||||
|
||||
expect(sets[0]?.ttlSeconds).toBe(120);
|
||||
});
|
||||
|
||||
it('returns a cached failure as a clean unavailable result', async () => {
|
||||
useLookupHandler(() => new HttpResponse(null, {status: 500}));
|
||||
const {cache} = createRecordingCache();
|
||||
const service = createIpInfoService({apiKey: 'token', cache});
|
||||
|
||||
await service.lookup('203.0.113.6');
|
||||
const cached = await service.lookup('203.0.113.6');
|
||||
|
||||
expect(cached).not.toHaveProperty('cachedFailure');
|
||||
expect(cached).not.toHaveProperty('failureOutcome');
|
||||
expect(cached).not.toHaveProperty('failureHttpStatus');
|
||||
expect(cached).not.toHaveProperty('cachedAtMs');
|
||||
expect(cached.ip).toBe('203.0.113.6');
|
||||
expect(cached.riskNote).toBe('IPInfo HTTP 500');
|
||||
});
|
||||
|
||||
it('writes a cached failure that older readers can still consume', async () => {
|
||||
useLookupHandler(() => new HttpResponse(null, {status: 500}));
|
||||
const {cache, sets} = createRecordingCache();
|
||||
const service = createIpInfoService({apiKey: 'token', cache});
|
||||
|
||||
await service.lookup('203.0.113.7');
|
||||
|
||||
const entry = sets[0]?.value as CachedIpInfoFailure;
|
||||
expect(entry.cachedFailure).toBe(true);
|
||||
expect(entry.failureOutcome).toBe('http_error');
|
||||
expect(entry.failureHttpStatus).toBe(500);
|
||||
expect(typeof entry.cachedAtMs).toBe('number');
|
||||
const legacyView = {...entry, ip: '203.0.113.7'};
|
||||
expect(legacyView.available).toBe(false);
|
||||
expect(legacyView.geo.countryCode).toBeNull();
|
||||
expect(legacyView.asn.number).toBeNull();
|
||||
expect(legacyView.mobile.name).toBeNull();
|
||||
expect(legacyView.anonymous.isAnonymous).toBe(false);
|
||||
expect(legacyView.flags.isMobile).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the existing success TTL selection', async () => {
|
||||
useLookupHandler(() => successPayload('203.0.113.8'));
|
||||
const plain = createRecordingCache();
|
||||
await createIpInfoService({apiKey: 'token', cache: plain.cache}).lookup('203.0.113.8');
|
||||
|
||||
useLookupHandler(() => successPayload('203.0.113.9', {is_vpn: true}));
|
||||
const anonymous = createRecordingCache();
|
||||
await createIpInfoService({apiKey: 'token', cache: anonymous.cache}).lookup('203.0.113.9');
|
||||
|
||||
expect(plain.sets[0]?.ttlSeconds).toBe(14 * 24 * 60 * 60);
|
||||
expect(anonymous.sets[0]?.ttlSeconds).toBe(7 * 24 * 60 * 60);
|
||||
});
|
||||
|
||||
it('coalesces concurrent lookups across a failure', async () => {
|
||||
const requests = useLookupHandler(() => new HttpResponse(null, {status: 500}));
|
||||
const {cache, sets} = createRecordingCache();
|
||||
const service = createIpInfoService({apiKey: 'token', cache});
|
||||
|
||||
const [first, second] = await Promise.all([service.lookup('203.0.113.10'), service.lookup('203.0.113.10')]);
|
||||
|
||||
expect(requests.count()).toBe(1);
|
||||
expect(sets).toHaveLength(1);
|
||||
expect(first.available).toBe(false);
|
||||
expect(second.available).toBe(false);
|
||||
});
|
||||
|
||||
it('sheds a lookup when the budget refuses it', async () => {
|
||||
const requests = useLookupHandler(() => successPayload('203.0.113.11'));
|
||||
const {cache, sets} = createRecordingCache();
|
||||
const {logger, events} = createRecordingAuditLogger();
|
||||
const refused: Array<IpInfoLookupPriority> = [];
|
||||
const budget: IpInfoLookupBudget = {
|
||||
async tryConsume(priority: IpInfoLookupPriority): Promise<boolean> {
|
||||
refused.push(priority);
|
||||
return priority !== 'background';
|
||||
},
|
||||
};
|
||||
const service = createIpInfoService({apiKey: 'token', cache, auditLogger: logger, budget});
|
||||
|
||||
const result = await service.lookup('203.0.113.11', {source: 'AbusiveIpAutoBanner', reason: 'classify'});
|
||||
|
||||
expect(refused).toEqual(['background']);
|
||||
expect(requests.count()).toBe(0);
|
||||
expect(result.available).toBe(false);
|
||||
expect(result.riskNote).toContain('shed');
|
||||
expect(sets).toHaveLength(0);
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.outcome).toBe('budget_shed');
|
||||
expect(events[0]?.httpStatus).toBeNull();
|
||||
});
|
||||
|
||||
it('still coalesces concurrent lookups when a budget is configured', async () => {
|
||||
const requests = useLookupHandler(() => successPayload('203.0.113.13'));
|
||||
const {cache} = createRecordingCache();
|
||||
const consumed: Array<IpInfoLookupPriority> = [];
|
||||
const budget: IpInfoLookupBudget = {
|
||||
async tryConsume(priority: IpInfoLookupPriority): Promise<boolean> {
|
||||
consumed.push(priority);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const service = createIpInfoService({apiKey: 'token', cache, budget});
|
||||
|
||||
const results = await Promise.all([
|
||||
service.lookup('203.0.113.13', {source: 'risk.geoip_city'}),
|
||||
service.lookup('203.0.113.13', {source: 'risk.geoip_asn'}),
|
||||
service.lookup('203.0.113.13', {source: 'risk.ipinfo_checker'}),
|
||||
]);
|
||||
|
||||
expect(requests.count()).toBe(1);
|
||||
expect(consumed).toEqual(['standard']);
|
||||
expect(results.every((result) => result.available)).toBe(true);
|
||||
});
|
||||
|
||||
it('lets an admitting budget through', async () => {
|
||||
const requests = useLookupHandler(() => successPayload('203.0.113.12'));
|
||||
const {cache} = createRecordingCache();
|
||||
const budget: IpInfoLookupBudget = {
|
||||
async tryConsume(): Promise<boolean> {
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const service = createIpInfoService({apiKey: 'token', cache, budget});
|
||||
|
||||
const result = await service.lookup('203.0.113.12', {source: 'risk.ipinfo_checker'});
|
||||
|
||||
expect(requests.count()).toBe(1);
|
||||
expect(result.available).toBe(true);
|
||||
});
|
||||
|
||||
it('maps every lookup source to a priority', () => {
|
||||
expect(resolveIpInfoLookupPriority('admin.ip_ban')).toBe('critical');
|
||||
expect(resolveIpInfoLookupPriority('admin.scheduled_deletion_suspicious_ip')).toBe('critical');
|
||||
expect(resolveIpInfoLookupPriority('AbusiveIpAutoBanner')).toBe('background');
|
||||
expect(resolveIpInfoLookupPriority('risk.ipinfo_checker')).toBe('standard');
|
||||
expect(resolveIpInfoLookupPriority('risk.geoip_city')).toBe('standard');
|
||||
expect(resolveIpInfoLookupPriority('risk.geoip_asn')).toBe('standard');
|
||||
expect(resolveIpInfoLookupPriority(undefined)).toBe('standard');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {createCurrentBehaviorTestAccountPolicyEvaluator} from '../../test/AccountPolicyTestEvaluator';
|
||||
import {setInjectedAccountPolicyEvaluator} from '../AccountPolicyService';
|
||||
import {
|
||||
type IpInfoPrescreenOptions,
|
||||
ipInfoPrescreenOptionsFromEnv,
|
||||
type LocalIpIntel,
|
||||
prescreenIpInfoLookup,
|
||||
} from '../RegistrationIpPrescreen';
|
||||
|
||||
const CLEAN_LOCAL: LocalIpIntel = {countryIso: 'SE', asn: 64500, asnOrg: 'Example Broadband ISP'};
|
||||
|
||||
function options(overrides: Partial<IpInfoPrescreenOptions> = {}): IpInfoPrescreenOptions {
|
||||
return {enabled: true, allowedAsns: new Set([64500]), ...overrides};
|
||||
}
|
||||
|
||||
function local(overrides: Partial<LocalIpIntel> = {}): LocalIpIntel {
|
||||
return {...CLEAN_LOCAL, ...overrides};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setInjectedAccountPolicyEvaluator(createCurrentBehaviorTestAccountPolicyEvaluator());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setInjectedAccountPolicyEvaluator(undefined);
|
||||
delete process.env.FLUXER_RISK_IPINFO_PRESCREEN_ENABLED;
|
||||
delete process.env.FLUXER_RISK_IPINFO_PRESCREEN_ALLOW_ASNS;
|
||||
});
|
||||
|
||||
describe('prescreenIpInfoLookup', () => {
|
||||
it('skips the lookup only when every local signal is clean and allowlisted', () => {
|
||||
expect(prescreenIpInfoLookup(local(), options())).toBe('skip');
|
||||
});
|
||||
|
||||
it('consults when the pre-screen is disabled', () => {
|
||||
expect(prescreenIpInfoLookup(local(), options({enabled: false}))).toBe('consult');
|
||||
});
|
||||
|
||||
it('consults when the allowlist is empty', () => {
|
||||
expect(prescreenIpInfoLookup(local(), options({allowedAsns: new Set()}))).toBe('consult');
|
||||
});
|
||||
|
||||
it('consults when the local city database has no country', () => {
|
||||
expect(prescreenIpInfoLookup(local({countryIso: null}), options())).toBe('consult');
|
||||
});
|
||||
|
||||
it('consults when the local ASN database has no ASN', () => {
|
||||
expect(prescreenIpInfoLookup(local({asn: null}), options())).toBe('consult');
|
||||
});
|
||||
|
||||
it('consults when the ASN is not on the allowlist', () => {
|
||||
expect(prescreenIpInfoLookup(local({asn: 64501}), options())).toBe('consult');
|
||||
});
|
||||
|
||||
it('consults for a trusted commercial privacy provider even on the allowlist', () => {
|
||||
expect(prescreenIpInfoLookup(local({asnOrg: 'Example Privacy Relay LLC'}), options())).toBe('consult');
|
||||
});
|
||||
|
||||
it('consults for an education organization even on the allowlist', () => {
|
||||
expect(prescreenIpInfoLookup(local({asnOrg: 'North Example Academy'}), options())).toBe('consult');
|
||||
});
|
||||
|
||||
it('consults for a cellular organization even on the allowlist', () => {
|
||||
expect(prescreenIpInfoLookup(local({asnOrg: 'Example cell-net Wireless'}), options())).toBe('consult');
|
||||
});
|
||||
|
||||
it('skips when the allowlisted ASN carries no organization name to veto', () => {
|
||||
expect(prescreenIpInfoLookup(local({asnOrg: null}), options())).toBe('skip');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ipInfoPrescreenOptionsFromEnv', () => {
|
||||
it('is disabled with an empty allowlist by default', () => {
|
||||
const parsed = ipInfoPrescreenOptionsFromEnv();
|
||||
expect(parsed.enabled).toBe(false);
|
||||
expect(parsed.allowedAsns.size).toBe(0);
|
||||
});
|
||||
|
||||
it('treats only 1 and true as enabled', () => {
|
||||
process.env.FLUXER_RISK_IPINFO_PRESCREEN_ENABLED = '1';
|
||||
expect(ipInfoPrescreenOptionsFromEnv().enabled).toBe(true);
|
||||
process.env.FLUXER_RISK_IPINFO_PRESCREEN_ENABLED = 'TRUE';
|
||||
expect(ipInfoPrescreenOptionsFromEnv().enabled).toBe(true);
|
||||
process.env.FLUXER_RISK_IPINFO_PRESCREEN_ENABLED = 'yes';
|
||||
expect(ipInfoPrescreenOptionsFromEnv().enabled).toBe(false);
|
||||
process.env.FLUXER_RISK_IPINFO_PRESCREEN_ENABLED = '0';
|
||||
expect(ipInfoPrescreenOptionsFromEnv().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('parses a comma separated allowlist and drops non numeric entries', () => {
|
||||
process.env.FLUXER_RISK_IPINFO_PRESCREEN_ALLOW_ASNS = ' 64500, 64501 ,,notanasn,64502x,-3,64503 ';
|
||||
expect([...ipInfoPrescreenOptionsFromEnv().allowedAsns]).toEqual([64500, 64501, 64503]);
|
||||
});
|
||||
|
||||
it('parses an empty allowlist from an empty or whitespace value', () => {
|
||||
process.env.FLUXER_RISK_IPINFO_PRESCREEN_ALLOW_ASNS = '';
|
||||
expect(ipInfoPrescreenOptionsFromEnv().allowedAsns.size).toBe(0);
|
||||
process.env.FLUXER_RISK_IPINFO_PRESCREEN_ALLOW_ASNS = ' , ,';
|
||||
expect(ipInfoPrescreenOptionsFromEnv().allowedAsns.size).toBe(0);
|
||||
});
|
||||
|
||||
it('yields a consult verdict for every input with the shipped defaults', () => {
|
||||
const shipped = ipInfoPrescreenOptionsFromEnv();
|
||||
expect(prescreenIpInfoLookup(local(), shipped)).toBe('consult');
|
||||
expect(prescreenIpInfoLookup(local({asnOrg: null}), shipped)).toBe('consult');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {createTieredIpInfoCache} from '@pkgs/geoip/src/TieredIpInfoCache';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
interface RecordedSet {
|
||||
key: string;
|
||||
value: unknown;
|
||||
ttlSeconds: number | undefined;
|
||||
}
|
||||
|
||||
interface RecordingCache {
|
||||
cache: IpInfoCache;
|
||||
store: Map<string, unknown>;
|
||||
sets: Array<RecordedSet>;
|
||||
}
|
||||
|
||||
function createRecordingCache(): RecordingCache {
|
||||
const store = new Map<string, unknown>();
|
||||
const sets: Array<RecordedSet> = [];
|
||||
return {
|
||||
store,
|
||||
sets,
|
||||
cache: {
|
||||
async get<T>(key: string): Promise<T | null> {
|
||||
return (store.get(key) as T | undefined) ?? null;
|
||||
},
|
||||
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
|
||||
store.set(key, value);
|
||||
sets.push({key, value, ttlSeconds});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('TieredIpInfoCache', () => {
|
||||
it('clamps the hot TTL to the requested TTL and passes the raw TTL to the cold tier', async () => {
|
||||
const hot = createRecordingCache();
|
||||
const cold = createRecordingCache();
|
||||
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
|
||||
|
||||
await tiered.set('a', {available: false}, 60);
|
||||
|
||||
expect(hot.sets).toEqual([{key: 'a', value: {available: false}, ttlSeconds: 60}]);
|
||||
expect(cold.sets).toEqual([{key: 'a', value: {available: false}, ttlSeconds: 60}]);
|
||||
});
|
||||
|
||||
it('caps the hot TTL at the configured hot window', async () => {
|
||||
const hot = createRecordingCache();
|
||||
const cold = createRecordingCache();
|
||||
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
|
||||
|
||||
await tiered.set('a', {available: true}, 100000);
|
||||
|
||||
expect(hot.sets[0]?.ttlSeconds).toBe(600);
|
||||
expect(cold.sets[0]?.ttlSeconds).toBe(100000);
|
||||
});
|
||||
|
||||
it('uses the hot window when no TTL is supplied', async () => {
|
||||
const hot = createRecordingCache();
|
||||
const cold = createRecordingCache();
|
||||
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
|
||||
|
||||
await tiered.set('a', {available: true});
|
||||
|
||||
expect(hot.sets[0]?.ttlSeconds).toBe(600);
|
||||
expect(cold.sets[0]?.ttlSeconds).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips the cold write when skipColdWrite matches', async () => {
|
||||
const hot = createRecordingCache();
|
||||
const cold = createRecordingCache();
|
||||
const tiered = createTieredIpInfoCache({
|
||||
hot: hot.cache,
|
||||
cold: cold.cache,
|
||||
skipColdWrite: (value) => (value as {available?: unknown}).available === false,
|
||||
});
|
||||
|
||||
await tiered.set('a', {available: false}, 60);
|
||||
await tiered.set('b', {available: true}, 60);
|
||||
|
||||
expect(hot.sets.map((entry) => entry.key)).toEqual(['a', 'b']);
|
||||
expect(cold.sets.map((entry) => entry.key)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('promotes a cold hit into the hot tier', async () => {
|
||||
const hot = createRecordingCache();
|
||||
const cold = createRecordingCache();
|
||||
cold.store.set('a', {available: true});
|
||||
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
|
||||
|
||||
const hit = await tiered.get('a');
|
||||
|
||||
expect(hit).toEqual({available: true});
|
||||
expect(hot.sets).toEqual([{key: 'a', value: {available: true}, ttlSeconds: 600}]);
|
||||
});
|
||||
|
||||
it('never promotes a cold hit that skipColdWrite matches', async () => {
|
||||
const hot = createRecordingCache();
|
||||
const cold = createRecordingCache();
|
||||
cold.store.set('a', {available: false});
|
||||
const tiered = createTieredIpInfoCache({
|
||||
hot: hot.cache,
|
||||
cold: cold.cache,
|
||||
skipColdWrite: (value) => (value as {available?: unknown}).available === false,
|
||||
});
|
||||
|
||||
const hit = await tiered.get('a');
|
||||
|
||||
expect(hit).toEqual({available: false});
|
||||
expect(hot.sets).toEqual([]);
|
||||
});
|
||||
|
||||
it('never writes a zero TTL', async () => {
|
||||
const hot = createRecordingCache();
|
||||
const cold = createRecordingCache();
|
||||
const tiered = createTieredIpInfoCache({hot: hot.cache, cold: cold.cache});
|
||||
|
||||
await tiered.set('a', {available: false}, 60);
|
||||
await tiered.set('b', {available: true}, 100000);
|
||||
await tiered.set('c', {available: true});
|
||||
cold.store.set('d', {available: true});
|
||||
await tiered.get('d');
|
||||
|
||||
for (const entry of [...hot.sets, ...cold.sets]) {
|
||||
expect(entry.ttlSeconds === undefined || entry.ttlSeconds > 0).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,15 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {parseIpAddress} from '@fluxer/ip_utils/src/IpAddress';
|
||||
import type {GeoipAsnResult, GeoipResult} from '@pkgs/geoip/src/GeoipLookup';
|
||||
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
|
||||
import type {GeoIpAsnResult, GeoIpCityResult} from '../RiskTypes';
|
||||
|
||||
interface GeoIpCityContext {
|
||||
ipInfoService: IpInfoService;
|
||||
lookupLocalCity?: (ip: string) => Promise<GeoipResult>;
|
||||
}
|
||||
|
||||
interface GeoIpAsnContext {
|
||||
ipInfoService: IpInfoService;
|
||||
lookupLocalAsn?: (ip: string) => Promise<GeoipAsnResult>;
|
||||
}
|
||||
|
||||
export function createGeoIpCityAdapter(ctx: GeoIpCityContext) {
|
||||
@@ -19,6 +22,22 @@ export function createGeoIpCityAdapter(ctx: GeoIpCityContext) {
|
||||
if (!parsed) {
|
||||
return notFound(ip, true);
|
||||
}
|
||||
const local = ctx.lookupLocalCity ? await ctx.lookupLocalCity(parsed.normalized) : null;
|
||||
if (local && local.countryCode !== null) {
|
||||
return {
|
||||
ip,
|
||||
available: true,
|
||||
found: true,
|
||||
countryIso: local.countryCode,
|
||||
country: local.countryName,
|
||||
region: local.region,
|
||||
city: local.city,
|
||||
latitude: local.latitude ?? null,
|
||||
longitude: local.longitude ?? null,
|
||||
accuracyRadiusKm: local.accuracyRadiusKm ?? null,
|
||||
timeZone: local.timeZone ?? null,
|
||||
};
|
||||
}
|
||||
const info = await ctx.ipInfoService.lookup(parsed.normalized, {
|
||||
source: 'risk.geoip_city',
|
||||
reason: 'registration_risk',
|
||||
@@ -63,6 +82,10 @@ export function createGeoIpAsnAdapter(ctx: GeoIpAsnContext) {
|
||||
if (!parsed) {
|
||||
return {ip, available: true, found: false, asn: null, asnOrg: null};
|
||||
}
|
||||
const local = ctx.lookupLocalAsn ? await ctx.lookupLocalAsn(parsed.normalized) : null;
|
||||
if (local && local.asn !== null) {
|
||||
return {ip, available: true, found: true, asn: local.asn, asnOrg: local.asnOrg};
|
||||
}
|
||||
const info = await ctx.ipInfoService.lookup(parsed.normalized, {
|
||||
source: 'risk.geoip_asn',
|
||||
reason: 'registration_risk',
|
||||
|
||||
@@ -2,14 +2,40 @@
|
||||
|
||||
import type {IpInfoService} from '@pkgs/geoip/src/IpInfoService';
|
||||
import {isAccountPolicyEducationOrganizationName} from '../AccountPolicyService';
|
||||
import type {IpInfoPrescreenVerdict} from '../RegistrationIpPrescreen';
|
||||
import type {IpConnectionType, IpInfoAnonymousResult} from '../RiskTypes';
|
||||
|
||||
interface IpInfoCheckerContext {
|
||||
ipInfoService: IpInfoService;
|
||||
prescreen?: (ip: string) => Promise<IpInfoPrescreenVerdict>;
|
||||
}
|
||||
|
||||
export function unavailableIpInfoAnonymousResult(ip: string, riskNote: string): IpInfoAnonymousResult {
|
||||
return {
|
||||
ip,
|
||||
available: false,
|
||||
isAnonymous: false,
|
||||
providerName: null,
|
||||
isVpn: false,
|
||||
isProxy: false,
|
||||
isResidentialProxy: false,
|
||||
isTor: false,
|
||||
isRelay: false,
|
||||
isHosting: false,
|
||||
isMobile: false,
|
||||
asnType: null,
|
||||
asnOrg: null,
|
||||
connectionType: 'unknown',
|
||||
percentDaysSeen: null,
|
||||
riskNote,
|
||||
};
|
||||
}
|
||||
|
||||
export function createIpInfoChecker(ctx: IpInfoCheckerContext) {
|
||||
return async function checkIpInfo(ip: string): Promise<IpInfoAnonymousResult> {
|
||||
if (ctx.prescreen && (await ctx.prescreen(ip)) === 'skip') {
|
||||
return unavailableIpInfoAnonymousResult(ip, 'IPInfo skipped (local pre-screen)');
|
||||
}
|
||||
const result = await ctx.ipInfoService.lookup(ip, {
|
||||
source: 'risk.ipinfo_checker',
|
||||
reason: 'registration_risk',
|
||||
|
||||
@@ -253,7 +253,7 @@ export class GlobalSearchService {
|
||||
page,
|
||||
cursor,
|
||||
});
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result, userId, requestCache);
|
||||
const mappedResponses = await this.responseMapper.mapSearchResultToResponses(result.messages, userId, requestCache);
|
||||
return {
|
||||
messages: mappedResponses.messages,
|
||||
channels: mappedResponses.channels,
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {
|
||||
MessageResponse,
|
||||
MessageSearchResultsResponse,
|
||||
} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {MessageSearchResultsResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import type {UserID} from '../BrandedTypes';
|
||||
import {createChannelID, createMessageID} from '../BrandedTypes';
|
||||
import {createChannelID} from '../BrandedTypes';
|
||||
import {mapChannelToResponse} from '../channel/ChannelMappers';
|
||||
import type {IChannelRepository} from '../channel/IChannelRepository';
|
||||
import {
|
||||
createMessageResponseDataService,
|
||||
messageResponseAccessForChannel,
|
||||
} from '../channel/services/message/MessageResponseDataService';
|
||||
import {createMessageResponseDataService} from '../channel/services/message/MessageResponseDataService';
|
||||
import type {UserCacheService} from '../infrastructure/UserCacheService';
|
||||
import type {RequestCache} from '../middleware/RequestCacheMiddleware';
|
||||
import type {Channel} from '../models/Channel';
|
||||
import type {Message} from '../models/Message';
|
||||
import {mapWithConcurrency} from '../utils/ConcurrencyUtils';
|
||||
|
||||
const CHANNEL_LOOKUP_CONCURRENCY = 16;
|
||||
|
||||
export class MessageSearchResponseMapper {
|
||||
constructor(
|
||||
@@ -23,71 +21,46 @@ export class MessageSearchResponseMapper {
|
||||
) {}
|
||||
|
||||
async mapSearchResultToResponses(
|
||||
result: {
|
||||
hits: Array<{
|
||||
channelId: string;
|
||||
id: string;
|
||||
}>;
|
||||
total: number;
|
||||
},
|
||||
messages: Array<Message>,
|
||||
userId: UserID,
|
||||
requestCache: RequestCache,
|
||||
): Promise<{
|
||||
messages: Array<MessageSearchResultsResponse['messages'][number]>;
|
||||
channels: Array<MessageSearchResultsResponse['channels'][number]>;
|
||||
}> {
|
||||
const messageEntries = result.hits.map((hit) => ({
|
||||
channelId: createChannelID(BigInt(hit.channelId)),
|
||||
messageId: createMessageID(BigInt(hit.id)),
|
||||
}));
|
||||
const orderedChannelIds = new Set<string>();
|
||||
for (const entry of messageEntries) {
|
||||
orderedChannelIds.add(entry.channelId.toString());
|
||||
}
|
||||
const channels = await Promise.all(
|
||||
Array.from(orderedChannelIds).map((channelId) =>
|
||||
this.channelRepository.findUnique(createChannelID(BigInt(channelId))),
|
||||
),
|
||||
const orderedChannelIds = Array.from(new Set(messages.map((message) => message.channelId.toString())));
|
||||
const channels = await mapWithConcurrency(orderedChannelIds, CHANNEL_LOOKUP_CONCURRENCY, (channelId) =>
|
||||
this.channelRepository.findUnique(createChannelID(BigInt(channelId))),
|
||||
);
|
||||
const validChannels = channels.filter((channel): channel is Channel => channel !== null);
|
||||
const channelById = new Map(validChannels.map((channel) => [channel.id.toString(), channel] as const));
|
||||
const responseDataService = createMessageResponseDataService();
|
||||
const messageResponsesWithEntries = await Promise.all(
|
||||
messageEntries.map(async (entry) => {
|
||||
const channel = channelById.get(entry.channelId.toString());
|
||||
if (!channel) return null;
|
||||
const message = await responseDataService.getMessage({
|
||||
userId,
|
||||
channelId: entry.channelId,
|
||||
messageId: entry.messageId,
|
||||
access: messageResponseAccessForChannel(channel),
|
||||
});
|
||||
return message ? {message, channelId: entry.channelId.toString()} : null;
|
||||
}),
|
||||
const channelById = new Map(
|
||||
channels
|
||||
.filter((channel): channel is Channel => channel !== null)
|
||||
.map((channel) => [channel.id.toString(), channel] as const),
|
||||
);
|
||||
const validMessageResponseEntries = messageResponsesWithEntries.filter(
|
||||
(entry): entry is {message: MessageResponse; channelId: string} => entry !== null,
|
||||
);
|
||||
const messageResponses = validMessageResponseEntries.map((entry) => {
|
||||
const {referenced_message: _referencedMessage, ...searchMessage} = entry.message;
|
||||
return searchMessage;
|
||||
const renderableMessages = messages.filter((message) => channelById.has(message.channelId.toString()));
|
||||
const messageResponses = await createMessageResponseDataService().buildMessagesForChannels({
|
||||
userId,
|
||||
messages: renderableMessages,
|
||||
channelById,
|
||||
});
|
||||
const orderedResponseChannelIds = new Set(validMessageResponseEntries.map((entry) => entry.channelId));
|
||||
const orderedChannels = Array.from(orderedResponseChannelIds)
|
||||
const searchMessages = messageResponses.map(
|
||||
({referenced_message: _referencedMessage, ...searchMessage}) => searchMessage,
|
||||
);
|
||||
const respondedChannelIds = new Set(searchMessages.map((message) => message.channel_id));
|
||||
const orderedChannels = orderedChannelIds
|
||||
.filter((channelId) => respondedChannelIds.has(channelId))
|
||||
.map((channelId) => channelById.get(channelId))
|
||||
.filter((channel): channel is Channel => channel !== undefined);
|
||||
const channelResponses = await Promise.all(
|
||||
orderedChannels.map((channel) =>
|
||||
mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
}),
|
||||
),
|
||||
const channelResponses = await mapWithConcurrency(orderedChannels, CHANNEL_LOOKUP_CONCURRENCY, (channel) =>
|
||||
mapChannelToResponse({
|
||||
channel,
|
||||
currentUserId: userId,
|
||||
userCacheService: this.userCacheService,
|
||||
requestCache,
|
||||
}),
|
||||
);
|
||||
return {
|
||||
messages: messageResponses,
|
||||
messages: searchMessages,
|
||||
channels: channelResponses,
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user