Compare commits

...
Author SHA1 Message Date
HampusandGitHub d75a29f099 feat(app): composer send-button toggle and active button hover (#2093) 2026-08-29 20:25:49 +02:00
HampusandGitHub cb889b1160 fix(api): correct apns clear payload and badge handling (#2092) 2026-08-29 20:07:18 +02:00
HampusandGitHub 8db4f5cb63 feat(app): full-Unicode font fallback with self-hosted Noto (#2091) 2026-08-29 19:27:47 +02:00
HampusandGitHub d297dc5805 feat(api): exempt APP_STORE_REVIEWER accounts from captcha (#2090) 2026-08-29 18:00:52 +02:00
HampusandGitHub 9b8659a40b fix(desktop): stop the updater button flickering after download (#2089) 2026-08-29 17:44:43 +02:00
HampusandGitHub 96c5db3f5d fix(markdown): open code blocks for fences after text (#2088) 2026-08-29 17:32:43 +02:00
HampusandGitHub 78e403819e fix(admin): set delete_message_seconds on ban request (#2087) 2026-08-29 16:35:40 +02:00
HampusandGitHub 805acf4e5e feat(ban): accept delete_message_seconds and app options (#2086) 2026-08-29 16:22:12 +02:00
HampusandGitHub 9f099a9127 fix(api): keep the saved placeholder on sent memes (#2085) 2026-08-29 16:04:58 +02:00
HampusandGitHub 4d15c39cd7 fix(app): give mature media blur the real media dimensions (#2084) 2026-08-29 16:04:55 +02:00
HampusandGitHub 827451d12d fix(unfurl): trust curated klipy media without rescanning (#2083) 2026-08-29 16:04:52 +02:00
HampusandGitHub 03603662c6 fix(media-proxy): require corroborating frames for nsfw (#2082) 2026-08-29 16:04:48 +02:00
HampusandGitHub 34eb10cd88 fix(markdown): only open code fences at line start (#2081) 2026-08-29 15:59:04 +02:00
HampusandGitHub 82941c08c9 fix(api): enforce single-role MFA, fix reindex and NCMEC scans (#2080) 2026-08-29 15:31:42 +02:00
HampusandGitHub 8d21c97d08 fix(admin): sort session imports to satisfy rustfmt (#2079) 2026-08-29 15:05:04 +02:00
HampusandGitHub 71a56f590d fix(ci): read dependent load flags at the pe32+ offset (#2078) 2026-08-29 15:00:38 +02:00
HampusandGitHub 38297c4fe7 chore(api): drop knip-unused search and SSO exports (#2077) 2026-08-29 15:00:09 +02:00
HampusandGitHub cf7ec06d85 fix(api): enforce scoped perms, age gates, audit logs (#2076) 2026-08-29 14:55:13 +02:00
HampusandGitHub f2ea10f951 fix(api): harden ratelimit, SSRF, uploads and DM guards (#2074) 2026-08-29 14:55:09 +02:00
HampusandGitHub bbd93df239 fix(api): expire auth tokens and harden login checks (#2073) 2026-08-29 14:55:04 +02:00
HampusandGitHub 9a6ab93e01 fix(media-proxy): bound GIF decode and BMFF box walking (#2075) 2026-08-29 14:55:00 +02:00
HampusandGitHub 38eed7cce6 fix(gateway): restrict /_metrics to loopback callers (#2072) 2026-08-29 14:54:56 +02:00
HampusandGitHub 79064c3399 fix(admin): sign CSRF tokens and escape them in scripts (#2071) 2026-08-29 14:54:51 +02:00
HampusandGitHub 0496b2f530 fix(ci): reject path traversal in S3 prefix downloads (#2070) 2026-08-29 14:54:37 +02:00
HampusandGitHub 9d0be1ebd1 fix(desktop): drop game capture injection and pin dll search (#2069) 2026-08-29 14:26:28 +02:00
HampusandGitHub 9ad026b8ce fix(app): repair theme CSS sync persistence and data loss (#2067) 2026-08-29 03:37:41 +02:00
HampusandGitHub 14de5971d5 chore(api): drop unused ELEVATED_MFA_PERMISSIONS export (#2066) 2026-08-29 03:12:24 +02:00
HampusandGitHub 5474be3efa fix(api): close auth, billing and authorization bypasses (#2065) 2026-08-29 02:59:38 +02:00
fluxer-ci[bot]andGitHub 9a54bbba2d chore(i18n): update public marketing catalogs (#2064) 2026-08-29 01:52:48 +02:00
fluxer-ci[bot]andGitHub 5a0110ccc8 chore(marketing): advance pointer 0c78170 → 5908507 (#2063) 2026-08-29 01:52:44 +02:00
HampusandGitHub d032d577bf fix(app-proxy): drop leaked canary debug cert from assetlinks (#2062) 2026-08-29 01:43:26 +02:00
HampusandGitHub 243954c9c5 test(api): use a future baseline in invoice-skip premium tests (#2061) 2026-08-29 01:22:29 +02:00
HampusandGitHub ba1be73389 fix(media-proxy): cap ISO-BMFF box walker recursion depth (#2059) 2026-08-29 01:04:46 +02:00
HampusandGitHub af3ad02962 fix(voice): default noise suppression to standard 2026-08-28 20:43:22 +02:00
HampusandGitHub 53ddca725e fix(desktop): deduplicate macOS release feeds (#2056) 2026-08-28 19:16:35 +02:00
HampusandGitHub 094fb0d1c8 feat(downloads): route desktop releases through GitHub 2026-08-28 18:19:08 +02:00
HampusandGitHub dc230926a4 fix(desktop): skip glibc check for directory packs 2026-08-28 15:09:00 +02:00
HampusandGitHub 026ace6747 fix(ci): publish draft releases by ID (#2050) 2026-08-28 00:38:09 +02:00
HampusandGitHub 374db9ed2b fix(desktop): harden capture and release packaging (#2049) 2026-08-27 23:54:38 +02:00
5ee59c4675 chore(i18n): update public marketing catalogs (#2047)
Co-authored-by: Jiralite <[email protected]>
2026-08-27 17:49:52 +01:00
33605171a8 chore(marketing): advance pointer 530c44e → 0c78170 (#2046)
Co-authored-by: Jiralite <[email protected]>
2026-08-27 17:49:38 +01:00
HampusandGitHub 89fac5b088 fix(api): use webhook ID for mention author (#2045) 2026-08-27 17:01:26 +02:00
HampusandGitHub 4a34b942b7 fix(api): key mention chunks by content (#2044) 2026-08-27 16:02:24 +02:00
HampusandGitHub fc3065ebe4 fix(desktop): build with compatible PipeWire headers (#2043) 2026-08-27 15:18:15 +02:00
HampusandGitHub 23493b4ac2 fix(desktop): build libfido2 on Linux runners (#2042) 2026-08-27 14:44:44 +02:00
HampusandGitHub 13344096b7 fix(desktop): keep Linux builds compatible with glibc 2.35 (#2041) 2026-08-27 13:41:08 +02:00
HampusandGitHub a800430997 fix(app): sort interface languages by locale code (#2040) 2026-08-27 13:30:39 +02:00
HampusandGitHub 509562e6da feat(app): delete attachments from the media viewer (#2039) 2026-08-27 13:26:18 +02:00
HampusandGitHub 88d85919f1 fix(app): trim pasted friend tags (#2038) 2026-08-27 13:12:12 +02:00
HampusandGitHub 154e223284 fix(app): keep sticker sizes fixed while resizing the expression picker 2026-08-27 12:59:38 +02:00
HampusandGitHub 58732f7770 fix(api): configure email app base URL separately 2026-08-27 03:26:11 +02:00
HampusandGitHub cb4c847d41 fix(app): separate unread state from unread counts 2026-08-27 02:35:56 +02:00
HampusandGitHub d3976e33f8 fix(app): keep unread channel opens anchored to the divider 2026-08-27 02:02:45 +02:00
HampusandGitHub 8e17970632 fix(app): submit message edits in background 2026-08-26 23:54:28 +02:00
HampusandGitHub c0048504db fix(gifs): bound shard cache memory 2026-08-26 23:22:46 +02:00
HampusandGitHub 5015452280 fix(search): respect scope in channel suggestions 2026-08-26 23:06:07 +02:00
HampusandGitHub c504b68354 fix(api): store administrator user archives separately (#2025) 2026-08-26 21:40:57 +02:00
HampusandGitHub 5d2e5932a4 fix(workspace): stabilise the development stack (#2024) 2026-08-26 18:52:53 +02:00
HampusandGitHub cf1a7d7a8a fix(api): mask Tor blocks as administrator IP bans (#2023) 2026-08-26 16:07:19 +02:00
392 changed files with 9830 additions and 17776 deletions
+10 -32
View File
@@ -7,7 +7,6 @@ ARG USER_UID=1000
ARG USER_GID=1000
ARG NODE_MAJOR=24
ARG ELP_VERSION=2026-02-27
ARG HELM_VERSION=4.2.0
ARG PNPM_VERSION=10.29.3
ARG WASM_BINDGEN_VERSION=0.2.122
@@ -15,8 +14,8 @@ ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
acl \
bash \
brotli \
build-essential \
ca-certificates \
clang \
@@ -42,13 +41,13 @@ RUN apt-get update \
libfido2-dev \
libgbm1 \
libgtk-3-0 \
libimage-exiftool-perl \
libnotify4 \
libnss3 \
libpipewire-0.3-dev \
libpulse-dev \
libsecret-1-0 \
libudev-dev \
libuv1-dev \
libcurl4-openssl-dev \
libswresample-dev \
libswscale-dev \
@@ -71,14 +70,12 @@ RUN apt-get update \
ninja-build \
openssh-client \
pkg-config \
protobuf-compiler \
python3 \
python3-pip \
rsync \
python3-venv \
sudo \
rpm \
unzip \
webp \
xz-utils \
xdg-utils \
zstd \
@@ -90,6 +87,7 @@ RUN apt-get update \
bat \
btop \
docker-cli \
docker-compose \
dnsutils \
fd-find \
gdb \
@@ -122,24 +120,12 @@ RUN apt-get update \
&& ln -sf /usr/bin/batcat /usr/local/bin/bat \
&& rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable
RUN ARCH="$(dpkg --print-architecture)" \
&& case "$ARCH" in \
amd64) HELM_ARCH="amd64" ;; \
arm64) HELM_ARCH="arm64" ;; \
*) echo "Unsupported architecture for Helm: $ARCH" >&2; exit 1 ;; \
esac \
&& curl -fsSL "https://get.helm.sh/helm-v${HELM_VERSION}-linux-${HELM_ARCH}.tar.gz" -o /tmp/helm.tgz \
&& tar -C /tmp -xzf /tmp/helm.tgz "linux-${HELM_ARCH}/helm" \
&& mv "/tmp/linux-${HELM_ARCH}/helm" /usr/local/bin/helm \
&& chmod +x /usr/local/bin/helm \
&& rm -rf /tmp/helm.tgz "/tmp/linux-${HELM_ARCH}"
RUN python3 -m pip install --break-system-packages --no-cache-dir awscli cqlsh
RUN python3 -m pip install --break-system-packages --no-cache-dir awscli
COPY tools/fonts/requirements.txt /tmp/fluxer-fonts-requirements.txt
RUN python3 -m pip install --break-system-packages --no-cache-dir -r /tmp/fluxer-fonts-requirements.txt \
@@ -147,18 +133,13 @@ RUN python3 -m pip install --break-system-packages --no-cache-dir -r /tmp/fluxer
&& pyftsubset --help >/dev/null \
&& python3 -c "import fontTools, brotli"
RUN if ! command -v rebar3 >/dev/null 2>&1; then \
curl -fsSL https://s3.amazonaws.com/rebar3/rebar3 -o /usr/local/bin/rebar3 \
&& chmod +x /usr/local/bin/rebar3; \
fi
RUN ARCH="$(dpkg --print-architecture)" \
&& case "$ARCH" in \
amd64) ELP_ARCH="x86_64" ;; \
arm64) ELP_ARCH="aarch64" ;; \
*) echo "Unsupported architecture for ELP: $ARCH" >&2; exit 1 ;; \
esac \
&& curl -fsSL "https://github.com/WhatsApp/erlang-language-platform/releases/download/${ELP_VERSION}/elp-linux-${ELP_ARCH}-unknown-linux-gnu-otp-28.tar.gz" -o /tmp/elp.tgz \
&& curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL "https://github.com/WhatsApp/erlang-language-platform/releases/download/${ELP_VERSION}/elp-linux-${ELP_ARCH}-unknown-linux-gnu-otp-28.tar.gz" -o /tmp/elp.tgz \
&& tar -C /usr/local/bin -xzf /tmp/elp.tgz elp \
&& chmod +x /usr/local/bin/elp \
&& rm /tmp/elp.tgz
@@ -179,16 +160,13 @@ ENV DOCKER_HOST="unix:///var/run/docker.sock" \
ENV CC_wasm32_unknown_unknown="clang" \
AR_wasm32_unknown_unknown="llvm-ar"
RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile default --component clippy,rustfmt \
RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --component clippy,rustfmt \
&& rustup target add wasm32-unknown-unknown \
&& cargo install cargo-watch --locked \
&& cargo install wasm-bindgen-cli --version "${WASM_BINDGEN_VERSION}" --locked
&& cargo install wasm-bindgen-cli --version "${WASM_BINDGEN_VERSION}" --locked \
&& rm -rf "/home/${USERNAME}/.cargo/registry" "/home/${USERNAME}/.cargo/git"
RUN corepack prepare "pnpm@${PNPM_VERSION}" --activate \
&& pnpm --version
RUN sudo apt-get update \
&& sudo apt-get install -y --no-install-recommends python3-venv \
&& sudo rm -rf /var/lib/apt/lists/*
WORKDIR /workspaces/fluxer
+11 -35
View File
@@ -5,20 +5,17 @@
"workspaceFolder": "/workspaces/fluxer",
"shutdownAction": "stopCompose",
"remoteUser": "vscode",
"hostRequirements": {
"cpus": 4,
"memory": "8gb",
"storage": "32gb"
},
"remoteEnv": {
"DOCKER_HOST": "unix:///var/run/docker.sock"
},
"runServices": ["workspace", "postgres", "valkey", "nats", "livekit", "meilisearch", "mailpit"],
"forwardPorts": [
3000, 8088, 8080, 8771, 8082, 3010, 3020, 8100, 8101, 8102, 8103, 8104, 8105, 8106, 8107, 8108, 8109, 8110, 8111,
8112, 8113, 8114, 8115, 8116, 8117, 8118, 8119, 8120, 8121, 8122, 8123, 8124, 8125, 3900, 8888, 9333, 9340, 23646,
4222, 7700, 7880, 7900, 9200, 8000
],
"forwardPorts": [3000, 8088, 8080, 8771, 8082, 8773, 3010, 3020, 8333],
"portsAttributes": {
"8000": {
"label": "Zensical docs",
"onAutoForward": "openBrowserOnce"
},
"8088": {
"label": "Fluxer dev proxy",
"onAutoForward": "notify"
@@ -29,36 +26,15 @@
"3020": {
"label": "Fluxer admin"
},
"8100": {
"label": "Fluxer Rust service health"
},
"3900": {
"8333": {
"label": "SeaweedFS S3"
},
"8888": {
"label": "SeaweedFS filer"
},
"9333": {
"label": "SeaweedFS master"
},
"9340": {
"label": "SeaweedFS volume"
},
"23646": {
"label": "SeaweedFS admin"
},
"7880": {
"label": "LiveKit"
},
"7700": {
"label": "Meilisearch"
},
"9200": {
"label": "Elasticsearch"
"8773": {
"label": "Fluxer app proxy"
}
},
"postCreateCommand": "sudo chown -R vscode:vscode /workspaces/fluxer/target && find /workspaces/fluxer -maxdepth 4 -type d -name node_modules -prune -exec sudo chown -R vscode:vscode {} + && sudo chown -R vscode:vscode /home/vscode/.local/share/pnpm && cargo run -p fluxer-dev -- bootstrap",
"postStartCommand": "bash /workspaces/fluxer/.devcontainer/fix-docker-socket.sh && cargo run -p fluxer-dev -- post-start && bash /workspaces/fluxer/fluxer_docs/serve.sh --daemon",
"postCreateCommand": "bash /workspaces/fluxer/.devcontainer/fix-docker-socket.sh && bash /workspaces/fluxer/.devcontainer/fix-workspace-permissions.sh && cargo run -p fluxer-dev -- bootstrap",
"postStartCommand": "bash /workspaces/fluxer/.devcontainer/fix-docker-socket.sh && bash /workspaces/fluxer/.devcontainer/fix-workspace-permissions.sh && cargo run -p fluxer-dev -- post-start",
"customizations": {
"vscode": {
"settings": {
+68 -69
View File
@@ -1,5 +1,3 @@
name: fluxer-dev
services:
workspace:
build:
@@ -7,15 +5,29 @@ services:
dockerfile: .devcontainer/Dockerfile
command: sleep infinity
init: true
env_file:
- ../config/env/development.env
environment:
FLUXER_SEARCH_ENGINE: meilisearch
FLUXER_SEARCH_URL: http://meilisearch:7700
FLUXER_SEARCH_API_KEY: fluxer-dev-meilisearch
FLUXER_POSTGRES_HOST: postgres
FLUXER_SELF_HOSTED: "true"
DOCKER_HOST: unix:///var/run/docker.sock
npm_config_store_dir: /home/vscode/.local/share/pnpm/store
FLUXER_PUBLIC_PORT: "${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_PUBLIC_URL: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_API_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
FLUXER_API_CLIENT_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
FLUXER_APP_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_GATEWAY_ENDPOINT: "ws://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/gateway"
FLUXER_MEDIA_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
FLUXER_STATIC_CDN_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_ADMIN_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/admin"
FLUXER_MEDIA_PROXY_PUBLIC_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
FLUXER_S3_PUBLIC_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_LIVEKIT_URL: "ws://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/livekit"
FLUXER_LIVEKIT_INTERNAL_URL: "http://livekit:7880"
FLUXER_LIVEKIT_WEBHOOK_URL: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api/webhooks/livekit"
FLUXER_MEDIA_PROXY_UPLOAD_RELAY_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
FLUXER_GATEWAY_MEDIA_PROXY_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/media"
FLUXER_GATEWAY_STATIC_CDN_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
FLUXER_ADMIN_OAUTH_REDIRECT_URI: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/admin/oauth2_callback"
FLUXER_PASSKEY_ADDITIONAL_ALLOWED_ORIGINS: "http://localhost,http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}"
PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT: "http://localhost:${FLUXER_DEV_PROXY_PORT:-8088}/api"
volumes:
- ..:/workspaces/fluxer:cached
- type: volume
@@ -229,6 +241,7 @@ services:
volume:
nocopy: true
- pnpm-store:/home/vscode/.local/share/pnpm/store
- docs-venv:/workspaces/fluxer/fluxer_docs/.venv
- cargo-registry:/home/vscode/.cargo/registry
- cargo-git:/home/vscode/.cargo/git
- rust-target:/workspaces/fluxer/target
@@ -236,45 +249,31 @@ services:
source: ${FLUXER_DOCKER_SOCKET:-/var/run/docker.sock}
target: /var/run/docker.sock
ports:
- "${FLUXER_DEV_DOCS_PORT:-8000}:8000"
- "${FLUXER_DEV_RSPACK_PORT:-3000}:3000"
- "${FLUXER_DEV_PROXY_PORT:-8088}:8088"
- "${FLUXER_DEV_APP_PROXY_PORT:-8080}:8080"
- "${FLUXER_DEV_API_PORT:-8771}:8771"
- "${FLUXER_DEV_GATEWAY_PORT:-8082}:8082"
- "${FLUXER_DEV_MARKETING_PORT:-3010}:3010"
- "${FLUXER_DEV_ADMIN_PORT:-3020}:3020"
- "${FLUXER_DEV_RUST_SERVICE_PORTS:-8100-8125}:8100-8125"
- "${FLUXER_DEV_SEAWEEDFS_S3_PORT:-3900}:8333"
- "${FLUXER_DEV_SEAWEEDFS_FILER_PORT:-8888}:8888"
- "${FLUXER_DEV_SEAWEEDFS_MASTER_PORT:-9333}:9333"
- "${FLUXER_DEV_SEAWEEDFS_VOLUME_PORT:-9340}:9340"
- "${FLUXER_DEV_SEAWEEDFS_ADMIN_PORT:-23646}:23646"
- "127.0.0.1:${FLUXER_DEV_RSPACK_PORT:-3000}:3000"
- "127.0.0.1:${FLUXER_DEV_PROXY_PORT:-8088}:8088"
- "127.0.0.1:${FLUXER_DEV_API_PORT:-8080}:8080"
- "127.0.0.1:${FLUXER_DEV_GATEWAY_PORT:-8771}:8771"
- "127.0.0.1:${FLUXER_DEV_MEDIA_PROXY_PORT:-8082}:8082"
- "127.0.0.1:${FLUXER_DEV_APP_PROXY_PORT:-8773}:8773"
- "127.0.0.1:${FLUXER_DEV_MARKETING_PORT:-3010}:3010"
- "127.0.0.1:${FLUXER_DEV_ADMIN_PORT:-3020}:3020"
- "127.0.0.1:${FLUXER_DEV_SEAWEEDFS_S3_PORT:-3900}:8333"
depends_on:
- postgres
- valkey
- nats
- livekit
- meilisearch
- mailpit
postgres:
condition: service_healthy
valkey:
condition: service_started
nats:
condition: service_started
livekit:
condition: service_started
meilisearch:
condition: service_healthy
mailpit:
condition: service_started
extra_hosts:
- "host.docker.internal:host-gateway"
cassandra:
image: cassandra:5.0.8
profiles:
- full
environment:
CASSANDRA_CLUSTER_NAME: fluxer-dev
CASSANDRA_DC: datacenter1
CASSANDRA_ENDPOINT_SNITCH: GossipingPropertyFileSnitch
HEAP_NEWSIZE: 128M
MAX_HEAP_SIZE: 768M
volumes:
- cassandra-data:/var/lib/cassandra
ports:
- "${FLUXER_DEV_CASSANDRA_PORT:-9042}:9042"
postgres:
image: postgres:16-alpine
environment:
@@ -284,13 +283,19 @@ services:
volumes:
- postgres-data:/var/lib/postgresql/data
ports:
- "${FLUXER_DEV_POSTGRES_PORT:-5432}:5432"
- "127.0.0.1:${FLUXER_DEV_POSTGRES_PORT:-5432}:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fluxer -d fluxer"]
interval: 2s
timeout: 5s
retries: 30
start_period: 5s
valkey:
image: valkey/valkey:8.1.7-alpine
command: ["valkey-server", "--save", "", "--appendonly", "no"]
ports:
- "${FLUXER_DEV_VALKEY_PORT:-6379}:6379"
- "127.0.0.1:${FLUXER_DEV_VALKEY_PORT:-6379}:6379"
nats:
image: nats:2.14.2-alpine
@@ -298,33 +303,22 @@ services:
volumes:
- nats-data:/data
ports:
- "${FLUXER_DEV_NATS_PORT:-4222}:4222"
- "${FLUXER_DEV_NATS_MONITOR_PORT:-8222}:8222"
- "127.0.0.1:${FLUXER_DEV_NATS_PORT:-4222}:4222"
- "127.0.0.1:${FLUXER_DEV_NATS_MONITOR_PORT:-8222}:8222"
livekit:
image: livekit/livekit-server:v1.12.0
command: ["--config", "/etc/livekit.yaml", "--bind", "0.0.0.0"]
environment:
LIVEKIT_RTC_TCP_PORT: "${FLUXER_DEV_LIVEKIT_TCP_PORT:-7881}"
LIVEKIT_RTC_UDP_PORT_START: "${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}"
LIVEKIT_RTC_UDP_PORT_END: "${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}"
volumes:
- ./livekit.yaml:/etc/livekit.yaml:ro
ports:
- "${FLUXER_DEV_LIVEKIT_PORT:-7880}:7880"
- "${FLUXER_DEV_LIVEKIT_TCP_PORT:-7881}:7881"
- "${FLUXER_DEV_LIVEKIT_UDP_PORTS:-7882-7892}:7882-7892/udp"
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:9.3.2
profiles:
- full
environment:
discovery.type: single-node
xpack.security.enabled: "true"
xpack.security.http.ssl.enabled: "false"
ELASTIC_PASSWORD: fluxer-dev-elasticsearch
ES_JAVA_OPTS: "-Xms512m -Xmx512m"
volumes:
- elasticsearch-data:/usr/share/elasticsearch/data
ports:
- "${FLUXER_DEV_ELASTICSEARCH_PORT:-9200}:9200"
- "127.0.0.1:${FLUXER_DEV_LIVEKIT_PORT:-7880}:7880"
- "127.0.0.1:${FLUXER_DEV_LIVEKIT_TCP_PORT:-7881}:${FLUXER_DEV_LIVEKIT_TCP_PORT:-7881}"
- "127.0.0.1:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}:${FLUXER_DEV_LIVEKIT_UDP_PORT:-7882}/udp"
meilisearch:
image: getmeili/meilisearch:v1.12
@@ -334,7 +328,13 @@ services:
volumes:
- meilisearch-data:/meili_data
ports:
- "${FLUXER_DEV_MEILISEARCH_PORT:-7700}:7700"
- "127.0.0.1:${FLUXER_DEV_MEILISEARCH_PORT:-7700}:7700"
healthcheck:
test: ["CMD", "curl", "--fail", "--silent", "http://127.0.0.1:7700/health"]
interval: 2s
timeout: 5s
retries: 30
start_period: 5s
mailpit:
image: axllent/mailpit:v1.30
@@ -349,6 +349,7 @@ services:
volumes:
pnpm-store:
docs-venv:
root-node-modules:
fluxer-api-node-modules:
fluxer-app-node-modules:
@@ -394,9 +395,7 @@ volumes:
cargo-registry:
cargo-git:
rust-target:
cassandra-data:
nats-data:
elasticsearch-data:
meilisearch-data:
mailpit-data:
postgres-data:
+6 -26
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
set -uo pipefail
set -euo pipefail
SOCKET="${DOCKER_SOCKET:-/var/run/docker.sock}"
USER_NAME="${USER:-vscode}"
USER_NAME="$(id -un)"
if [ ! -S "$SOCKET" ]; then
echo "fix-docker-socket: no socket at $SOCKET; skipping (Docker-in-devcontainer will not work)"
@@ -16,31 +16,11 @@ if docker version --format '{{.Server.Version}}' >/dev/null 2>&1; then
exit 0
fi
socket_gid="$(stat -c '%g' "$SOCKET" 2>/dev/null || echo "")"
if [ -z "$socket_gid" ]; then
echo "fix-docker-socket: could not stat $SOCKET; skipping" >&2
exit 0
fi
sudo sh -c '
set -e
gid="$1"
user="$2"
socket="$3"
if ! getent group "$gid" >/dev/null 2>&1; then
groupadd --gid "$gid" docker-host
fi
group_name="$(getent group "$gid" | cut -d: -f1)"
usermod --append --groups "$group_name" "$user"
chgrp "$gid" "$socket"
chmod g+rw "$socket"
' sh "$socket_gid" "$USER_NAME" "$SOCKET" || {
echo "fix-docker-socket: could not adjust $SOCKET; run docker with sudo" >&2
exit 0
}
sudo setfacl --modify "user:${USER_NAME}:rw" "$SOCKET"
if docker version --format '{{.Server.Version}}' >/dev/null 2>&1; then
echo "fix-docker-socket: $SOCKET is now usable as $USER_NAME (gid $socket_gid)"
echo "fix-docker-socket: $SOCKET is now usable as $USER_NAME"
else
echo "fix-docker-socket: $SOCKET still unreachable as $USER_NAME; run docker with sudo" >&2
echo "fix-docker-socket: $SOCKET is still unreachable as $USER_NAME" >&2
exit 1
fi
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
set -euo pipefail
owner="$(id -u):$(id -g)"
repair_tree() {
local path="$1"
local unwritable
if [ ! -d "$path" ]; then
echo "fix-workspace-permissions: expected mount is missing: $path" >&2
exit 1
fi
unwritable="$(find "$path" -xdev \( -type d -o -type f \) ! -writable -print -quit 2>/dev/null || true)"
if [ ! -w "$path" ] || [ -n "$unwritable" ]; then
sudo find "$path" -xdev \( -type d -o -type f \) -exec chown "$owner" {} +
fi
unwritable="$(find "$path" -xdev \( -type d -o -type f \) ! -writable -print -quit 2>/dev/null || true)"
if [ ! -w "$path" ] || [ -n "$unwritable" ]; then
echo "fix-workspace-permissions: $path is not writable as $(id -un)" >&2
exit 1
fi
}
for path in \
/workspaces/fluxer/target \
/home/vscode/.cargo/registry \
/home/vscode/.cargo/git \
/home/vscode/.local \
/home/vscode/.local/share/pnpm/store \
/workspaces/fluxer/fluxer_docs/.venv; do
repair_tree "$path"
done
while IFS= read -r -d '' path; do
if mountpoint -q "$path"; then
repair_tree "$path"
fi
done < <(find /workspaces/fluxer -maxdepth 4 -type d -name node_modules -prune -print0)
+1 -2
View File
@@ -1,11 +1,10 @@
port: 7880
keys:
devkey: secret
devkey: fluxer-livekit-development-secret
rtc:
tcp_port: 7881
udp_port: 7882-7892
node_ip: 127.0.0.1
use_mdns: true
stun_servers:
+42 -1
View File
@@ -524,6 +524,7 @@ jobs:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
S3_DESKTOP_PREFIX: ${{ needs.meta.outputs.s3_prefix }}
DESKTOP_HANDOFF_PREFIX: _handoff/desktop/${{ needs.meta.outputs.build_channel }}/${{ needs.meta.outputs.version }}/${{ needs.meta.outputs.source_sha }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ 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
@@ -553,11 +554,29 @@ jobs:
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step build_payload
- name: Prepare GitHub release assets
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step prepare_release_assets
- name: Publish GitHub release descriptor
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_descriptor
- name: Upload payload to S3
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step upload_payload
- name: Upload GitHub release asset handoff
if: needs.meta.outputs.test_build != 'true'
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step upload_release_assets
- name: Build summary
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
@@ -577,9 +596,17 @@ jobs:
- upload
runs-on: ubuntu-24.04-arm
environment: desktop-releases
timeout-minutes: 10
timeout-minutes: 60
permissions:
contents: write
env:
CHANNEL: ${{ needs.meta.outputs.build_channel }}
VERSION: ${{ needs.meta.outputs.version }}
DESKTOP_RELEASE_ASSETS_PREFIX: _handoff/desktop-release-assets/${{ 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 }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DOWNLOADS_AWS_SECRET_ACCESS_KEY || secrets.AWS_SECRET_ACCESS_KEY }}
steps:
- name: Checkout source
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
@@ -590,6 +617,12 @@ jobs:
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9
with:
toolchain: "1.93.0"
- name: Download GitHub release assets
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step download_release_assets
- name: Create token
id: create-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
@@ -614,8 +647,16 @@ jobs:
--build-version "${VERSION}"
--source-sha "${SOURCE_SHA}"
--previous-sha "${RELEASE_BASELINE_SHA}"
--asset-dir release_assets
)
if [[ "${CHANNEL}" == "canary" ]]; then
release_args+=(--prerelease)
fi
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- "${release_args[@]}"
- name: Publish GitHub release readiness marker
env:
SOURCE_SHA: ${{ needs.meta.outputs.source_sha }}
run: >-
cargo run --locked --quiet --manifest-path tools/ci/Cargo.toml -- build-desktop
--step publish_release_marker
+2 -7
View File
@@ -30,12 +30,6 @@ FLUXER_POSTGRES_PASSWORD=fluxer
FLUXER_POSTGRES_SSL=false
FLUXER_POSTGRES_MAX_CONNECTIONS=20
FLUXER_POSTGRES_KV_TABLE=fluxer_kv
FLUXER_CASSANDRA_HOSTS=cassandra
FLUXER_CASSANDRA_PORT=9042
FLUXER_CASSANDRA_KEYSPACE=fluxer
FLUXER_CASSANDRA_LOCAL_DC=datacenter1
FLUXER_CASSANDRA_USERNAME=fluxer
FLUXER_CASSANDRA_PASSWORD=fluxer
FLUXER_KV_URL=redis://valkey:6379/0
FLUXER_NATS_URL=nats://nats:4222
FLUXER_NATS_JETSTREAM_URL=nats://nats:4222
@@ -66,8 +60,9 @@ FLUXER_S3_BUCKET_STATIC=fluxer-static
FLUXER_LIVEKIT_ENABLED=true
FLUXER_LIVEKIT_URL=ws://localhost:8088/livekit
FLUXER_LIVEKIT_INTERNAL_URL=http://localhost:7880
FLUXER_LIVEKIT_API_KEY=devkey
FLUXER_LIVEKIT_API_SECRET=secret
FLUXER_LIVEKIT_API_SECRET=fluxer-livekit-development-secret
FLUXER_LIVEKIT_WEBHOOK_URL=http://localhost:8088/api/webhooks/livekit
FLUXER_LIVEKIT_DEFAULT_REGION={"id":"local","name":"Local","emoji":"LC","latitude":59.3293,"longitude":18.0686}
+1
View File
@@ -55,6 +55,7 @@ FLUXER_EMAIL_ENABLED=false
FLUXER_EMAIL_PROVIDER=none
FLUXER_EMAIL_FROM_EMAIL=[email protected]
FLUXER_EMAIL_FROM_NAME=Fluxer
FLUXER_EMAIL_APP_BASE_URL=
FLUXER_EMAIL_SMTP_HOST=
FLUXER_EMAIL_SMTP_PORT=587
FLUXER_EMAIL_SMTP_USERNAME=
+2
View File
@@ -47,6 +47,7 @@ x-fluxer-env: &fluxer-env
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_WEBHOOK_URL: http://api:8080/webhooks/livekit
FLUXER_LIVEKIT_DEFAULT_REGION: '{"id":"default","name":"Default","emoji":"🌍","latitude":0,"longitude":0}'
@@ -56,6 +57,7 @@ x-fluxer-env: &fluxer-env
FLUXER_EMAIL_PROVIDER: ${FLUXER_EMAIL_PROVIDER:-none}
FLUXER_EMAIL_FROM_EMAIL: ${FLUXER_EMAIL_FROM_EMAIL:-noreply@localhost}
FLUXER_EMAIL_FROM_NAME: ${FLUXER_EMAIL_FROM_NAME:-Fluxer}
FLUXER_EMAIL_APP_BASE_URL: ${FLUXER_EMAIL_APP_BASE_URL:-}
FLUXER_EMAIL_SMTP_HOST: ${FLUXER_EMAIL_SMTP_HOST:-}
FLUXER_EMAIL_SMTP_PORT: ${FLUXER_EMAIL_SMTP_PORT:-587}
FLUXER_EMAIL_SMTP_USERNAME: ${FLUXER_EMAIL_SMTP_USERNAME:-}
+8 -3
View File
@@ -9896,8 +9896,6 @@
"GLOBAL_IP_BANNED",
"GLOBAL_IP_TEMPORARILY_BANNED",
"IP_BANNED",
"RESIDENTIAL_PROXY_BLOCKED",
"TOR_BLOCKED",
"MAX_ANIMATED_EMOJIS",
"MAX_APPLICATIONS",
"MAX_BOOKMARKS",
@@ -11856,7 +11854,14 @@
"minimum": 0,
"maximum": 7,
"format": "int32",
"description": "Number of days of messages to delete from the banned user (0-7)"
"description": "Number of days of messages to delete from the banned user (0-7). Deprecated in favor of delete_message_seconds."
},
"delete_message_seconds": {
"type": "integer",
"minimum": 0,
"maximum": 604800,
"format": "int32",
"description": "Number of seconds of messages to delete for the banned user (0-604800, default 0)"
},
"reason": {"description": "The reason for the ban (max 512 characters)", "nullable": true, "type": "string"},
"ban_duration_seconds": {
+1
View File
@@ -136,6 +136,7 @@ impl AdminApiClient {
let body = generated_types::BanGuildMemberRequest {
ban_duration_seconds: None,
delete_message_days: None,
delete_message_seconds: None,
guild_id: snowflake(guild_id),
reason: None,
user_id: snowflake(user_id),
+59 -61
View File
@@ -2,24 +2,43 @@
use axum::{
body::{Body, to_bytes},
extract::Request,
extract::{Request, State},
http::{HeaderValue, Method, StatusCode, header},
middleware::Next,
response::{IntoResponse, Response},
};
use rand::RngExt;
use crate::middleware::auth::AuthContext;
use crate::session::{create_csrf_token, verify_csrf_token};
use crate::state::AppState;
const CSRF_COOKIE_NAME: &str = "csrf_token";
pub const CSRF_FORM_FIELD: &str = "_csrf";
const CSRF_HEADER_NAME: &str = "x-csrf-token";
const TOKEN_LENGTH: usize = 32;
const HOST_CSRF_COOKIE_NAME: &str = "__Host-csrf_token";
const MAX_CSRF_FORM_BYTES: usize = 8 * 1024 * 1024;
const IGNORED_PATH_SUFFIXES: &[&str] = &["/oauth2_callback", "/auth/start"];
pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
let existing_token = extract_csrf_cookie(&request);
let token = existing_token.unwrap_or_else(generate_csrf_token);
pub async fn csrf_protection(
State(state): State<AppState>,
mut request: Request,
next: Next,
) -> Response {
let config = state.config();
let secret = config.secret_key_base.clone();
let admin_endpoint = config.admin_endpoint.clone();
let is_production = config.is_production();
let user_id = request
.extensions()
.get::<AuthContext>()
.map(|ctx| ctx.session.user_id.clone())
.unwrap_or_default();
let token = extract_csrf_cookie(&request)
.filter(|cookie| verify_csrf_token(cookie, &user_id, &secret))
.unwrap_or_else(|| create_csrf_token(&user_id, &secret));
request.extensions_mut().insert(CsrfToken(token.clone()));
if matches!(
@@ -31,6 +50,9 @@ pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
.iter()
.any(|suffix| path.ends_with(suffix));
if !is_ignored {
if !is_same_site_request(&request, &admin_endpoint) {
return StatusCode::FORBIDDEN.into_response();
}
let header_token = extract_csrf_header(&request);
let query_token = extract_csrf_from_query(&request);
let mut submitted = query_token.or(header_token);
@@ -43,21 +65,24 @@ pub async fn csrf_protection(mut request: Request, next: Next) -> Response {
request = restored_request;
submitted = body_token;
}
match submitted {
Some(ref submitted_token) if submitted_token == &token => {}
_ => {
return StatusCode::FORBIDDEN.into_response();
}
let accepted = submitted.as_deref().is_some_and(|submitted_token| {
submitted_token == token && verify_csrf_token(submitted_token, &user_id, &secret)
});
if !accepted {
return StatusCode::FORBIDDEN.into_response();
}
}
}
let mut response = next.run(request).await;
let cookie_value = format!(
"{}={}; Path=/; SameSite=Lax; HttpOnly",
CSRF_COOKIE_NAME, token
);
let cookie_name = if is_production {
HOST_CSRF_COOKIE_NAME
} else {
CSRF_COOKIE_NAME
};
let secure = if is_production { "; Secure" } else { "" };
let cookie_value = format!("{cookie_name}={token}; Path=/; SameSite=Lax; HttpOnly{secure}");
if let Ok(value) = HeaderValue::from_str(&cookie_value) {
response.headers_mut().append(header::SET_COOKIE, value);
}
@@ -69,7 +94,10 @@ fn extract_csrf_cookie(request: &Request) -> Option<String> {
let cookie_header = request.headers().get(header::COOKIE)?.to_str().ok()?;
for pair in cookie_header.split(';') {
let pair = pair.trim();
if let Some(value) = pair.strip_prefix("csrf_token=") {
let value = pair
.strip_prefix("__Host-csrf_token=")
.or_else(|| pair.strip_prefix("csrf_token="));
if let Some(value) = value {
let trimmed = value.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_owned());
@@ -127,18 +155,22 @@ async fn extract_csrf_from_form_body(
Ok((request, token))
}
fn generate_csrf_token() -> String {
let mut rng = rand::rng();
let bytes: [u8; TOKEN_LENGTH] = rng.random();
hex_encode(&bytes)
}
fn hex_encode(bytes: &[u8]) -> String {
let mut s = String::with_capacity(bytes.len() * 2);
for byte in bytes {
s.push_str(&format!("{byte:02x}"));
fn is_same_site_request(request: &Request, admin_endpoint: &str) -> bool {
if let Some(site) = request
.headers()
.get("sec-fetch-site")
.and_then(|value| value.to_str().ok())
{
return matches!(site, "same-origin" | "same-site" | "none");
}
match request
.headers()
.get(header::ORIGIN)
.and_then(|value| value.to_str().ok())
{
Some(origin) => origin == admin_endpoint,
None => true,
}
s
}
#[derive(Clone, Debug)]
@@ -156,40 +188,6 @@ pub fn get_csrf_token(request: &Request) -> String {
mod tests {
use super::*;
#[test]
fn generate_csrf_token_correct_length() {
let token = generate_csrf_token();
assert_eq!(
token.len(),
TOKEN_LENGTH * 2,
"token must be {} hex chars",
TOKEN_LENGTH * 2
);
}
#[test]
fn generate_csrf_token_is_valid_hex() {
let token = generate_csrf_token();
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"token must contain only hex chars: {token}"
);
}
#[test]
fn generate_csrf_token_is_unique() {
let a = generate_csrf_token();
let b = generate_csrf_token();
assert_ne!(a, b, "consecutive tokens must differ");
}
#[test]
fn hex_encode_produces_correct_output() {
assert_eq!(hex_encode(&[0x00, 0xff, 0x0a]), "00ff0a");
assert_eq!(hex_encode(&[]), "");
assert_eq!(hex_encode(&[0xde, 0xad]), "dead");
}
#[test]
fn oauth2_callback_is_exempt() {
let exempt = IGNORED_PATH_SUFFIXES
+1 -1
View File
@@ -348,7 +348,7 @@ fn oauth_callback_page(config: &AdminConfig, code: Option<&str>, state: Option<&
)
}
fn json_string(value: &str) -> String {
pub(crate) fn json_string(value: &str) -> String {
serde_json::to_string(value)
.expect("JSON string serialization cannot fail")
.replace('<', "\\u003c")
+4 -1
View File
@@ -73,7 +73,10 @@ pub fn build_router(config: AdminConfig) -> Router {
.route("/", get(dashboard))
.route("/dashboard", get(dashboard))
.layer(from_fn(middleware::htmx::flash_redirect_to_toast))
.layer(from_fn(middleware::csrf::csrf_protection))
.layer(from_fn_with_state(
state.clone(),
middleware::csrf::csrf_protection,
))
.layer(from_fn(middleware::self_hosted::self_hosted_override))
.layer(from_fn_with_state(
state.clone(),
+31
View File
@@ -2,6 +2,7 @@
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use hmac::{Hmac, KeyInit, Mac};
use rand::RngExt;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
@@ -78,6 +79,36 @@ fn verify_signature<'a>(signed_data: &'a str, secret_key: &str) -> Option<&'a st
if diff == 0 { Some(data) } else { None }
}
pub fn create_csrf_token(user_id: &str, secret_key: &str) -> String {
let mut rng = rand::rng();
let nonce: [u8; 16] = rng.random();
let payload = URL_SAFE_NO_PAD.encode(format!(
"{}:{}",
URL_SAFE_NO_PAD.encode(user_id.as_bytes()),
URL_SAFE_NO_PAD.encode(nonce)
));
sign_data(&payload, secret_key)
}
pub fn verify_csrf_token(token: &str, user_id: &str, secret_key: &str) -> bool {
let Some(data) = verify_signature(token, secret_key) else {
return false;
};
let Ok(decoded) = URL_SAFE_NO_PAD.decode(data.as_bytes()) else {
return false;
};
let Ok(payload) = String::from_utf8(decoded) else {
return false;
};
let Some((encoded_uid, _nonce)) = payload.split_once(':') else {
return false;
};
match URL_SAFE_NO_PAD.decode(encoded_uid.as_bytes()) {
Ok(uid_bytes) => uid_bytes == user_id.as_bytes(),
Err(_) => false,
}
}
pub const LEGACY_SESSION_COOKIE_NAME: &str = "session";
pub const SESSION_COOKIE_NAME: &str = "admin_session";
pub const SESSION_MAX_AGE: i64 = MAX_AGE_SECONDS as i64;
@@ -7,6 +7,7 @@ use super::media::user_avatar_url;
use super::nsfw_indicators::{attachment_nsfw_badge, channel_nsfw_state_badge};
use super::user_display::format_user_display;
use crate::config::AdminConfig;
use crate::routes::auth::json_string;
pub struct Attachment {
pub id: String,
@@ -309,7 +310,7 @@ pub fn message_list(
}
pub fn message_deletion_script(csrf_token: &str) -> Markup {
let csrf = serde_json::to_string(csrf_token).unwrap_or_else(|_| "\"\"".into());
let csrf = json_string(csrf_token);
let script = r#"(function() {
var csrf = __CSRF__;
function bp() {
+31 -5
View File
@@ -23,6 +23,7 @@ type CacheEntry = {
};
const CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_MAX_ENTRIES = 10_000;
const geoipCache = new Map<string, CacheEntry>();
let maxmindReader: Reader<CityResponse> | null = null;
@@ -85,6 +86,32 @@ function isAsciiUpperAlpha2(value: string): boolean {
);
}
function getCachedGeoipResult(cacheKey: string, normalizedIp: string): GeoipResult | null {
const cached = geoipCache.get(cacheKey);
if (!cached) {
return null;
}
if (Date.now() >= cached.expiresAt) {
geoipCache.delete(cacheKey);
return null;
}
geoipCache.delete(cacheKey);
geoipCache.set(cacheKey, cached);
return {...cached.result, normalizedIp};
}
function setCachedGeoipResult(cacheKey: string, result: GeoipResult): void {
geoipCache.delete(cacheKey);
if (geoipCache.size >= CACHE_MAX_ENTRIES) {
const oldestKey = geoipCache.keys().next().value;
if (oldestKey === undefined) {
throw new Error('GeoIP cache reached capacity without an entry to evict');
}
geoipCache.delete(oldestKey);
}
geoipCache.set(cacheKey, {result, expiresAt: Date.now() + CACHE_TTL_MS});
}
async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult> {
try {
const reader = await ensureReader(dbPath);
@@ -108,14 +135,13 @@ async function lookupMaxmind(clean: string, dbPath: string): Promise<GeoipResult
}
async function resolveGeoip(clean: string, dbPath: string): Promise<GeoipResult> {
const now = Date.now();
const cacheKey = getSameIpDecisionKey(clean) ?? clean;
const cached = geoipCache.get(cacheKey);
if (cached && now < cached.expiresAt) {
return {...cached.result, normalizedIp: clean};
const cached = getCachedGeoipResult(cacheKey, clean);
if (cached) {
return cached;
}
const result = await lookupMaxmind(clean, dbPath);
geoipCache.set(cacheKey, {result, expiresAt: now + CACHE_TTL_MS});
setCachedGeoipResult(cacheKey, result);
return result;
}
@@ -89,29 +89,77 @@ function isFqdnHostname(hostname: string): boolean {
return !/^\d+$/.test(topLevelDomain);
}
function parseIpv4MappedIpv6Address(ipv6Address: string): string | null {
function expandIpv6ToBytes(ipv6Address: string): Uint8Array | null {
const normalized = stripIpv6Brackets(ipv6Address.trim().toLowerCase());
if (!normalized.startsWith('::ffff:')) {
if (isIP(normalized) !== 6) {
return null;
}
const suffix = normalized.slice('::ffff:'.length);
if (isIP(suffix) === 4) {
return suffix;
let head = normalized;
let embeddedIpv4Octets: Array<number> | null = null;
const lastColonIndex = head.lastIndexOf(':');
const trailing = head.slice(lastColonIndex + 1);
if (trailing.includes('.')) {
if (isIP(trailing) !== 4) {
return null;
}
embeddedIpv4Octets = trailing.split('.').map((part) => Number.parseInt(part, 10));
head = `${head.slice(0, lastColonIndex + 1)}0:0`;
}
const groups = suffix.split(':');
if (groups.length !== 2) {
let groups: Array<string>;
if (head.indexOf('::') === -1) {
groups = head.split(':');
if (groups.length !== 8) {
return null;
}
} else {
const [beforePart, afterPart] = head.split('::');
const before = beforePart.length > 0 ? beforePart.split(':') : [];
const after = afterPart.length > 0 ? afterPart.split(':') : [];
const missing = 8 - before.length - after.length;
if (missing < 1) {
return null;
}
groups = [...before, ...new Array(missing).fill('0'), ...after];
}
const bytes = new Uint8Array(16);
for (let index = 0; index < 8; index += 1) {
const value = parseHexGroup(groups[index]);
if (value === null) {
return null;
}
bytes[index * 2] = (value >> 8) & 0xff;
bytes[index * 2 + 1] = value & 0xff;
}
if (embeddedIpv4Octets) {
bytes[12] = embeddedIpv4Octets[0];
bytes[13] = embeddedIpv4Octets[1];
bytes[14] = embeddedIpv4Octets[2];
bytes[15] = embeddedIpv4Octets[3];
}
return bytes;
}
function parseEmbeddedIpv4Address(ipv6Address: string): string | null {
const bytes = expandIpv6ToBytes(ipv6Address);
if (!bytes) {
return null;
}
const high = parseHexGroup(groups[0]);
const low = parseHexGroup(groups[1]);
if (high === null || low === null) {
return null;
const hasPrefix = (prefix: Array<number>): boolean => prefix.every((byte, index) => bytes[index] === byte);
const dottedQuadAt = (start: number): string =>
`${bytes[start]}.${bytes[start + 1]}.${bytes[start + 2]}.${bytes[start + 3]}`;
if (hasPrefix([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff])) {
return dottedQuadAt(12);
}
const octet1 = (high >> 8) & 0xff;
const octet2 = high & 0xff;
const octet3 = (low >> 8) & 0xff;
const octet4 = low & 0xff;
return `${octet1}.${octet2}.${octet3}.${octet4}`;
if (hasPrefix([0x00, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0])) {
return dottedQuadAt(12);
}
if (hasPrefix([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0])) {
return dottedQuadAt(12);
}
if (hasPrefix([0x20, 0x02])) {
return dottedQuadAt(2);
}
return null;
}
function parseHexGroup(value: string | undefined): number | null {
@@ -128,9 +176,9 @@ function isBlockedIpAddress(address: string): boolean {
return blockedIpv4List.check(normalizedAddress, 'ipv4');
}
if (family === 6) {
const mappedIpv4 = parseIpv4MappedIpv6Address(normalizedAddress);
if (mappedIpv4) {
return blockedIpv4List.check(mappedIpv4, 'ipv4');
const embeddedIpv4 = parseEmbeddedIpv4Address(normalizedAddress);
if (embeddedIpv4) {
return blockedIpv4List.check(embeddedIpv4, 'ipv4');
}
return blockedIpv6List.check(normalizedAddress, 'ipv6');
}
+1 -1
View File
@@ -39,7 +39,7 @@ function AbuseAwareAppErrorHandler(err: Error, ctx: Context<HonoEnv>): Response
export async function createAPIApp(options: CreateAPIAppOptions): Promise<APIAppResult> {
const {config, logger} = options;
const shutdownApiLifecycle = createShutdown(logger);
const shutdownApiLifecycle = createShutdown(config, logger);
setIsDevelopment(config.nodeEnv === 'development');
const routes = new Hono<HonoEnv>({strict: true});
configureMiddleware(routes, {
+38
View File
@@ -19,6 +19,26 @@ function trimTrailingSlash(url: string): string {
return url.replace(/\/+$/u, '');
}
function resolveEmailAppBaseUrl(master: MasterConfig): string {
const configuredAppBaseUrl = master.integrations.email.app_base_url.trim();
if (!configuredAppBaseUrl) return trimTrailingSlash(master.endpoints.app);
try {
const appBaseUrl = new URL(configuredAppBaseUrl);
if (
(appBaseUrl.protocol !== 'http:' && appBaseUrl.protocol !== 'https:') ||
appBaseUrl.username ||
appBaseUrl.password ||
appBaseUrl.search ||
appBaseUrl.hash
) {
throw new Error(`Invalid email app base URL: ${configuredAppBaseUrl}`);
}
return trimTrailingSlash(appBaseUrl.toString());
} catch {
throw new Error(`Invalid email app base URL: ${configuredAppBaseUrl}`);
}
}
function resolveGatewayInternalUrl(master: MasterConfig): string {
const configuredInternalGateway = (
master.internal as {
@@ -65,6 +85,18 @@ function normalizeIpBanExemptIps(values: Array<string>): Array<string> {
return Array.from(normalized);
}
function normalizeCountryCodes(values: Array<string>, configName: string): ReadonlySet<string> {
const normalized = new Set<string>();
for (const value of values) {
const countryCode = value.trim().toUpperCase();
if (!/^[A-Z]{2}$/u.test(countryCode)) {
throw new Error(`${configName} contains an invalid ISO 3166-1 alpha-2 country code: ${value}`);
}
normalized.add(countryCode);
}
return normalized;
}
function mapPushProviderApps(
apps:
| Array<{
@@ -122,6 +154,10 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
nodeEnv: master.env === 'test' ? 'development' : master.env,
port: master.services.api.port,
ipBanExemptIps: normalizeIpBanExemptIps(master.services.api.ip_ban_exempt_ips),
desktopGitHubRedirectCountries: normalizeCountryCodes(
master.services.api.desktop_github_redirect_countries,
'FLUXER_API_DESKTOP_GITHUB_REDIRECT_COUNTRIES',
),
cassandra: {
hosts: cassandraSource?.hosts.join(',') ?? '',
port: cassandraSource?.port ?? 9042,
@@ -254,6 +290,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
webhookSecret: master.integrations.email.webhook_secret ?? undefined,
fromEmail: master.integrations.email.from_email,
fromName: master.integrations.email.from_name,
appBaseUrl: resolveEmailAppBaseUrl(master),
smtp: master.integrations.email.smtp
? {
host: master.integrations.email.smtp.host,
@@ -306,6 +343,7 @@ export function buildAPIConfigFromMaster(master: MasterConfig): APIConfig {
apiSecret: master.integrations.voice.api_secret,
webhookUrl: master.integrations.voice.webhook_url,
url: master.integrations.voice.url,
internalUrl: master.integrations.voice.internal_url,
defaultRegion: master.integrations.voice.default_region,
},
stripe: {
@@ -133,6 +133,7 @@ export class AdminGuildMembershipService {
guildId,
targetId,
deleteMessageDays: data.delete_message_days,
deleteMessageSeconds: data.delete_message_seconds,
reason: data.reason ?? undefined,
banDurationSeconds: data.ban_duration_seconds ?? undefined,
skipGuildAuditLog: true,
+28 -15
View File
@@ -36,6 +36,11 @@ import {JetStreamWorkerQueue} from '../worker/JetStreamWorkerQueue';
import {WorkerService} from '../worker/WorkerService';
let jsConnectionManager: JetStreamConnectionManager | null = null;
function unsupportedDatabaseBackend(backend: never): never {
throw new Error(`Unsupported database backend during shutdown: ${String(backend)}`);
}
export function createInitializer(config: APIConfig, logger: ILogger): () => Promise<void> {
return async (): Promise<void> => {
try {
@@ -131,7 +136,7 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro
try {
const kvDeletionQueue = getKVAccountDeletionQueue();
if (await kvDeletionQueue.needsRebuild()) {
logger.warn('KV deletion queue needs rebuild, rebuilding...');
logger.info('KV deletion queue needs rebuild, rebuilding...');
await kvDeletionQueue.rebuildState();
} else {
logger.info('KV deletion queue state is healthy');
@@ -204,13 +209,13 @@ export function createInitializer(config: APIConfig, logger: ILogger): () => Pro
logger.info('API service initialization complete');
} catch (error) {
logger.error({error}, 'API service initialization failed');
await createShutdown(logger)();
await createShutdown(config, logger)();
throw error;
}
};
}
export function createShutdown(logger: ILogger): () => Promise<void> {
export function createShutdown(config: APIConfig, logger: ILogger): () => Promise<void> {
return async (): Promise<void> => {
logger.info('Shutting down API service...');
if (jsConnectionManager) {
@@ -258,18 +263,26 @@ export function createShutdown(logger: ILogger): () => Promise<void> {
} catch (error) {
logger.error({error}, 'Error shutting down report service');
}
try {
setDatabaseQueryExecutor(null);
await shutdownPostgres();
logger.info('Postgres client shut down');
} catch (error) {
logger.error({error}, 'Error shutting down Postgres client');
}
try {
await shutdownCassandra();
logger.info('Cassandra client shut down');
} catch (error) {
logger.error({error}, 'Error shutting down Cassandra client');
setDatabaseQueryExecutor(null);
switch (config.database.backend) {
case 'postgres':
try {
await shutdownPostgres();
logger.info('Postgres client shut down');
} catch (error) {
logger.error({error}, 'Error shutting down Postgres client');
}
break;
case 'cassandra':
try {
await shutdownCassandra();
logger.info('Cassandra client shut down');
} catch (error) {
logger.error({error}, 'Error shutting down Cassandra client');
}
break;
default:
unsupportedDatabaseBackend(config.database.backend);
}
logger.info('API service shutdown complete');
};
+3 -1
View File
@@ -66,7 +66,9 @@ export function registerControllers(routes: HonoApp, config: APIConfig): void {
TestHarnessController(routes);
}
UserController(routes);
registerInboundSmsWebhook(routes);
if (config.sms.enabled) {
registerInboundSmsWebhook(routes);
}
WebhookController(routes);
OAuth2Controller(routes);
OAuth2ApplicationsController(routes);
+32 -19
View File
@@ -359,13 +359,36 @@ export async function login(
const MFA_TICKET_MAX_ATTEMPTS = 5;
const MFA_USER_MAX_ATTEMPTS = 10;
const MFA_USER_ATTEMPTS_WINDOW = seconds('15 minutes');
async function consumeMfaAttempt(
ctx: ApiContext,
{userId, ticket, field}: {userId: string; ticket: string; field: string},
): Promise<void> {
const {cache, rateLimit} = ctx.services;
const userLimit = await rateLimit.checkLimit({
identifier: `mfa:user:${userId}`,
maxAttempts: MFA_USER_MAX_ATTEMPTS,
windowMs: ms('15 minutes'),
});
if (!userLimit.allowed) {
throw InputValidationError.fromCode(field, ValidationErrorCodes.INVALID_CODE);
}
const ticketLimit = await rateLimit.checkLimit({
identifier: `mfa:ticket:${ticket}`,
maxAttempts: MFA_TICKET_MAX_ATTEMPTS,
windowMs: ms('5 minutes'),
});
if (!ticketLimit.allowed) {
await cache.delete(`mfa-ticket:${ticket}`);
throw InputValidationError.fromCode(field, ValidationErrorCodes.INVALID_CODE);
}
}
export async function loginMfaTotp(
ctx: ApiContext,
{code, ticket, request}: LoginMfaTotpParams,
): Promise<LoginTokenResult> {
const {users, cache} = ctx.services;
const {users, cache, rateLimit} = ctx.services;
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.SESSION_TIMEOUT);
@@ -378,32 +401,19 @@ export async function loginMfaTotp(
if (!user.totpSecret || !user.authenticatorTypes?.has(UserAuthenticatorTypes.TOTP)) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.TOTP_NOT_ENABLED);
}
const userAttemptsKey = `mfa-user-attempts:${user.id}`;
const userAttempts = (await cache.get<number>(userAttemptsKey)) ?? 0;
if (userAttempts >= MFA_USER_MAX_ATTEMPTS) {
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
}
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'code'});
const isValid = await AuthMfa.verifyMfaCode(ctx, {
userId: user.id,
mfaSecret: user.totpSecret,
code,
allowBackup: true,
});
const attemptsKey = `mfa-ticket-attempts:${ticket}`;
if (!isValid) {
await cache.set(userAttemptsKey, userAttempts + 1, MFA_USER_ATTEMPTS_WINDOW);
const attempts = ((await cache.get<number>(attemptsKey)) ?? 0) + 1;
if (attempts >= MFA_TICKET_MAX_ATTEMPTS) {
await cache.delete(`mfa-ticket:${ticket}`);
await cache.delete(attemptsKey);
} else {
await cache.set(attemptsKey, attempts, seconds('5 minutes'));
}
throw InputValidationError.fromCode('code', ValidationErrorCodes.INVALID_CODE);
}
await cache.delete(`mfa-ticket:${ticket}`);
await cache.delete(attemptsKey);
await cache.delete(userAttemptsKey);
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:user:${user.id}`);
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
@@ -415,7 +425,7 @@ export async function loginMfaWebAuthn(
ctx: ApiContext,
{response, challenge, ticket, request}: LoginMfaWebAuthnParams,
): Promise<LoginTokenResult> {
const {users, cache} = ctx.services;
const {users, cache, rateLimit} = ctx.services;
const userId = await cache.get<string>(`mfa-ticket:${ticket}`);
if (!userId) {
throw InputValidationError.fromCode('ticket', ValidationErrorCodes.SESSION_TIMEOUT);
@@ -425,8 +435,11 @@ export async function loginMfaWebAuthn(
throw new UnknownUserError();
}
AuthUtility.assertNonBotUser(ctx, user);
await consumeMfaAttempt(ctx, {userId: user.id.toString(), ticket, field: 'ticket'});
await AuthMfa.verifyWebAuthnAuthentication(ctx, user.id, response, challenge, 'mfa', ticket);
await cache.delete(`mfa-ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:ticket:${ticket}`);
await rateLimit.resetLimit(`mfa:user:${user.id}`);
const [token] = await AuthSession.createAuthSession(ctx, {
user,
origin: AuthSession.resolveSessionOrigin(ctx, request),
+19 -1
View File
@@ -21,7 +21,7 @@ import {
verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import {seconds} from 'itty-time';
import {ms, seconds} from 'itty-time';
import type {ApiContext} from '../ApiContext';
import {createUserID, type UserID} from '../BrandedTypes';
import {Logger} from '../Logger';
@@ -410,6 +410,20 @@ export async function generateWebAuthnOptionsForSudo(ctx: ApiContext, userId: Us
return options;
}
const SUDO_MFA_USER_MAX_ATTEMPTS = 10;
async function consumeSudoMfaAttempt(ctx: ApiContext, userId: UserID): Promise<void> {
const {rateLimit} = ctx.services;
const userLimit = await rateLimit.checkLimit({
identifier: `sudo-mfa:user:${userId}`,
maxAttempts: SUDO_MFA_USER_MAX_ATTEMPTS,
windowMs: ms('15 minutes'),
});
if (!userLimit.allowed) {
throw InputValidationError.fromCode('mfa_code', ValidationErrorCodes.INVALID_MFA_CODE);
}
}
export async function verifySudoMfa(
ctx: ApiContext,
params: SudoMfaVerificationParams,
@@ -427,7 +441,11 @@ export async function verifySudoMfa(
case 'totp': {
if (!code) return {success: false, error: 'TOTP code is required'};
if (!user.totpSecret) return {success: false, error: 'TOTP is not enabled'};
await consumeSudoMfaAttempt(ctx, userId);
const isValid = await verifyMfaCode(ctx, {userId, mfaSecret: user.totpSecret, code, allowBackup: true});
if (isValid) {
await ctx.services.rateLimit.resetLimit(`sudo-mfa:user:${userId}`);
}
return {success: isValid, error: isValid ? undefined : 'Invalid TOTP code'};
}
case 'webauthn': {
+1 -1
View File
@@ -267,7 +267,7 @@ export async function resetPassword(
},
user.toRow(),
);
await users.deleteAllAuthSessions(user.id);
await AuthSession.terminateAllUserSessions(ctx, user.id);
await users.deletePasswordResetToken(data.token);
const hasMfa =
updatedUser.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) ||
+1
View File
@@ -91,6 +91,7 @@ export async function createAuthSession(
if (user.isBot) throw new BotUserAuthSessionCreationDeniedError();
if (user.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)) throw new RegistrationPendingApprovalError();
if (user.traits.has(REGISTRATION_REJECTED_TRAIT)) throw new RegistrationRejectedError();
user = await AuthUtility.handleBanStatus(ctx, user);
const now = new Date();
const token = await AuthUtility.generateAuthToken(ctx);
let clientCountry: string | null = null;
@@ -225,7 +225,7 @@ function resolveEmailVerified({
if (values.includes(false)) {
return false;
}
return values.length === 0 || values.includes(true);
return values.length > 0 && values.includes(true);
}
function isJsonWebKeySet(value: unknown): value is JSONWebKeySet {
@@ -0,0 +1,79 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
import {Config} from '../../Config';
import type {ApiTestHarness} from '../../test/ApiTestHarness';
import {HTTP_STATUS} from '../../test/TestConstants';
import {createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
import {createAuthHarness, createUniqueEmail, createUniqueUsername, registerUser} from './AuthTestUtils';
async function withCaptchaEnabled<T>(run: () => Promise<T>): Promise<T> {
const previousEnabled = Config.captcha.enabled;
const previousTestModeEnabled = Config.dev.testModeEnabled;
Config.captcha.enabled = true;
Config.dev.testModeEnabled = true;
try {
return await run();
} finally {
Config.captcha.enabled = previousEnabled;
Config.dev.testModeEnabled = previousTestModeEnabled;
}
}
async function registerAndFlag(
harness: ApiTestHarness,
flags: Array<string>,
): Promise<{email: string; password: string; userId: string}> {
const email = createUniqueEmail('captcha-flags');
const password = 'a-strong-password';
const reg = await registerUser(harness, {
email,
username: createUniqueUsername('captchaflags'),
global_name: 'Captcha Flags User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
if (flags.length > 0) {
await createBuilderWithoutAuth(harness)
.post(`/test/users/${reg.user_id}/security-flags`)
.body({set_flags: flags})
.execute();
}
return {email, password, userId: reg.user_id};
}
describe('Auth Captcha Bypass Flags', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createAuthHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
it('lets APP_STORE_REVIEWER accounts log in without solving a captcha', async () => {
const account = await registerAndFlag(harness, ['APP_STORE_REVIEWER']);
await withCaptchaEnabled(async () => {
const resp = await createBuilderWithoutAuth<{token?: string; user_id?: string}>(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.execute();
expect(resp.token).toBeTruthy();
expect(resp.user_id).toBe(account.userId);
});
});
it('still requires a captcha for accounts without the APP_STORE_REVIEWER flag', async () => {
const account = await registerAndFlag(harness, []);
await withCaptchaEnabled(async () => {
await createBuilderWithoutAuth(harness)
.post('/auth/login')
.body({email: account.email, password: account.password})
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.CAPTCHA_REQUIRED)
.execute();
});
});
});
@@ -311,7 +311,7 @@ describe('Auth registration', () => {
date_of_birth: '2000-01-01',
consent: true,
})
.expect(403, 'TOR_BLOCKED')
.expect(403, 'GLOBAL_IP_BANNED')
.execute();
} finally {
torExitListCache.clearForTesting();
@@ -233,11 +233,14 @@ export class AttachmentUploadService {
const parts = await Promise.all(
Array.from({length: partCount}, async (_, index) => {
const partNumber = index + 1;
const partContentLength =
partNumber < partCount ? partSize : attachment.file_size - partSize * (partCount - 1);
const presigned_upload_url = await this.storageService.getPresignedUploadPartURL({
bucket,
key: uploadKey,
uploadId,
partNumber,
contentLength: partContentLength,
});
const upload_url = applyUploadRelayDecision({
presignedUrl: presigned_upload_url,
@@ -246,7 +249,7 @@ export class AttachmentUploadService {
relayDecision: uploadRelayDecision,
uploadId,
partNumber,
maxBytes: partSize,
maxBytes: partContentLength,
});
return {part_number: partNumber, upload_url};
}),
@@ -275,7 +278,7 @@ export class AttachmentUploadService {
if (!Config.presignedAttachmentUploadsEnabled) {
throw new FeatureTemporarilyDisabledError();
}
await this.getUploadPermissionAndLimit({userId, channelId});
const {maxFileSize} = await this.getUploadPermissionAndLimit({userId, channelId});
const bucket = Config.s3.buckets.uploads;
return Promise.all(
uploads.map(async ({upload_filename, upload_id}, index) => {
@@ -305,6 +308,13 @@ export class AttachmentUploadService {
.catch(() => undefined);
throw InputValidationError.fromCode('parts', ValidationErrorCodes.NO_UPLOADED_PARTS_TO_FINALIZE);
}
const totalUploadedBytes = parts.reduce((sum, part) => sum + (part.size ?? 0), 0);
if (totalUploadedBytes > maxFileSize) {
await this.storageService
.abortMultipartUpload({bucket, key: upload_filename, uploadId: upload_id})
.catch(() => undefined);
throw new FileSizeTooLargeError(maxFileSize);
}
try {
await runAttachmentStorageOperation(() =>
this.storageService.completeMultipartUpload({
@@ -13,6 +13,7 @@ import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponse
import type {ChannelID, GuildID, UserID} from '../../BrandedTypes';
import {SYSTEM_USER_ID} from '../../constants/Core';
import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';
import {createGuildMfaEnforcer} from '../../guild/services/GuildMfaEnforcement';
import type {IGatewayService} from '../../infrastructure/IGatewayService';
import type {Channel} from '../../models/Channel';
import type {GuildMember} from '../../models/GuildMember';
@@ -173,8 +174,15 @@ export abstract class BaseChannelAuthService {
userId,
memberData: guildMemberResult.memberData!,
});
const enforceGuildMfa = await createGuildMfaEnforcer({
userRepository: this.userRepository,
guildData: guildDataResult!,
userId,
});
const hasPermission = async (permission: bigint): Promise<boolean> => {
return await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id});
const allowed = await this.gatewayService.checkPermission({guildId, userId, permission, channelId: channel.id});
if (allowed) enforceGuildMfa(permission);
return allowed;
};
const checkPermission = async (permission: bigint): Promise<void> => {
const allowed = await hasPermission(permission);
@@ -40,9 +40,8 @@ export class DMPermissionValidator {
if (isBugHunterBotUser(senderUser)) {
return;
}
if (!senderUser.isBot && (senderUser.flags & UserFlags.SPAMMER) === UserFlags.SPAMMER) {
return;
}
const isShadowbannedSpammer =
!senderUser.isBot && (senderUser.flags & UserFlags.SPAMMER) === UserFlags.SPAMMER;
const [senderBlockedTarget, targetBlockedSender, areFriends, targetSettings, senderSettings] = await Promise.all([
this.deps.userRepository.getRelationship(senderId, recipientId, RelationshipTypes.BLOCKED),
this.deps.userRepository.getRelationship(recipientId, senderId, RelationshipTypes.BLOCKED),
@@ -53,6 +52,9 @@ export class DMPermissionValidator {
if (senderBlockedTarget || targetBlockedSender) {
throw new CannotSendMessagesToUserError();
}
if (isShadowbannedSpammer) {
return;
}
if (areFriends) {
return;
}
@@ -17,6 +17,7 @@ import type {IUserRepository} from '../../user/IUserRepository';
import {assertGuildMemberCanCommunicate} from '../../utils/GuildCommunicationUtils';
import type {IChannelRepository} from '../IChannelRepository';
import {MessageInteractionAuthService} from './interaction/MessageInteractionAuthService';
import {MessagePinAuthService} from './interaction/MessagePinAuthService';
import {MessagePinService} from './interaction/MessagePinService';
import {MessageReactionService} from './interaction/MessageReactionService';
import {MessageReadStateService} from './interaction/MessageReadStateService';
@@ -25,6 +26,7 @@ import type {MessagePersistenceService} from './message/MessagePersistenceServic
export class MessageInteractionService {
readonly authService: MessageInteractionAuthService;
private pinAuthService: MessagePinAuthService;
private readStateService: MessageReadStateService;
private pinService: MessagePinService;
private reactionService: MessageReactionService;
@@ -45,6 +47,12 @@ export class MessageInteractionService {
guildRepository,
gatewayService,
);
this.pinAuthService = new MessagePinAuthService(
channelRepository,
userRepository,
guildRepository,
gatewayService,
);
this.readStateService = new MessageReadStateService(gatewayService);
this.pinService = new MessagePinService(
gatewayService,
@@ -85,7 +93,7 @@ export class MessageInteractionService {
items: Array<ChannelPinResponse>;
has_more: boolean;
}> {
const authChannel = await this.authService.getChannelAuthenticated({userId, channelId});
const authChannel = await this.pinAuthService.getChannelAuthenticated({userId, channelId});
return this.pinService.getChannelPins({authChannel, userId, requestCache, beforeTimestamp, limit});
}
@@ -186,12 +186,7 @@ export class ChannelOperationsService {
let permissionOverwrites = channel.permissionOverwrites;
if (data.permission_overwrites !== undefined) {
const guildId = createGuildID(BigInt(guild.id));
const canManageRoles = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
await checkPermission(Permissions.MANAGE_ROLES);
const isOwner = guild.owner_id === userId.toString();
const channelPermissions = await this.gatewayService.getUserPermissions({
guildId,
@@ -205,6 +200,17 @@ export class ChannelOperationsService {
throw new MissingPermissionsError();
}
}
const nextDeny = new Map<RoleID | UserID, bigint>();
for (const overwrite of data.permission_overwrites ?? []) {
const targetKey = overwrite.type === 0 ? createRoleID(overwrite.id) : createUserID(overwrite.id);
nextDeny.set(targetKey, (overwrite.deny ? BigInt(overwrite.deny) : 0n) & ALL_PERMISSIONS);
}
for (const [targetId, existing] of previousPermissionOverwrites ?? []) {
const removedDeny = existing.deny & ~(nextDeny.get(targetId) ?? 0n);
if ((removedDeny & ~channelPermissions) !== 0n) {
throw new MissingPermissionsError();
}
}
}
permissionOverwrites = new Map();
for (const overwrite of data.permission_overwrites ?? []) {
@@ -590,6 +596,7 @@ export class ChannelOperationsService {
const canManageRoles = await this.gatewayService.checkPermission({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
@@ -615,6 +622,8 @@ export class ChannelOperationsService {
const sanitizedDeny = protectedBits.deny;
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
if (!hasAdministrator && (sanitizedAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError();
const removedDeny = (existing?.deny ?? 0n) & ~sanitizedDeny;
if (!hasAdministrator && (removedDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
const previousPermissionOverwrites = channel.permissionOverwrites;
const overwrites = new Map(channel.permissionOverwrites ?? []);
overwrites.set(
@@ -697,6 +706,7 @@ export class ChannelOperationsService {
const canManageRoles = await this.gatewayService.checkPermission({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
permission: Permissions.MANAGE_ROLES,
});
if (!canManageRoles) throw new MissingPermissionsError();
@@ -705,6 +715,15 @@ export class ChannelOperationsService {
const removedRole = overwrites.get(createRoleID(params.overwriteId));
const removedUser = overwrites.get(createUserID(params.overwriteId));
const removed = removedRole ?? removedUser;
if (removed) {
const userPermissions = await this.gatewayService.getUserPermissions({
guildId: channel.guildId,
userId: params.userId,
channelId: channel.id,
});
const hasAdministrator = (userPermissions & Permissions.ADMINISTRATOR) !== 0n;
if (!hasAdministrator && (removed.deny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
}
overwrites.delete(createRoleID(params.overwriteId));
overwrites.delete(createUserID(params.overwriteId));
const updated = await this.channelRepository.channelData.upsert({
@@ -2,7 +2,6 @@
import {dispatchChannelEvent} from '@app/api/channel/services/ChannelGatewayDispatch';
import type {MessageID, UserID} from '../../../BrandedTypes';
import {Config} from '../../../Config';
import type {IPurgeQueue} from '../../../infrastructure/BunnyPurgeQueue';
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
import type {IStorageService} from '../../../infrastructure/IStorageService';
@@ -13,7 +12,7 @@ import type {Message} from '../../../models/Message';
import {mapChannelToResponse} from '../../ChannelMappers';
import type {IChannelRepositoryAggregate} from '../../repositories/IChannelRepositoryAggregate';
import {dispatchMessageCreateBroadcast} from '../message/MessageGatewayDispatch';
import {makeAttachmentCdnKey, makeAttachmentCdnUrl} from '../message/MessageHelpers';
import {purgeMessageAttachments} from '../message/MessageHelpers';
export class ChannelUtilsService {
constructor(
@@ -44,20 +43,7 @@ export class ChannelUtilsService {
}
private async purgeMessageAttachments(message: Message): Promise<void> {
const cdnUrls: Array<string> = [];
await Promise.all(
message.attachments.map(async (attachment) => {
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
await this.storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
if (Config.bunny.purgeEnabled) {
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
cdnUrls.push(cdnUrl);
}
}),
);
if (Config.bunny.purgeEnabled && cdnUrls.length > 0) {
await this.purgeQueue.addUrls(cdnUrls);
}
await purgeMessageAttachments(message, this.storageService, this.purgeQueue);
}
async dispatchChannelUpdate({channel, requestCache}: {channel: Channel; requestCache: RequestCache}): Promise<void> {
@@ -0,0 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {BaseChannelAuthService, type ChannelAuthOptions} from '../BaseChannelAuthService';
export class MessagePinAuthService extends BaseChannelAuthService {
protected readonly options: ChannelAuthOptions = {
errorOnMissingGuild: 'unknown_channel',
validateNsfw: true,
};
}
@@ -116,6 +116,16 @@ export class AttachmentProcessingService {
);
const hasVirusDetected = results.some((result) => result.hasVirusDetected);
if (hasVirusDetected) {
await Promise.all(
results.map(async (result) => {
if (result.sourceLocalPath) {
await fs.promises.unlink(result.sourceLocalPath).catch(() => undefined);
}
}),
);
for (const result of results) {
this.deleteUploadObject(result.copyOperation.sourceBucket, result.copyOperation.sourceKey);
}
return {attachments: [], hasVirusDetected: true};
}
const copyResults = await mapWithConcurrency(results, ATTACHMENT_PROCESSING_CONCURRENCY, (result) =>
@@ -225,14 +225,14 @@ export class MessageDeleteService {
async deleteUserMessagesInGuild({
userId,
guildId,
days,
seconds,
}: {
userId: UserID;
guildId: GuildID;
days: number;
seconds: number;
}): Promise<void> {
const channels = await this.deps.channelRepository.channelData.listGuildChannels(guildId);
const cutoffTimestamp = Date.now() - days * ms('1 day');
const cutoffTimestamp = Date.now() - seconds * ms('1 second');
const cutoffSnowflake = createMessageID(createSnowflakeFromTimestamp(cutoffTimestamp));
await Promise.all(
channels.map(async (channel: Channel) => {
@@ -25,6 +25,7 @@ import type {LimitConfigService} from '../../../limits/LimitConfigService';
import {resolveLimitSafe} from '../../../limits/LimitConfigUtils';
import {createLimitMatchContext} from '../../../limits/LimitMatchContextBuilder';
import {Attachment} from '../../../models/Attachment';
import type {Embed} from '../../../models/Embed';
import type {Message} from '../../../models/Message';
import {MessageSnapshot as MessageSnapshotModel} from '../../../models/MessageSnapshot';
import type {User} from '../../../models/User';
@@ -307,22 +308,60 @@ export async function createMessageSnapshotsForForward(
return [new MessageSnapshotModel(snapshotData)];
}
function collectEmbedReferencedAttachmentCdnKeys(message: Message): Array<string> {
const mediaPrefix = `${Config.endpoints.media}/`;
const keys = new Set<string>();
const consider = (url: string | null | undefined): void => {
if (!url || !url.startsWith(mediaPrefix)) {
return;
}
const key = url.slice(mediaPrefix.length);
if (key.startsWith('attachments/')) {
keys.add(key);
}
};
const scanEmbeds = (embeds: Array<Embed>): void => {
for (const embed of embeds) {
consider(embed.image?.url);
consider(embed.thumbnail?.url);
consider(embed.video?.url);
consider(embed.audio?.url);
}
};
scanEmbeds(message.embeds);
for (const snapshot of message.messageSnapshots) {
scanEmbeds(snapshot.embeds);
}
return [...keys];
}
export async function purgeMessageAttachments(
message: Message,
storageService: IStorageService,
purgeQueue: IPurgeQueue,
): Promise<void> {
const cdnKeys = new Set<string>();
const cdnUrls: Array<string> = [];
await Promise.all(
message.attachments.map(async (attachment) => {
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
await storageService.deleteObject(Config.s3.buckets.cdn, cdnKey);
if (Config.bunny.purgeEnabled) {
const cdnUrl = makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename);
cdnUrls.push(cdnUrl);
}
}),
);
for (const attachment of collectMessageAttachments(message)) {
const cdnKey = makeAttachmentCdnKey(message.channelId, attachment.id, attachment.filename);
if (cdnKeys.has(cdnKey)) {
continue;
}
cdnKeys.add(cdnKey);
if (Config.bunny.purgeEnabled) {
cdnUrls.push(makeAttachmentCdnUrl(message.channelId, attachment.id, attachment.filename));
}
}
for (const embedKey of collectEmbedReferencedAttachmentCdnKeys(message)) {
if (cdnKeys.has(embedKey)) {
continue;
}
cdnKeys.add(embedKey);
if (Config.bunny.purgeEnabled) {
cdnUrls.push(`${Config.endpoints.media}/${embedKey}`);
}
}
await Promise.all([...cdnKeys].map((cdnKey) => storageService.deleteObject(Config.s3.buckets.cdn, cdnKey)));
if (Config.bunny.purgeEnabled && cdnUrls.length > 0) {
await purgeQueue.addUrls(cdnUrls);
}
@@ -14,6 +14,7 @@ import type {IFavoriteMemeRepository} from '../../../favorite_meme/IFavoriteMeme
import type {IMediaService} from '../../../infrastructure/IMediaService';
import type {ISnowflakeService} from '../../../infrastructure/ISnowflakeService';
import type {IStorageService} from '../../../infrastructure/IStorageService';
import {Logger} from '../../../Logger';
import type {FavoriteMeme} from '../../../models/FavoriteMeme';
import type {Message} from '../../../models/Message';
import type {User} from '../../../models/User';
@@ -90,8 +91,21 @@ export class MessageOperationsHelpers {
}
throw error;
}
const needsAnimationProbe =
!favoriteMeme.isGifv &&
favoriteMeme.contentType !== 'image/gif' &&
favoriteMeme.contentType !== 'image/apng' &&
ANIMATION_PROBE_CONTENT_TYPES.has(favoriteMeme.contentType);
const metadata =
needsAnimationProbe || favoriteMeme.placeholder == null
? await this.probeFavoriteMemeMetadata(favoriteMeme)
: null;
const placeholder = favoriteMeme.placeholder ?? metadata?.placeholder ?? null;
if (placeholder != null && favoriteMeme.placeholder == null) {
await this.repairFavoriteMemePlaceholder(favoriteMeme, placeholder);
}
let flags = 0;
if (await this.isFavoriteMemeAnimated(favoriteMeme)) {
if (this.isFavoriteMemeAnimated(favoriteMeme, metadata)) {
flags |= MessageAttachmentFlags.IS_ANIMATED;
}
return {
@@ -104,7 +118,7 @@ export class MessageOperationsHelpers {
height: favoriteMeme.height,
content_type: favoriteMeme.contentType,
content_hash: favoriteMeme.contentHash,
placeholder: null,
placeholder,
flags,
duration: favoriteMeme.duration,
nsfw: null,
@@ -112,20 +126,31 @@ export class MessageOperationsHelpers {
};
}
private async isFavoriteMemeAnimated(favoriteMeme: FavoriteMeme): Promise<boolean> {
if (favoriteMeme.isGifv) return true;
if (favoriteMeme.contentType === 'image/gif' || favoriteMeme.contentType === 'image/apng') return true;
if (!ANIMATION_PROBE_CONTENT_TYPES.has(favoriteMeme.contentType)) return false;
private async probeFavoriteMemeMetadata(favoriteMeme: FavoriteMeme) {
try {
const metadata = await this.deps.mediaService.getMetadata({
return await this.deps.mediaService.getMetadata({
type: 's3',
bucket: Config.s3.buckets.cdn,
key: favoriteMeme.storageKey,
nsfw: 'allow',
});
return metadata?.animated === true;
} catch {
return false;
return null;
}
}
private async repairFavoriteMemePlaceholder(favoriteMeme: FavoriteMeme, placeholder: string): Promise<void> {
try {
await this.deps.favoriteMemeRepository.updatePlaceholder(favoriteMeme.userId, favoriteMeme.id, placeholder);
} catch (error) {
Logger.warn({error, memeId: favoriteMeme.id.toString()}, 'Failed to backfill favorite meme placeholder');
}
}
private isFavoriteMemeAnimated(favoriteMeme: FavoriteMeme, metadata: {animated?: boolean | null} | null): boolean {
if (favoriteMeme.isGifv) return true;
if (favoriteMeme.contentType === 'image/gif' || favoriteMeme.contentType === 'image/apng') return true;
if (!ANIMATION_PROBE_CONTENT_TYPES.has(favoriteMeme.contentType)) return false;
return metadata?.animated === true;
}
}
@@ -1191,7 +1191,7 @@ export class MessageSendService {
await this.deps.mentionService.handleMentionTasks({
guildId: channel.guildId,
message,
authorId: createUserID(0n),
authorId: createUserID(BigInt(webhook.id)),
mentionHere: mentionData?.mentionHere ?? false,
});
await this.deps.dispatchService.dispatchMessageCreate({
@@ -248,9 +248,10 @@ export class MessageValidationService {
}
const isAuthor = message.authorId === userId;
if (!guild) return isAuthor;
if (isAuthor) return true;
const canManageMessages =
(await hasPermission(Permissions.SEND_MESSAGES)) && (await hasPermission(Permissions.MANAGE_MESSAGES));
return isAuthor || canManageMessages;
return canManageMessages;
}
private validateVoiceMessageConstraints(
+3
View File
@@ -35,6 +35,7 @@ export interface APIConfig {
nodeEnv: 'development' | 'production';
port: number;
ipBanExemptIps: Array<string>;
desktopGitHubRedirectCountries: ReadonlySet<string>;
cassandra: {
hosts: string;
port: number;
@@ -158,6 +159,7 @@ export interface APIConfig {
webhookSecret?: string;
fromEmail: string;
fromName: string;
appBaseUrl: string;
smtp?: {
host: string;
port: number;
@@ -204,6 +206,7 @@ export interface APIConfig {
apiSecret?: string;
webhookUrl?: string;
url?: string;
internalUrl?: string;
defaultRegion?: {
id: string;
name: string;
@@ -30,6 +30,7 @@ import {Config} from '../Config';
import type {IChannelRepository} from '../channel/IChannelRepository';
import type {AttachmentUploadTraceRepository} from '../channel/repositories/message/AttachmentUploadTraceRepository';
import {
collectMessageAttachments,
makeAttachmentCdnKey,
makeAttachmentCdnUrl,
purgeMessageAttachments,
@@ -43,6 +44,9 @@ import type {IStorageService} from '../infrastructure/IStorageService';
import type {KVAccountDeletionQueueService} from '../infrastructure/KVAccountDeletionQueueService';
import type {UserCacheService} from '../infrastructure/UserCacheService';
import {Logger} from '../Logger';
import type {Embed} from '../models/Embed';
import type {EmbedMedia} from '../models/EmbedMedia';
import type {Message} from '../models/Message';
import type {User} from '../models/User';
import type {IARMessageContext, IARSubmission} from '../report/IReportRepository';
import type {ReportRepository} from '../report/ReportRepository';
@@ -471,18 +475,34 @@ export class NcmecSubmissionService {
private async resolveAttachment(input: SubmitAttachmentToNcmecInput): Promise<ResolvedAttachment> {
const liveMessage = await this.deps.channelRepository.getMessage(input.channelId, input.messageId);
const liveAttachment = liveMessage?.attachments.find(
(attachment) => attachment.id === input.attachmentId && attachment.filename === input.filename,
);
if (liveMessage?.authorId && liveAttachment) {
return this.buildAttachmentContext(input, {
contentType: liveAttachment.contentType,
reportedAt: snowflakeToDate(input.messageId),
userId: liveMessage.authorId,
sourceReportId: input.sourceReportId ?? null,
bucket: Config.s3.buckets.cdn,
storageKey: makeAttachmentCdnKey(input.channelId, input.attachmentId, input.filename),
});
if (liveMessage?.authorId) {
const liveAttachment = collectMessageAttachments(liveMessage).find(
(attachment) => attachment.id === input.attachmentId && attachment.filename === input.filename,
);
if (liveAttachment) {
return this.buildAttachmentContext(input, {
contentType: liveAttachment.contentType,
reportedAt: snowflakeToDate(input.messageId),
userId: liveMessage.authorId,
sourceReportId: input.sourceReportId ?? null,
bucket: Config.s3.buckets.cdn,
storageKey: makeAttachmentCdnKey(input.channelId, input.attachmentId, input.filename),
});
}
const embedMedia = findEmbedReferencedAttachmentMedia(
liveMessage,
makeAttachmentCdnUrl(input.channelId, input.attachmentId, input.filename),
);
if (embedMedia) {
return this.buildAttachmentContext(input, {
contentType: embedMedia.contentType,
reportedAt: snowflakeToDate(input.messageId),
userId: liveMessage.authorId,
sourceReportId: input.sourceReportId ?? null,
bucket: Config.s3.buckets.cdn,
storageKey: makeAttachmentCdnKey(input.channelId, input.attachmentId, input.filename),
});
}
}
if (!input.sourceReportId) {
throw new UnknownMessageError();
@@ -939,6 +959,30 @@ function buildAuditMetadata(args: {
return metadata;
}
function findEmbedReferencedAttachmentMedia(message: Message, targetCdnUrl: string): EmbedMedia | null {
const scan = (embeds: Array<Embed>): EmbedMedia | null => {
for (const embed of embeds) {
for (const media of [embed.image, embed.thumbnail, embed.video, embed.audio]) {
if (media?.url === targetCdnUrl) {
return media;
}
}
}
return null;
};
const direct = scan(message.embeds);
if (direct) {
return direct;
}
for (const snapshot of message.messageSnapshots) {
const found = scan(snapshot.embeds);
if (found) {
return found;
}
}
return null;
}
function findAttachmentInReport(
report: IARSubmission,
channelId: ChannelID,
@@ -19,6 +19,8 @@ interface PageState {
const VALUE_SEPARATOR = '\u001f';
const ENCODED_TYPE_KEY = '__fluxer_type';
const POSTGRES_KV_SCHEMA_LOCK_NAMESPACE = 0x46584b56;
const POSTGRES_KV_SCHEMA_LOCK_TIMEOUT = '120s';
function normalizeCql(cql: string): string {
return cql.replace(/\s+/g, ' ').trim();
@@ -354,8 +356,13 @@ function parseEqWhere(whereSql: string, cql: string): ReadonlyArray<EqWhereExpr>
}
export async function ensurePostgresKvSchema(client: IPostgresClient): Promise<void> {
const table = quoteIdentifier(client.kvTable());
await client.query(`
const kvTable = client.kvTable();
const table = quoteIdentifier(kvTable);
await client.transaction(async (db) => {
await db.query("SELECT set_config('statement_timeout', $1, true)", [POSTGRES_KV_SCHEMA_LOCK_TIMEOUT]);
await db.query('SELECT pg_advisory_xact_lock($1, hashtext($2))', [POSTGRES_KV_SCHEMA_LOCK_NAMESPACE, kvTable]);
await db.query("SELECT set_config('statement_timeout', '0', true)");
await db.query(`
CREATE TABLE IF NOT EXISTS ${table} (
table_name text NOT NULL,
partition_key text NOT NULL,
@@ -365,28 +372,29 @@ CREATE TABLE IF NOT EXISTS ${table} (
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (table_name, row_key)
)`);
await client.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_partition_row_idx`)} ON ${table} (table_name, partition_key, row_key)`,
);
await client.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_row_key_c_idx`)} ON ${table} (table_name, row_key COLLATE "C")`,
);
await client.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_expires_idx`)} ON ${table} (expires_at) WHERE expires_at IS NOT NULL`,
);
await client.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_messages_message_idx`)} ON ${table} (partition_key, ((CASE WHEN row_data -> 'message_id' ->> 'value' ~ '^-?[0-9]+$' THEN (row_data -> 'message_id' ->> 'value')::bigint END))) WHERE table_name = 'messages'`,
);
await client.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${client.kvTable()}_message_reactions_message_idx`)} ON ${table} (partition_key, ((CASE WHEN row_data -> 'message_id' ->> 'value' ~ '^-?[0-9]+$' THEN (row_data -> 'message_id' ->> 'value')::bigint END))) WHERE table_name = 'message_reactions'`,
);
await client.query(`
await db.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_partition_row_idx`)} ON ${table} (table_name, partition_key, row_key)`,
);
await db.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_row_key_c_idx`)} ON ${table} (table_name, row_key COLLATE "C")`,
);
await db.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_expires_idx`)} ON ${table} (expires_at) WHERE expires_at IS NOT NULL`,
);
await db.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_messages_message_idx`)} ON ${table} (partition_key, ((CASE WHEN row_data -> 'message_id' ->> 'value' ~ '^-?[0-9]+$' THEN (row_data -> 'message_id' ->> 'value')::bigint END))) WHERE table_name = 'messages'`,
);
await db.query(
`CREATE INDEX IF NOT EXISTS ${quoteIdentifier(`${kvTable}_message_reactions_message_idx`)} ON ${table} (partition_key, ((CASE WHEN row_data -> 'message_id' ->> 'value' ~ '^-?[0-9]+$' THEN (row_data -> 'message_id' ->> 'value')::bigint END))) WHERE table_name = 'message_reactions'`,
);
await db.query(`
UPDATE ${table}
SET partition_key = split_part(row_key, chr(31), 1) || chr(31) || split_part(row_key, chr(31), 2)
WHERE table_name = 'messages'
AND partition_key = row_key
AND split_part(row_key, chr(31), 3) <> ''`);
await client.query(`DROP INDEX IF EXISTS ${quoteIdentifier(`${client.kvTable()}_partition_idx`)}`);
await db.query(`DROP INDEX IF EXISTS ${quoteIdentifier(`${kvTable}_partition_idx`)}`);
});
}
export async function pruneExpiredPostgresKvRows(client: IPostgresClient, batchSize = 5000): Promise<number> {
@@ -17,6 +17,7 @@ export interface GiftCodeRow {
stripe_payment_intent_id: Nullish<string>;
visionary_sequence_number: Nullish<number>;
checkout_session_id: Nullish<string>;
revoked_at?: Nullish<Date>;
version: number;
}
@@ -103,6 +104,7 @@ export const GIFT_CODE_COLUMNS = [
'stripe_payment_intent_id',
'visionary_sequence_number',
'checkout_session_id',
'revoked_at',
'version',
] as const;
export const GIFT_CODE_BY_CREATOR_COLUMNS = ['created_by_user_id', 'code'] as const;
@@ -0,0 +1,202 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {DesktopArch, DesktopChannel, DesktopPlatform} from '@fluxer/schema/src/domains/download/DownloadSchemas';
import {isJsonRecord} from '../utils/JsonBoundaryUtils';
const DESKTOP_BUCKET_PREFIX = 'desktop';
const MIN_RELEASE_ROUTE_COUNT = 28;
const MAX_RELEASE_ROUTE_COUNT = 128;
const MIN_RELEASE_ASSET_COUNT = 24;
interface DesktopReleaseAsset {
storage_key: string;
release_asset: string;
sha256: string;
size: number;
}
interface DesktopReleaseDescriptor {
schema_version: 1;
channel: DesktopChannel;
version: string;
release_tag: string;
source_sha: string;
assets: Array<DesktopReleaseAsset>;
}
interface DesktopReleaseReadiness {
schema_version: 1;
channel: DesktopChannel;
version: string;
release_tag: string;
source_sha: string;
descriptor_sha256: string;
}
interface DesktopArtifactScope {
channel: DesktopChannel;
plat: DesktopPlatform;
arch: DesktopArch;
}
export function parseDesktopArtifactScope(key: string): DesktopArtifactScope | null {
const segments = key.split('/');
if (segments.length !== 5 || segments[0] !== DESKTOP_BUCKET_PREFIX || segments[4].length === 0) {
return null;
}
const [, channel, plat, arch] = segments;
if (
(channel !== 'stable' && channel !== 'canary') ||
(plat !== 'win32' && plat !== 'darwin' && plat !== 'linux') ||
(arch !== 'x64' && arch !== 'arm64')
) {
return null;
}
return {channel, plat, arch};
}
function parseDesktopReleaseAsset(value: unknown): DesktopReleaseAsset | null {
if (
!isJsonRecord(value) ||
typeof value.storage_key !== 'string' ||
typeof value.release_asset !== 'string' ||
typeof value.sha256 !== 'string' ||
typeof value.size !== 'number'
) {
return null;
}
if (
!/^desktop\/(?:stable|canary)\/(?:win32|darwin|linux)\/(?:x64|arm64)\/[A-Za-z0-9._-]+$/u.test(value.storage_key) ||
!/^[A-Za-z0-9._-]+$/u.test(value.release_asset) ||
!/^[a-f0-9]{64}$/u.test(value.sha256) ||
!Number.isSafeInteger(value.size) ||
value.size <= 0
) {
return null;
}
return {
storage_key: value.storage_key,
release_asset: value.release_asset,
sha256: value.sha256,
size: value.size,
};
}
export function parseDesktopReleaseDescriptor(value: unknown): DesktopReleaseDescriptor | null {
if (
!isJsonRecord(value) ||
value.schema_version !== 1 ||
(value.channel !== 'stable' && value.channel !== 'canary') ||
typeof value.version !== 'string' ||
!/^\d+\.\d+\.\d+$/u.test(value.version) ||
typeof value.release_tag !== 'string' ||
typeof value.source_sha !== 'string' ||
!/^[a-f0-9]{40}$/u.test(value.source_sha) ||
!Array.isArray(value.assets) ||
value.assets.length < MIN_RELEASE_ROUTE_COUNT ||
value.assets.length > MAX_RELEASE_ROUTE_COUNT
) {
return null;
}
const expectedTag = `fluxer-desktop-${value.channel}@${value.version}`;
const expectedStoragePrefix = `desktop/${value.channel}/`;
const expectedReleasePrefix = `${value.channel === 'canary' ? 'Fluxer-Canary' : 'Fluxer'}-${value.version}-`;
const descriptorName = `${expectedReleasePrefix}release-manifest.json`;
if (value.release_tag !== expectedTag) {
return null;
}
const storageKeys = new Set<string>();
const routeCounts = new Map<string, number>();
const releaseAssets = new Map<string, {sha256: string; size: number}>();
const releaseAssetNames = new Map<string, string>([[descriptorName.toLowerCase(), descriptorName]]);
const assets: Array<DesktopReleaseAsset> = [];
for (const rawAsset of value.assets) {
const asset = parseDesktopReleaseAsset(rawAsset);
if (
!asset ||
!asset.storage_key.startsWith(expectedStoragePrefix) ||
!asset.release_asset.startsWith(expectedReleasePrefix) ||
storageKeys.has(asset.storage_key)
) {
return null;
}
storageKeys.add(asset.storage_key);
const [, , platform, arch, filename] = asset.storage_key.split('/');
const platformToken = platform === 'win32' ? 'win' : platform === 'darwin' ? 'mac' : 'linux';
const releaseFilename =
platform === 'darwin' && filename.toLowerCase() === 'releases.json' ? 'releases.json' : filename;
const expectedReleaseAsset = filename.startsWith(expectedReleasePrefix)
? filename
: `${expectedReleasePrefix}${platformToken}-${arch}-${releaseFilename}`;
if (
asset.release_asset !== expectedReleaseAsset ||
asset.release_asset.toLowerCase() === descriptorName.toLowerCase()
) {
return null;
}
const caseFoldedReleaseAsset = asset.release_asset.toLowerCase();
const existingReleaseAssetName = releaseAssetNames.get(caseFoldedReleaseAsset);
if (existingReleaseAssetName && existingReleaseAssetName !== asset.release_asset) {
return null;
}
releaseAssetNames.set(caseFoldedReleaseAsset, asset.release_asset);
const scope = `${platform}/${arch}`;
routeCounts.set(scope, (routeCounts.get(scope) ?? 0) + 1);
const existing = releaseAssets.get(asset.release_asset);
if (existing && (existing.sha256 !== asset.sha256 || existing.size !== asset.size)) {
return null;
}
releaseAssets.set(asset.release_asset, {sha256: asset.sha256, size: asset.size});
assets.push(asset);
}
if (releaseAssets.size < MIN_RELEASE_ASSET_COUNT || releaseAssets.size > MAX_RELEASE_ROUTE_COUNT) {
return null;
}
const expectedRouteCounts = new Map([
['darwin/arm64', 4],
['darwin/x64', 4],
['linux/arm64', 4],
['linux/x64', 4],
['win32/arm64', 6],
['win32/x64', 6],
]);
if (
routeCounts.size !== expectedRouteCounts.size ||
Array.from(expectedRouteCounts).some(([scope, count]) => (routeCounts.get(scope) ?? 0) < count)
) {
return null;
}
return {
schema_version: 1,
channel: value.channel,
version: value.version,
release_tag: value.release_tag,
source_sha: value.source_sha,
assets,
};
}
export function parseDesktopReleaseReadiness(value: unknown): DesktopReleaseReadiness | null {
if (
!isJsonRecord(value) ||
value.schema_version !== 1 ||
(value.channel !== 'stable' && value.channel !== 'canary') ||
typeof value.version !== 'string' ||
!/^\d+\.\d+\.\d+$/u.test(value.version) ||
typeof value.release_tag !== 'string' ||
typeof value.source_sha !== 'string' ||
!/^[a-f0-9]{40}$/u.test(value.source_sha) ||
typeof value.descriptor_sha256 !== 'string' ||
!/^[a-f0-9]{64}$/u.test(value.descriptor_sha256)
) {
return null;
}
return {
schema_version: 1,
channel: value.channel,
version: value.version,
release_tag: value.release_tag,
source_sha: value.source_sha,
descriptor_sha256: value.descriptor_sha256,
};
}
@@ -17,6 +17,7 @@ import {Config} from '../Config';
import {OpenAPI} from '../middleware/ResponseTypeMiddleware';
import type {HonoEnv} from '../types/HonoEnv';
import {Validator} from '../Validator';
import {resolveArtifactRoute} from './DownloadRouting';
import type {DesktopChecksumFile, DownloadService, DownloadStreamResult} from './DownloadService';
import {
DESKTOP_REDIRECT_PREFIX,
@@ -29,6 +30,17 @@ function artifactFilename(key: string, filenameOverride?: string): string {
return filenameOverride ?? key.split('/').pop() ?? 'download';
}
function artifactRedirectResponse(location: string, cacheControl = 'no-store'): Response {
return new Response(null, {
status: 302,
headers: new Headers({
Location: location,
'Cache-Control': cacheControl,
'Accept-Ranges': 'bytes',
}),
});
}
function setCommonArtifactHeaders(
headers: Headers,
key: string,
@@ -87,8 +99,12 @@ async function streamArtifactResponse(
cacheControl: string,
filenameOverride?: string,
): Promise<Response> {
const route = await resolveArtifactRoute({request: ctx.req.raw, downloadService, key, cacheControl});
if (route.kind === 'redirect') {
return artifactRedirectResponse(route.location, route.cacheControl);
}
if (ctx.req.method === 'HEAD') {
return headArtifactResponse(ctx, downloadService, key, cacheControl, filenameOverride);
return headArtifactResponse(ctx, downloadService, key, route.cacheControl, filenameOverride);
}
if (downloadService.isPresignedDownloadEnabled()) {
const location = await downloadService.getPresignedDownloadRedirect({
@@ -99,14 +115,7 @@ async function streamArtifactResponse(
if (!location) {
return ctx.text('Not Found', 404);
}
return new Response(null, {
status: 302,
headers: new Headers({
Location: location,
'Cache-Control': 'no-store',
'Accept-Ranges': 'bytes',
}),
});
return artifactRedirectResponse(location);
}
const range = ctx.req.header('range') ?? undefined;
let result: DownloadStreamResult | null;
@@ -117,7 +126,7 @@ async function streamArtifactResponse(
const headers = new Headers();
headers.set('Accept-Ranges', 'bytes');
headers.set('Content-Range', `bytes */${error.totalSize}`);
headers.set('Cache-Control', cacheControl);
headers.set('Cache-Control', route.cacheControl);
return new Response(null, {status: 416, headers});
}
throw error;
@@ -129,7 +138,7 @@ async function streamArtifactResponse(
setCommonArtifactHeaders(
headers,
key,
cacheControl,
route.cacheControl,
filenameOverride,
result.contentType,
result.contentDisposition,
@@ -0,0 +1,53 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Config} from '../Config';
import {Logger} from '../Logger';
import {lookupGeoip} from '../utils/IpUtils';
import {parseDesktopArtifactScope} from './DesktopReleaseContract';
import type {DownloadService, GitHubDesktopReleaseResolution} from './DownloadService';
const COUNTRY_DEPENDENT_CACHE_CONTROL = 'private, no-store';
type ArtifactRoute =
| {kind: 'storage'; cacheControl: string}
| {kind: 'redirect'; cacheControl: string; location: string};
export async function resolveArtifactRoute(params: {
request: Request;
downloadService: DownloadService;
key: string;
cacheControl: string;
}): Promise<ArtifactRoute> {
if (Config.instance.selfHosted) {
return {kind: 'storage', cacheControl: params.cacheControl};
}
if (Config.desktopGitHubRedirectCountries.size === 0) {
return {kind: 'storage', cacheControl: params.cacheControl};
}
if (!parseDesktopArtifactScope(params.key)) {
return {kind: 'storage', cacheControl: params.cacheControl};
}
const geoip = await lookupGeoip(params.request);
const countryCode = geoip.countryCode?.trim().toUpperCase();
if (!countryCode || !Config.desktopGitHubRedirectCountries.has(countryCode)) {
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
}
let release: GitHubDesktopReleaseResolution;
try {
release = await params.downloadService.resolveGitHubDesktopRelease(params.key);
} catch (error) {
Logger.error({error, key: params.key}, 'Failed to resolve GitHub desktop download route');
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
}
if (release.kind === 'not_current') {
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
}
if (release.kind === 'ready') {
return {
kind: 'redirect',
cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL,
location: release.location,
};
}
return {kind: 'storage', cacheControl: COUNTRY_DEPENDENT_CACHE_CONTROL};
}
+123 -28
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createHash} from 'node:crypto';
import {posix} from 'node:path';
import {Readable} from 'node:stream';
import {S3ServiceException} from '@aws-sdk/client-s3';
@@ -12,6 +13,11 @@ import type {
import {Config} from '../Config';
import type {IStorageService} from '../infrastructure/IStorageService';
import {isJsonRecord, parseJsonUnknown} from '../utils/JsonBoundaryUtils';
import {
parseDesktopArtifactScope,
parseDesktopReleaseDescriptor,
parseDesktopReleaseReadiness,
} from './DesktopReleaseContract';
export const DOWNLOAD_PREFIX = '/dl';
export const DESKTOP_REDIRECT_PREFIX = `${DOWNLOAD_PREFIX}/desktop`;
@@ -45,7 +51,10 @@ function isUnsatisfiableRangeError(error: unknown): boolean {
}
const DESKTOP_BUCKET_PREFIX = 'desktop';
const DESKTOP_TEST_BUCKET_PREFIX = 'desktop-test';
const DOWNLOAD_KEY_ALLOWED_PREFIXES = [`${DESKTOP_BUCKET_PREFIX}/`, `${DESKTOP_TEST_BUCKET_PREFIX}/`];
const DEFAULT_API_CLIENT_BASE_URL = 'https://api.fluxer.app';
const GITHUB_RELEASE_DOWNLOAD_BASE_URL = 'https://github.com/fluxerapp/fluxer/releases/download';
const GITHUB_RELEASE_MARKER_DIRECTORY = 'github-releases';
function desktopBucketPrefix(test?: boolean): string {
return test ? DESKTOP_TEST_BUCKET_PREFIX : DESKTOP_BUCKET_PREFIX;
@@ -176,9 +185,70 @@ interface ManifestFilenameResolutionParams extends LatestFilenameLookupParams {
filename: string;
}
export type GitHubDesktopReleaseResolution =
| {kind: 'not_current'}
| {kind: 'awaiting_release'}
| {kind: 'ready'; location: string};
export class DownloadService {
constructor(private readonly storageService: IStorageService) {}
async resolveGitHubDesktopRelease(key: string): Promise<GitHubDesktopReleaseResolution> {
const scope = parseDesktopArtifactScope(key);
if (!scope) {
return {kind: 'not_current'};
}
const manifestKey = `${DESKTOP_BUCKET_PREFIX}/${scope.channel}/${scope.plat}/${scope.arch}/manifest.json`;
const manifest = await this.readOptionalJsonObjectFromStorage(manifestKey);
if (
!isDesktopManifest(manifest) ||
manifest.channel !== scope.channel ||
manifest.platform !== scope.plat ||
manifest.arch !== scope.arch
) {
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) {
return {kind: 'not_current'};
}
const descriptor = parseDesktopReleaseDescriptor(parseJsonUnknown(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}`);
}
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)}`,
};
}
async resolveLatestDesktopKey(params: {
channel: DesktopChannel;
plat: DesktopPlatform;
@@ -659,6 +729,11 @@ export class DownloadService {
}
private async readJsonObjectFromStorage(key: string): Promise<unknown | null> {
const text = await this.readTextFromStorage(key);
return text == null ? null : parseJsonUnknown(text);
}
private async readTextFromStorage(key: string): Promise<string | null> {
const streamResult = await this.storageService.streamObject({
bucket: Config.s3.buckets.downloads,
key,
@@ -667,8 +742,29 @@ export class DownloadService {
return null;
}
const body = Readable.toWeb(streamResult.body);
const text = await new Response(body as ReadableStream).text();
return parseJsonUnknown(text);
return new Response(body as ReadableStream).text();
}
private async readOptionalJsonObjectFromStorage(key: string): Promise<unknown | null> {
try {
return await this.readJsonObjectFromStorage(key);
} catch (error) {
if (isStorageNotFoundError(error)) {
return null;
}
throw error;
}
}
private async readOptionalTextFromStorage(key: string): Promise<string | null> {
try {
return await this.readTextFromStorage(key);
} catch (error) {
if (isStorageNotFoundError(error)) {
return null;
}
throw error;
}
}
private isValidSha256(value: string): boolean {
@@ -774,9 +870,11 @@ export class DownloadService {
const {ext, arch: archMap} = mapping;
const filenames = new Set<string>();
for (const archSuffix of this.getArchTokens(archMap[arch as 'x64' | 'arm64'])) {
const modernFilename = this.buildModernArtifactFilename(channel, version, plat, archSuffix, ext);
if (modernFilename) {
filenames.add(modernFilename);
for (const productName of this.getModernProductNames(channel)) {
filenames.add(`${productName}-${version}-${MODERN_PLATFORM_TOKENS[plat]}-${archSuffix}${ext}`);
if (format === 'portable') {
filenames.add(`${productName}-${version}-portable-${MODERN_PLATFORM_TOKENS[plat]}-${archSuffix}${ext}`);
}
}
if (format === 'setup') {
filenames.add(`fluxer-${channel}-${version}-${archSuffix}-setup${ext}`);
@@ -784,9 +882,6 @@ export class DownloadService {
filenames.add(`fluxer-${version}-${archSuffix}-setup${ext}`);
filenames.add(`Fluxer-${version}-${archSuffix}-Setup${ext}`);
} else if (format === 'portable') {
filenames.add(
`${this.getModernProductName(channel)}-${version}-portable-${MODERN_PLATFORM_TOKENS[plat]}-${archSuffix}${ext}`,
);
filenames.add(`fluxer-${channel}-${version}-portable-${archSuffix}${ext}`);
filenames.add(`Fluxer-${version}-portable-${archSuffix}${ext}`);
} else {
@@ -816,7 +911,6 @@ export class DownloadService {
}
const {ext, arch: archMap} = mapping;
const escapedExt = this.escapeRegex(ext);
const escapedModernFilenamePrefix = this.escapeRegex(this.getModernProductName(channel));
const modernPlatformToken = MODERN_PLATFORM_TOKENS[plat];
for (const archSuffix of this.getArchTokens(archMap[arch as 'x64' | 'arm64'])) {
const patterns = [
@@ -828,18 +922,23 @@ export class DownloadService {
`^[Ff]luxer-(\\d+\\.\\d+\\.\\d+)-${this.escapeRegex(archSuffix)}(?:-[Ss]etup)?${escapedExt}$`,
'u',
),
new RegExp(
`^${escapedModernFilenamePrefix}-(\\d+\\.\\d+\\.\\d+)-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
'iu',
),
];
if (format === 'portable') {
for (const productName of this.getModernProductNames(channel)) {
const escapedProductName = this.escapeRegex(productName);
patterns.push(
new RegExp(
`^${escapedModernFilenamePrefix}-(\\d+\\.\\d+\\.\\d+)-portable-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
`^${escapedProductName}-(\\d+\\.\\d+\\.\\d+)-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
'iu',
),
);
if (format === 'portable') {
patterns.push(
new RegExp(
`^${escapedProductName}-(\\d+\\.\\d+\\.\\d+)-portable-${this.escapeRegex(modernPlatformToken)}-${this.escapeRegex(archSuffix)}${escapedExt}$`,
'iu',
),
);
}
}
for (const pattern of patterns) {
const match = filename.match(pattern);
@@ -852,18 +951,8 @@ export class DownloadService {
return null;
}
private getModernProductName(channel: DesktopChannel): string {
return channel === 'canary' ? 'Fluxer Canary' : 'Fluxer';
}
private buildModernArtifactFilename(
channel: DesktopChannel,
version: string,
plat: DesktopPlatform,
archToken: string,
ext: string,
): string {
return `${this.getModernProductName(channel)}-${version}-${MODERN_PLATFORM_TOKENS[plat]}-${archToken}${ext}`;
private getModernProductNames(channel: DesktopChannel): Array<string> {
return channel === 'canary' ? ['Fluxer-Canary', 'Fluxer Canary'] : ['Fluxer'];
}
private getArchTokens(archToken: string | Array<string>): Array<string> {
@@ -899,7 +988,13 @@ export class DownloadService {
return null;
}
}
return normalized.length > 0 ? normalized : null;
if (normalized.length === 0) {
return null;
}
if (!DOWNLOAD_KEY_ALLOWED_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
return null;
}
return normalized;
}
private normalizePlatformArchKey(key: string): string | null {
@@ -2,6 +2,7 @@
import type {MemeID, UserID} from '../BrandedTypes';
import {BatchBuilder, fetchMany, fetchOne, upsertOne} from '../database/CassandraQueryExecution';
import {Db} from '../database/CassandraTypes';
import type {FavoriteMemeRow} from '../database/types/UserTypes';
import {FavoriteMeme} from '../models/FavoriteMeme';
import {FavoriteMemes, FavoriteMemesByMemeId} from '../Tables';
@@ -99,6 +100,10 @@ export class FavoriteMemeRepository extends IFavoriteMemeRepository {
return new FavoriteMeme(memeRow);
}
async updatePlaceholder(userId: UserID, memeId: MemeID, placeholder: string): Promise<void> {
await fetchOne(FavoriteMemes.patchByPk({user_id: userId, meme_id: memeId}, {placeholder: Db.set(placeholder)}));
}
async delete(userId: UserID, memeId: MemeID): Promise<void> {
const batch = new BatchBuilder();
batch.addPrepared(FavoriteMemes.deleteByPk({user_id: userId, meme_id: memeId}));
@@ -34,6 +34,8 @@ export abstract class IFavoriteMemeRepository {
abstract update(userId: UserID, memeId: MemeID, data: CreateFavoriteMemeParams): Promise<FavoriteMeme>;
abstract updatePlaceholder(userId: UserID, memeId: MemeID, placeholder: string): Promise<void>;
abstract delete(userId: UserID, memeId: MemeID): Promise<void>;
abstract deleteAllByUserId(userId: UserID): Promise<void>;
@@ -262,7 +262,7 @@ export function GuildMemberController(app: HonoApp) {
const userId = ctx.get('user').id;
const targetId = createUserID(user_id);
const guildId = createGuildID(guild_id);
const {delete_message_days, reason, ban_duration_seconds} = ctx.req.valid('json');
const {delete_message_days, delete_message_seconds, reason, ban_duration_seconds} = ctx.req.valid('json');
const auditLogReason = ctx.get('auditLogReason') ?? null;
const effectiveReason = reason ?? auditLogReason ?? undefined;
await ctx.get('guildService').moderation.banMember(
@@ -271,6 +271,7 @@ export function GuildMemberController(app: HonoApp) {
guildId,
targetId,
deleteMessageDays: delete_message_days,
deleteMessageSeconds: delete_message_seconds,
reason: effectiveReason,
banDurationSeconds: ban_duration_seconds,
},
@@ -14,9 +14,11 @@ import type {ISnowflakeService} from '../../infrastructure/ISnowflakeService';
import type {UserCacheService} from '../../infrastructure/UserCacheService';
import type {LimitConfigService} from '../../limits/LimitConfigService';
import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
import type {IUserRepository} from '../../user/IUserRepository';
import type {GuildAuditLogService} from '../GuildAuditLogService';
import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate';
import {ChannelOperationsService} from './channel/ChannelOperationsService';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
export class GuildChannelService {
private readonly channelOps: ChannelOperationsService;
@@ -30,6 +32,7 @@ export class GuildChannelService {
snowflakeService: ISnowflakeService,
guildAuditLogService: GuildAuditLogService,
limitConfigService: LimitConfigService,
private readonly userRepository: IUserRepository,
) {
this.channelOps = new ChannelOperationsService(
channelRepository,
@@ -131,5 +134,12 @@ export class GuildChannelService {
permission: params.permission,
});
if (!hasPermission) throw new MissingPermissionsError();
const guildData = await this.gatewayService.getGuildData({guildId: params.guildId, userId: params.userId});
const enforceGuildMfa = await createGuildMfaEnforcer({
userRepository: this.userRepository,
guildData,
userId: params.userId,
});
enforceGuildMfa(params.permission);
}
}
@@ -51,7 +51,7 @@ export class GuildDataService {
private readonly guildAuditLogService: GuildAuditLogService,
private readonly limitConfigService: LimitConfigService,
) {
this.helpers = new GuildDataHelpers(this.gatewayService, this.guildAuditLogService);
this.helpers = new GuildDataHelpers(this.gatewayService, this.guildAuditLogService, this.userRepository);
this.operationsService = new GuildOperationsService(
this.guildRepository,
this.channelRepository,
@@ -50,7 +50,7 @@ export class GuildMemberService {
ipInfoService: IpInfoService,
) {
this.userRepository = userRepository;
this.authService = new GuildMemberAuthService(gatewayService);
this.authService = new GuildMemberAuthService(gatewayService, userRepository);
this.validationService = new GuildMemberValidationService(guildRepository, userRepository, ipInfoService);
this.auditService = new GuildMemberAuditService(guildAuditLogService);
this.eventService = new GuildMemberEventService(gatewayService, userCacheService);
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {UserID} from '../../BrandedTypes';
import type {IUserRepository} from '../../user/IUserRepository';
const ELEVATED_MFA_PERMISSIONS =
Permissions.KICK_MEMBERS |
Permissions.BAN_MEMBERS |
Permissions.ADMINISTRATOR |
Permissions.MANAGE_CHANNELS |
Permissions.MANAGE_GUILD |
Permissions.MANAGE_MESSAGES |
Permissions.MANAGE_ROLES |
Permissions.MANAGE_WEBHOOKS |
Permissions.MODERATE_MEMBERS;
export async function createGuildMfaEnforcer(params: {
userRepository: IUserRepository;
guildData: Pick<GuildResponse, 'mfa_level' | 'owner_id'>;
userId: UserID;
}): Promise<(permission: bigint) => void> {
const {userRepository, guildData, userId} = params;
const requiresGuildMfa = guildData.mfa_level === GuildMFALevel.ELEVATED && guildData.owner_id !== userId.toString();
let actorLacksMfa = false;
if (requiresGuildMfa) {
const actor = await userRepository.findUnique(userId);
actorLacksMfa = !actor || actor.authenticatorTypes.size === 0;
}
return (permission: bigint) => {
if (requiresGuildMfa && actorLacksMfa && (permission & ELEVATED_MFA_PERMISSIONS) !== 0n) {
throw new MfaNotEnabledError();
}
};
}
@@ -27,8 +27,11 @@ import type {GuildAuditLogService} from '../GuildAuditLogService';
import type {GuildAuditLogChange} from '../GuildAuditLogTypes';
import {mapGuildBansToResponse} from '../GuildModel';
import type {IGuildRepositoryAggregate} from '../repositories/IGuildRepositoryAggregate';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
import {GuildMemberSearchIndexService} from './member/GuildMemberSearchIndexService';
const SECONDS_PER_DAY = 86_400;
export class GuildModerationService {
private readonly searchIndexService: GuildMemberSearchIndexService;
@@ -44,25 +47,43 @@ export class GuildModerationService {
this.searchIndexService = new GuildMemberSearchIndexService();
}
private async checkModerationPermission(params: {
guildId: GuildID;
userId: UserID;
permission: bigint;
}): Promise<void> {
const {guildId, userId, permission} = params;
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
const guildData = await this.gatewayService.getGuildData({guildId, userId});
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
enforceGuildMfa(permission);
}
async banMember(
params: {
userId: UserID;
targetId: UserID;
guildId: GuildID;
deleteMessageDays?: number;
deleteMessageSeconds?: number;
reason?: string | null;
banDurationSeconds?: number;
skipGuildAuditLog?: boolean;
},
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, targetId, deleteMessageDays, reason, banDurationSeconds, skipGuildAuditLog} = params;
const hasPermission = await this.gatewayService.checkPermission({
guildId,
const {
userId,
permission: Permissions.BAN_MEMBERS,
});
if (!hasPermission) throw new MissingPermissionsError();
guildId,
targetId,
deleteMessageDays,
deleteMessageSeconds,
reason,
banDurationSeconds,
skipGuildAuditLog,
} = params;
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
if (userId === targetId) throw new UnknownGuildMemberError();
const targetUser = await this.userRepository.findUnique(targetId);
if (!targetUser) {
@@ -73,11 +94,13 @@ export class GuildModerationService {
const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId: targetId});
if (!canManage) throw new MissingPermissionsError();
}
if (deleteMessageDays && deleteMessageDays > 0) {
const effectiveDeleteMessageSeconds =
deleteMessageSeconds ?? (deleteMessageDays !== undefined ? deleteMessageDays * SECONDS_PER_DAY : undefined);
if (effectiveDeleteMessageSeconds && effectiveDeleteMessageSeconds > 0) {
await this.workerService.addJob('deleteUserMessagesInGuildByTime', {
guildId: guildId.toString(),
userId: targetId.toString(),
days: deleteMessageDays,
seconds: effectiveDeleteMessageSeconds,
});
}
const targetIp = isIpBanExempt(targetUser.lastActiveIp) ? null : targetUser.lastActiveIp || null;
@@ -145,12 +168,7 @@ export class GuildModerationService {
requestCache: RequestCache;
}): Promise<Array<GuildBanResponse>> {
const {userId, guildId, requestCache} = params;
const hasPermission = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.BAN_MEMBERS,
});
if (!hasPermission) throw new MissingPermissionsError();
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
const bans = await this.guildRepository.listBans(guildId);
return await mapGuildBansToResponse(bans, this.userCacheService, requestCache);
}
@@ -164,12 +182,7 @@ export class GuildModerationService {
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, targetId} = params;
const hasPermission = await this.gatewayService.checkPermission({
guildId,
userId,
permission: Permissions.BAN_MEMBERS,
});
if (!hasPermission) throw new MissingPermissionsError();
await this.checkModerationPermission({guildId, userId, permission: Permissions.BAN_MEMBERS});
const ban = await this.guildRepository.getBan(guildId, targetId);
if (!ban) {
throw InputValidationError.fromCode('user_id', ValidationErrorCodes.USER_IS_NOT_BANNED);
@@ -26,6 +26,7 @@ import type {LimitConfigService} from '../../limits/LimitConfigService';
import {resolveLimitSafe} from '../../limits/LimitConfigUtils';
import {createLimitMatchContext} from '../../limits/LimitMatchContextBuilder';
import {GuildRole} from '../../models/GuildRole';
import type {IUserRepository} from '../../user/IUserRepository';
import {applyProtectedRolePermissions} from '../../utils/featureUtils';
import {computePermissionsDiff} from '../../utils/PermissionUtils';
import type {GuildAuditLogService} from '../GuildAuditLogService';
@@ -33,6 +34,7 @@ import type {GuildAuditLogChange} from '../GuildAuditLogTypes';
import {mapGuildRoleToResponse} from '../GuildModel';
import type {IGuildMemberRepository} from '../repositories/IGuildMemberRepository';
import type {IGuildRoleRepository} from '../repositories/IGuildRoleRepository';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
interface GuildRoleRepository extends IGuildRoleRepository, IGuildMemberRepository {}
@@ -62,6 +64,7 @@ export class GuildRoleService {
private readonly gatewayService: IGatewayService,
private readonly guildAuditLogService: GuildAuditLogService,
private readonly limitConfigService: LimitConfigService,
private readonly userRepository: IUserRepository,
) {}
async systemCreateRole(params: {
@@ -305,7 +308,7 @@ export class GuildRoleService {
position?: number;
}>;
},
_auditLogReason?: string | null,
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, updates} = params;
const {checkPermission} = await this.getGuildAuthenticated({userId, guildId});
@@ -316,7 +319,7 @@ export class GuildRoleService {
throw new ResourceLockedError();
}
try {
await this.updateRolePositionsByList({userId, guildId, updates});
await this.updateRolePositionsByList({userId, guildId, updates, auditLogReason: auditLogReason ?? null});
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
}
@@ -342,7 +345,7 @@ export class GuildRoleService {
hoistPosition: number;
}>;
},
_auditLogReason?: string | null,
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId, updates} = params;
const {checkPermission, guildData} = await this.getGuildAuthenticated({userId, guildId});
@@ -400,6 +403,7 @@ export class GuildRoleService {
}
if (changedRoles.length > 0) {
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
}
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
@@ -411,7 +415,7 @@ export class GuildRoleService {
userId: UserID;
guildId: GuildID;
},
_auditLogReason?: string | null,
auditLogReason?: string | null,
): Promise<void> {
const {userId, guildId} = params;
const {checkPermission} = await this.getGuildAuthenticated({userId, guildId});
@@ -423,6 +427,7 @@ export class GuildRoleService {
}
try {
const allRoles = await this.guildRepository.listRoles(guildId);
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
const changedRoles: Array<GuildRole> = [];
for (const role of allRoles) {
if (role.hoistPosition === null) continue;
@@ -438,6 +443,7 @@ export class GuildRoleService {
}
if (changedRoles.length > 0) {
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
}
} finally {
await this.cacheService.releaseLock(lockKey, lockToken);
@@ -446,9 +452,11 @@ export class GuildRoleService {
private async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
enforceGuildMfa(permission);
};
const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId});
return {
@@ -554,8 +562,9 @@ export class GuildRoleService {
roleId: RoleID;
position?: number;
}>;
auditLogReason?: string | null;
}): Promise<void> {
const {userId, guildId, updates} = params;
const {userId, guildId, updates, auditLogReason} = params;
const {guildData} = await this.getGuildAuthenticated({userId, guildId});
const allRoles = await this.guildRepository.listRoles(guildId);
const roleMap = new Map(allRoles.map((r) => [r.id, r]));
@@ -620,6 +629,7 @@ export class GuildRoleService {
});
if (changedRoles.length > 0) {
await this.dispatchGuildRoleUpdateBulk({guildId, roles: changedRoles});
await this.recordRolePositionAuditLogs({guildId, userId, roleMap, changedRoles, auditLogReason});
}
}
@@ -694,6 +704,30 @@ export class GuildRoleService {
return newRoles;
}
private async recordRolePositionAuditLogs(params: {
guildId: GuildID;
userId: UserID;
roleMap: Map<RoleID, GuildRole>;
changedRoles: Array<GuildRole>;
auditLogReason?: string | null;
}): Promise<void> {
const {guildId, userId, roleMap, changedRoles, auditLogReason} = params;
for (const role of changedRoles) {
const oldRole = roleMap.get(role.id);
await this.recordAuditLog({
guildId,
userId,
action: AuditLogActionType.ROLE_UPDATE,
targetId: role.id,
auditLogReason: auditLogReason ?? null,
changes: this.guildAuditLogService.computeChanges(
oldRole ? this.serializeRoleForAudit(oldRole) : null,
this.serializeRoleForAudit(role),
),
});
}
}
private serializeRoleForAudit(role: GuildRole): Record<string, unknown> {
return {
role_id: role.id.toString(),
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildNSFWLevel} from '@fluxer/constants/src/GuildConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
@@ -22,6 +22,7 @@ import {buildMessageSearchFilters} from '../../search/BuildMessageSearchFilters'
import {channelNeedsReindexing} from '../../search/ChannelIndexingUtils';
import {MessageSearchResponseMapper} from '../../search/MessageSearchResponseMapper';
import {searchExistingMessages} from '../../search/MessageSearchResultReconciler';
import {channelRequiresAgeVerification} from '../../search/SearchNsfwUtils';
import type {IUserRepository} from '../../user/IUserRepository';
import {canUserAccessNsfwContent} from '../../utils/AgeUtils';
import {mapWithConcurrency} from '../../utils/ConcurrencyUtils';
@@ -84,6 +85,7 @@ export class GuildSearchService {
}
}
const canIncludeNsfw = includeNsfwRequested && canUserAccessNsfw;
const guildNsfw = guildData?.nsfw ?? false;
const channels = await this.channelRepository.listChannels(channelIds);
const channelMap = new Map<string, Channel>();
for (const channel of channels) {
@@ -96,9 +98,10 @@ export class GuildSearchService {
throw InputValidationError.fromCode('channel_ids', ValidationErrorCodes.ALL_CHANNELS_MUST_BELONG_TO_GUILD);
}
}
const categoryLookup = await this.buildParentCategoryLookup(channelMap);
const nsfwFilteredIds = channelIds.filter((id) => {
const channel = channelMap.get(id.toString())!;
return !(channel.isNsfw && !canIncludeNsfw);
return !(channelRequiresAgeVerification(channel, categoryLookup, guildNsfw) && !canIncludeNsfw);
});
const permissionResults = await mapWithConcurrency(nsfwFilteredIds, PERMISSION_CHECK_CONCURRENCY, (channelId) =>
this.gatewayService.checkPermission({
@@ -188,7 +191,7 @@ export class GuildSearchService {
if (!searchService) {
throw new FeatureTemporarilyDisabledError();
}
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels} =
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories} =
await this.collectAccessibleGuildChannels(userId);
if (unindexedChannelIds.size > 0) {
await this.queueIndexingChannels(unindexedChannelIds);
@@ -219,7 +222,7 @@ export class GuildSearchService {
if (guildIsAgeRestricted) {
return canIncludeNsfw;
}
if (channel.isNsfw) {
if (channelRequiresAgeVerification(channel, parentCategories, false)) {
return canIncludeNsfw;
}
return true;
@@ -259,6 +262,24 @@ export class GuildSearchService {
};
}
private async buildParentCategoryLookup(channelMap: Map<string, Channel>): Promise<Map<string, Channel>> {
const lookup = new Map<string, Channel>(channelMap);
const missingParentIds: Array<ChannelID> = [];
for (const channel of channelMap.values()) {
const parentId = channel.parentId;
if (parentId != null && !lookup.has(parentId.toString())) {
missingParentIds.push(parentId);
}
}
if (missingParentIds.length > 0) {
const parents = await this.channelRepository.listChannels(missingParentIds);
for (const parent of parents) {
lookup.set(parent.id.toString(), parent);
}
}
return lookup;
}
private async getCanUserAccessNsfw(userId: UserID): Promise<boolean> {
const user = await this.userRepository.findUnique(userId);
if (!user) {
@@ -284,11 +305,13 @@ export class GuildSearchService {
accessibleChannels: Map<string, Channel>;
unindexedChannelIds: Set<string>;
guildNsfwLevels: Map<string, number>;
parentCategories: Map<string, Channel>;
}> {
const guildIds = await this.userRepository.getUserGuildIds(userId);
const accessibleChannels = new Map<string, Channel>();
const unindexedChannelIds = new Set<string>();
const guildNsfwLevels = new Map<string, number>();
const parentCategories = new Map<string, Channel>();
const permissionChecks: Array<{
channel: Channel;
guildId: GuildID;
@@ -304,6 +327,9 @@ export class GuildSearchService {
}
const viewableChannelIds = new Set(viewableChannels.map((channelId) => channelId.toString()));
for (const channel of guildChannels) {
if (channel.type === ChannelTypes.GUILD_CATEGORY) {
parentCategories.set(channel.id.toString(), channel);
}
if (viewableChannelIds.has(channel.id.toString())) {
permissionChecks.push({channel, guildId});
}
@@ -331,6 +357,6 @@ export class GuildSearchService {
unindexedChannelIds.add(channelIdStr);
}
}
return {accessibleChannels, unindexedChannelIds, guildNsfwLevels};
return {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories};
}
}
@@ -2,9 +2,8 @@
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
import {GuildFeatures, GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {MissingAccessError} from '@fluxer/errors/src/domains/core/MissingAccessError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
@@ -44,6 +43,7 @@ import {GuildChannelService} from './GuildChannelService';
import {GuildContentService} from './GuildContentService';
import {GuildDataService} from './GuildDataService';
import {GuildMemberService} from './GuildMemberService';
import {createGuildMfaEnforcer} from './GuildMfaEnforcement';
import {GuildModerationService} from './GuildModerationService';
import {GuildRoleService} from './GuildRoleService';
import {GuildSearchService} from './GuildSearchService';
@@ -95,17 +95,6 @@ interface GuildAuth {
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
}
const ELEVATED_MFA_PERMISSIONS =
Permissions.KICK_MEMBERS |
Permissions.BAN_MEMBERS |
Permissions.ADMINISTRATOR |
Permissions.MANAGE_CHANNELS |
Permissions.MANAGE_GUILD |
Permissions.MANAGE_MESSAGES |
Permissions.MANAGE_ROLES |
Permissions.MANAGE_WEBHOOKS |
Permissions.MODERATE_MEMBERS;
export class GuildService {
public readonly data: GuildDataService;
public readonly members: GuildMemberService;
@@ -182,6 +171,7 @@ export class GuildService {
gatewayService,
guildAuditLogService,
limitConfigService,
userRepository,
);
this.moderation = new GuildModerationService(
guildRepository,
@@ -211,6 +201,7 @@ export class GuildService {
snowflakeService,
guildAuditLogService,
limitConfigService,
userRepository,
);
this.search = new GuildSearchService(
channelRepository,
@@ -561,17 +552,7 @@ export class GuildService {
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
if (!guildData) throw new MissingAccessError();
const requiresGuildMfa = guildData.mfa_level === GuildMFALevel.ELEVATED && guildData.owner_id !== userId.toString();
let actorLacksMfa = false;
if (requiresGuildMfa) {
const actor = await this.userRepository.findUnique(userId);
actorLacksMfa = !actor || actor.authenticatorTypes.size === 0;
}
const enforceGuildMfa = (permission: bigint) => {
if (requiresGuildMfa && actorLacksMfa && (permission & ELEVATED_MFA_PERMISSIONS) !== 0n) {
throw new MfaNotEnabledError();
}
};
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
@@ -355,12 +355,18 @@ export class ChannelOperationsService {
requestCache,
});
if (update.lockPermissions && desiredParent && desiredParent !== (target.parentId ?? null)) {
await this.syncPermissionsWithParent({guildId, channelId: target.id, parentId: desiredParent});
await this.syncPermissionsWithParent({
guildId,
userId: params.userId,
channelId: target.id,
parentId: desiredParent,
});
}
}
private async syncPermissionsWithParent(params: {
guildId: GuildID;
userId: UserID;
channelId: ChannelID;
parentId: ChannelID;
}): Promise<void> {
@@ -368,6 +374,22 @@ export class ChannelOperationsService {
if (!parent || parent.guildId !== params.guildId || parent.type !== ChannelTypes.GUILD_CATEGORY) return;
const child = await this.channelRepository.findUnique(params.channelId);
if (!child || child.guildId !== params.guildId) return;
const userPermissions = await this.gatewayService.getUserPermissions({
guildId: params.guildId,
userId: params.userId,
channelId: child.id,
});
if ((userPermissions & Permissions.MANAGE_ROLES) === 0n) {
throw new MissingPermissionsError();
}
for (const [targetId, existing] of child.permissionOverwrites) {
const incomingDeny = parent.permissionOverwrites.get(targetId)?.deny ?? 0n;
if ((existing.deny & ~incomingDeny & ~userPermissions) !== 0n) throw new MissingPermissionsError();
}
for (const [targetId, incoming] of parent.permissionOverwrites) {
const existingAllow = child.permissionOverwrites.get(targetId)?.allow ?? 0n;
if ((incoming.allow & ~existingAllow & ~userPermissions) !== 0n) throw new MissingPermissionsError();
}
await this.channelRepository.upsert({
...child.toRow(),
permission_overwrites: new Map(
@@ -8,12 +8,14 @@ import type {ChannelID, EmojiID, GuildID, RoleID, StickerID, UserID} from '../..
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
import {Logger} from '../../../Logger';
import type {Guild} from '../../../models/Guild';
import type {IUserRepository} from '../../../user/IUserRepository';
import {serializeGuildForAudit as serializeGuildForAuditUtil} from '../../../utils/AuditSerializationUtils';
import {requirePermission} from '../../../utils/PermissionUtils';
import type {GuildAuditLogService} from '../../GuildAuditLogService';
import type {GuildAuditLogChange} from '../../GuildAuditLogTypes';
import {mapGuildToGuildResponse} from '../../GuildModel';
import {GuildRepository} from '../../repositories/GuildRepository';
import {createGuildMfaEnforcer} from '../GuildMfaEnforcement';
interface GuildAuth {
guildData: GuildResponse;
@@ -24,6 +26,7 @@ export class GuildDataHelpers {
constructor(
private readonly gatewayService: IGatewayService,
private readonly guildAuditLogService: GuildAuditLogService,
private readonly userRepository: IUserRepository,
) {}
private readonly guildRepository = new GuildRepository();
@@ -33,7 +36,7 @@ export class GuildDataHelpers {
try {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
if (!guildData) throw new UnknownGuildError();
return this.createGuildAuth({guildData, guildId, userId});
return await this.createGuildAuth({guildData, guildId, userId});
} catch (error) {
if (error instanceof UnknownGuildError && (await this.guildExists(guildId))) {
throw new AccessDeniedError();
@@ -42,10 +45,16 @@ export class GuildDataHelpers {
}
}
private createGuildAuth(params: {guildData: GuildResponse; guildId: GuildID; userId: UserID}): GuildAuth {
private async createGuildAuth(params: {
guildData: GuildResponse;
guildId: GuildID;
userId: UserID;
}): Promise<GuildAuth> {
const {guildData, guildId, userId} = params;
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
await requirePermission(this.gatewayService, {guildId, userId, permission});
enforceGuildMfa(permission);
};
return {guildData, checkPermission};
}
@@ -57,7 +57,7 @@ import {Guild} from '../../../models/Guild';
import type {User} from '../../../models/User';
import {getGuildSearchService} from '../../../SearchFactory';
import type {GuildDiscoveryContext} from '../../../search/guild/GuildSearchSerializer';
import {deleteGuildMessageSearchDocuments} from '../../../search/MessageSearchIndexCleanup';
import {deleteChannelMessageSearchDocuments} from '../../../search/MessageSearchIndexCleanup';
import {Channels, ChannelsByGuild, GuildMembers, GuildMembersByUserId, GuildRoles, Guilds} from '../../../Tables';
import type {IUserRepository} from '../../../user/IUserRepository';
import {mapUserSettingsToResponse} from '../../../user/UserMappers';
@@ -858,7 +858,11 @@ export class GuildOperationsService {
await Promise.all(webhooks.map((webhook) => this.webhookRepository.delete(webhook.id)));
const channels = await this.channelRepository.listGuildChannels(guildId);
await Promise.all(channels.map((channel) => this.channelRepository.deleteAllChannelMessages(channel.id)));
await deleteGuildMessageSearchDocuments(guildId, {context: {source: 'guild_delete'}});
await Promise.all(
channels.map((channel) =>
deleteChannelMessageSearchDocuments(channel.id, {context: {source: 'guild_delete'}}),
),
);
await Promise.all(channels.map((channel) => this.channelService.attachments.purgeChannelAttachments(channel)));
const discoveryRow = await this.discoveryRepository.findByGuildId(guildId);
if (discoveryRow) {
@@ -5,6 +5,8 @@ import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPe
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import type {GuildID, RoleID, UserID} from '../../../BrandedTypes';
import type {IGatewayService} from '../../../infrastructure/IGatewayService';
import type {IUserRepository} from '../../../user/IUserRepository';
import {createGuildMfaEnforcer} from '../GuildMfaEnforcement';
interface GuildAuth {
guildData: GuildResponse;
@@ -16,22 +18,30 @@ interface GuildAuth {
}
export class GuildMemberAuthService {
constructor(private readonly gatewayService: IGatewayService) {}
constructor(
private readonly gatewayService: IGatewayService,
private readonly userRepository: IUserRepository,
) {}
async getGuildAuthenticated({userId, guildId}: {userId: UserID; guildId: GuildID}): Promise<GuildAuth> {
const guildData = await this.gatewayService.getGuildData({guildId, userId});
if (!guildData) throw new MissingAccessError();
const enforceGuildMfa = await createGuildMfaEnforcer({userRepository: this.userRepository, guildData, userId});
const checkPermission = async (permission: bigint) => {
const hasPermission = await this.gatewayService.checkPermission({guildId, userId, permission});
if (!hasPermission) throw new MissingPermissionsError();
enforceGuildMfa(permission);
};
const checkTargetMember = async (targetUserId: UserID) => {
const canManage = await this.gatewayService.checkTargetMember({guildId, userId, targetUserId});
if (!canManage) throw new MissingPermissionsError();
};
const getMyPermissions = async () => this.gatewayService.getUserPermissions({guildId, userId});
const hasPermission = async (permission: bigint) =>
this.gatewayService.checkPermission({guildId, userId, permission});
const hasPermission = async (permission: bigint) => {
const allowed = await this.gatewayService.checkPermission({guildId, userId, permission});
if (allowed) enforceGuildMfa(permission);
return allowed;
};
const canManageRoles = async (targetUserId: UserID, targetRoleId: RoleID) =>
this.gatewayService.canManageRoles({guildId, userId, targetUserId, roleId: targetRoleId});
return {
@@ -42,7 +42,7 @@ export class GuildMemberRoleService {
requestCache: RequestCache;
}): Promise<void> {
const {userId, targetId, guildId, roleId} = params;
const {guildData, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
const {guildData, hasPermission, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
const targetMember = await this.guildRepository.getMember(guildId, targetId);
if (!targetMember) throw new UnknownGuildMemberError();
await this.validationService.validateRoleAssignment({
@@ -51,6 +51,7 @@ export class GuildMemberRoleService {
userId,
targetId,
roleId,
hasPermission,
canManageRoles,
});
if (targetMember.roleIds.has(roleId)) return;
@@ -75,7 +76,7 @@ export class GuildMemberRoleService {
requestCache: RequestCache;
}): Promise<void> {
const {userId, targetId, guildId, roleId} = params;
const {guildData, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
const {guildData, hasPermission, canManageRoles} = await this.authService.getGuildAuthenticated({userId, guildId});
const targetMember = await this.guildRepository.getMember(guildId, targetId);
if (!targetMember) throw new UnknownGuildMemberError();
await this.validationService.validateRoleAssignment({
@@ -84,6 +85,7 @@ export class GuildMemberRoleService {
userId,
targetId,
roleId,
hasPermission,
canManageRoles,
});
if (!targetMember.roleIds.has(roleId)) return;
@@ -74,9 +74,10 @@ export class GuildMemberValidationService {
userId: UserID;
targetId: UserID;
roleId: RoleID;
hasPermission: (permission: bigint) => Promise<boolean>;
canManageRoles: (targetUserId: UserID, targetRoleId: RoleID) => Promise<boolean>;
}): Promise<void> {
const {guildData, guildId, userId, targetId, roleId, canManageRoles} = params;
const {guildData, guildId, userId, targetId, roleId, hasPermission, canManageRoles} = params;
ensureNotEveryoneRole(roleId, guildId, 'role_id');
if (guildData && guildData.owner_id === userId.toString()) {
const role = await this.guildRepository.getRole(roleId, guildId);
@@ -84,6 +85,9 @@ export class GuildMemberValidationService {
throw new UnknownGuildRoleError();
}
} else {
if (!(await hasPermission(Permissions.MANAGE_ROLES))) {
throw new MissingPermissionsError();
}
if (!(await canManageRoles(targetId, roleId))) {
throw new MissingPermissionsError();
}
@@ -91,6 +91,7 @@ export interface IStorageService {
key: string;
uploadId: string;
partNumber: number;
contentLength?: number;
expiresIn?: number;
}): Promise<string>;
purgeBucket(bucket: string): Promise<void>;
@@ -60,7 +60,7 @@ export class KVAccountDeletionQueueService {
}
async rebuildState(): Promise<void> {
Logger.info('Starting deletion queue rebuild from Cassandra');
Logger.info('Starting deletion queue rebuild from primary database');
try {
await this.kvClient.del(QUEUE_KEY);
await this.kvClient.del(STATE_VERSION_KEY);
@@ -109,6 +109,18 @@ function createRoomServiceClient(endpoint: string, apiKey: string, apiSecret: st
return client;
}
function resolveRoomServiceEndpoint(server: VoiceServerRecord): string {
const defaultRegion = Config.voice.defaultRegion;
const internalUrl = Config.voice.internalUrl;
if (!defaultRegion || !internalUrl) {
return server.endpoint;
}
if (server.regionId !== defaultRegion.id || server.serverId !== `${defaultRegion.id}-server-1`) {
return server.endpoint;
}
return internalUrl;
}
export class LiveKitService extends ILiveKitService {
private serverClients: Map<string, Map<string, ServerClientConfig>> = new Map();
private topology: VoiceTopology;
@@ -453,12 +465,13 @@ export class LiveKitService extends ILiveKitService {
const servers = this.topology.getServersForRegion(region.id);
const serverMap: Map<string, ServerClientConfig> = new Map();
for (const server of servers) {
const roomServiceEndpoint = resolveRoomServiceEndpoint(server);
serverMap.set(server.serverId, {
endpoint: server.endpoint,
apiKey: server.apiKey,
apiSecret: server.apiSecret,
isActive: server.isActive,
roomServiceClient: createRoomServiceClient(server.endpoint, server.apiKey, server.apiSecret),
roomServiceClient: createRoomServiceClient(roomServiceEndpoint, server.apiKey, server.apiSecret),
});
}
newMap.set(region.id, serverMap);
@@ -296,12 +296,14 @@ export class StorageService implements IStorageService {
key,
uploadId,
partNumber,
contentLength,
expiresIn = seconds('1 hour'),
}: {
bucket: string;
key: string;
uploadId: string;
partNumber: number;
contentLength?: number;
expiresIn?: number;
}): Promise<string> {
const command = new UploadPartCommand({
@@ -309,6 +311,7 @@ export class StorageService implements IStorageService {
Key: key,
UploadId: uploadId,
PartNumber: partNumber,
ContentLength: contentLength,
});
return getSignedUrl(this.presignClient, command, {expiresIn});
}
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import {CaptchaRequiredError, InvalidCaptchaError} from '@fluxer/errors/src/CaptchaErrors';
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {createCaptchaProvider} from '@pkgs/captcha/src/CaptchaProviderFactory';
@@ -53,7 +54,9 @@ export async function verifyCaptchaToken(ctx: Context<HonoEnv>): Promise<void> {
if (!captchaConfig.enabled && !(Config.dev.testModeEnabled && Config.captcha.enabled)) return;
const user = ctx.get('user') as User | undefined;
if (accountPolicyContactHasCapability(user?.email, 'captcha_exempt')) return;
if (userHasCaptchaExemptFlag(user)) return;
if (await requestContactHasCaptchaExemption(ctx.req.raw)) return;
if (await requestUserHasCaptchaExemptFlag(ctx)) return;
const token = ctx.req.header('x-captcha-token');
if (!token) {
throw new CaptchaRequiredError();
@@ -83,6 +86,23 @@ async function requestContactHasCaptchaExemption(request: Request): Promise<bool
}
}
function userHasCaptchaExemptFlag(user: User | null | undefined): boolean {
return user != null && (user.flags & UserFlags.APP_STORE_REVIEWER) !== 0n;
}
async function requestUserHasCaptchaExemptFlag(ctx: Context<HonoEnv>): Promise<boolean> {
try {
const body = (await ctx.req.raw.clone().json()) as unknown;
if (!body || typeof body !== 'object' || Array.isArray(body)) return false;
const email = (body as Record<string, unknown>).email;
if (typeof email !== 'string') return false;
const user = await ctx.get('userRepository').findByEmail(email);
return userHasCaptchaExemptFlag(user);
} catch {
return false;
}
}
export const CaptchaMiddleware = createMiddleware<HonoEnv>(async (ctx, next) => {
await verifyCaptchaToken(ctx);
await next();
@@ -52,7 +52,11 @@ function shouldShowHeadersOnSuccess(accountType: AccountType): boolean {
function getClientIdentifier(ctx: Context<HonoEnv>): string {
const user = ctx.get('user');
if (user?.id) {
return `user:${user.id}`;
const tokenType = ctx.get('authTokenType') ?? 'session';
if (tokenType === 'bearer') {
return `user:${user.id}:bearer:${ctx.get('oauthBearerApplicationId') ?? 'unknown'}`;
}
return `user:${user.id}:${tokenType}`;
}
const ip = extractClientIp(ctx.req.raw, {
trustClientIpHeader: Config.proxy.trust_client_ip_header,
@@ -284,7 +284,7 @@ export function setInjectedAccountPolicyEvaluator(evaluator: IAccountPolicyEvalu
function getRegistrationRiskEvaluator(): IRegistrationRiskEvaluator {
if (_registrationRiskEvaluator) return _registrationRiskEvaluator;
if (!Config.risk.enabled) {
Logger.warn(
Logger.info(
{},
'[ServiceMiddleware] integrations.risk_integration.enabled is false — account risk scoring is disabled',
);
@@ -160,7 +160,7 @@ function createEmailServiceForConfig(
enabled: emailConfigSource.enabled,
fromEmail: emailConfigSource.fromEmail,
fromName: emailConfigSource.fromName,
appBaseUrl: Config.endpoints.webApp,
appBaseUrl: emailConfigSource.appBaseUrl,
marketingBaseUrl: Config.endpoints.marketing,
};
return new EmailService(emailConfig, emailI18n, createEmailProvider(emailConfigSource), bouncedEmailChecker);
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {TorBlockedError} from '@fluxer/errors/src/domains/moderation/TorBlockedError';
import {IpBannedError} from '@fluxer/errors/src/domains/moderation/IpBannedError';
import {extractClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {createMiddleware} from 'hono/factory';
import {Config} from '../Config';
@@ -13,7 +13,10 @@ export const TorExitMiddleware = createMiddleware<HonoEnv>(async (ctx, next) =>
clientIpHeaderName: Config.proxy.client_ip_header,
});
if (clientIp && torExitListCache.isTorExit(clientIp)) {
throw new TorBlockedError();
throw new IpBannedError({
ipAddress: clientIp,
kind: 'permanent',
});
}
await next();
});
+3
View File
@@ -113,6 +113,7 @@ export class GiftCode {
readonly stripePaymentIntentId: string | null;
readonly visionarySequenceNumber: number | null;
readonly checkoutSessionId: string | null;
readonly revokedAt: Date | null;
readonly version: number;
constructor(row: GiftCodeRow) {
@@ -128,6 +129,7 @@ export class GiftCode {
this.stripePaymentIntentId = row.stripe_payment_intent_id ?? null;
this.visionarySequenceNumber = row.visionary_sequence_number ?? null;
this.checkoutSessionId = row.checkout_session_id ?? null;
this.revokedAt = row.revoked_at ?? null;
this.version = row.version;
}
@@ -144,6 +146,7 @@ export class GiftCode {
stripe_payment_intent_id: this.stripePaymentIntentId,
visionary_sequence_number: this.visionarySequenceNumber,
checkout_session_id: this.checkoutSessionId,
revoked_at: this.revokedAt,
version: this.version,
};
}
+1 -1
View File
@@ -254,7 +254,7 @@ function normalizeStoredSyncedPreferences(raw: string | null | undefined): strin
try {
decodeSyncedPreferences(raw);
} catch {
return '';
return raw;
}
return raw;
}
@@ -316,7 +316,10 @@ export class OAuth2RequestService {
throw err;
}
}
Logger.info({redirectTo}, 'OAuth2 consent: returning redirect URL');
Logger.info(
{clientId: params.body.client_id.toString(), userId: params.userId.toString()},
'OAuth2 consent: returning redirect URL',
);
return {redirect_to: redirectTo};
}
@@ -3,3 +3,4 @@
import {seconds} from 'itty-time';
export const ACCESS_TOKEN_TTL_SECONDS = seconds('7 days');
export const AUTHORIZATION_CODE_TTL_SECONDS = seconds('10 minutes');
@@ -19,13 +19,17 @@ import {
OAuth2RefreshTokens,
OAuth2RefreshTokensByUser,
} from '../../Tables';
import {ACCESS_TOKEN_TTL_SECONDS} from '../OAuth2TokenConstants';
import {ACCESS_TOKEN_TTL_SECONDS, AUTHORIZATION_CODE_TTL_SECONDS} from '../OAuth2TokenConstants';
import type {IOAuth2TokenRepository} from './IOAuth2TokenRepository';
function isAccessTokenExpired(createdAt: Date): boolean {
return Date.now() - createdAt.getTime() > ACCESS_TOKEN_TTL_SECONDS * 1000;
}
function isAuthorizationCodeExpired(createdAt: Date): boolean {
return Date.now() - createdAt.getTime() > AUTHORIZATION_CODE_TTL_SECONDS * 1000;
}
const SELECT_AUTHORIZATION_CODE = OAuth2AuthorizationCodes.selectCql({
where: OAuth2AuthorizationCodes.where.eq('code'),
});
@@ -46,13 +50,19 @@ const SELECT_REFRESH_TOKENS_BY_USER = OAuth2RefreshTokensByUser.selectCql({
export class OAuth2TokenRepository implements IOAuth2TokenRepository {
async createAuthorizationCode(data: OAuth2AuthorizationCodeRow): Promise<OAuth2AuthorizationCode> {
await upsertOne(OAuth2AuthorizationCodes.insert(data));
await upsertOne(OAuth2AuthorizationCodes.insertWithTtl(data, AUTHORIZATION_CODE_TTL_SECONDS));
return new OAuth2AuthorizationCode(data);
}
async getAuthorizationCode(code: string): Promise<OAuth2AuthorizationCode | null> {
const row = await fetchOne<OAuth2AuthorizationCodeRow>(SELECT_AUTHORIZATION_CODE, {code});
return row ? new OAuth2AuthorizationCode(row) : null;
if (!row) {
return null;
}
if (isAuthorizationCodeExpired(row.created_at)) {
return null;
}
return new OAuth2AuthorizationCode(row);
}
async deleteAuthorizationCode(code: string): Promise<void> {
+8 -5
View File
@@ -25344,8 +25344,6 @@
"GLOBAL_IP_BANNED",
"GLOBAL_IP_TEMPORARILY_BANNED",
"IP_BANNED",
"RESIDENTIAL_PROXY_BLOCKED",
"TOR_BLOCKED",
"MAX_ANIMATED_EMOJIS",
"MAX_APPLICATIONS",
"MAX_BOOKMARKS",
@@ -25660,8 +25658,6 @@
"Your IP address {ipAddress} has been permanently blocked from the Fluxer API by platform administrators. If you believe this is a mistake, contact [email protected] to appeal. Include this IP address in your appeal.",
"Your IP address {ipAddress} has been temporarily blocked from the Fluxer API for 24 hours because of abusive or unusual access patterns. We usually do not provide appeals for temporary API bans. Change IP addresses or wait for the ban to expire, and review the Fluxer API access patterns coming from your client.",
"Your IP address {ipAddress} has been permanently blocked from the Fluxer API by platform administrators. If you believe this is a mistake, contact [email protected] to appeal. Include this IP address in your appeal.",
"This user's IP is banned from this community.",
"This user's IP is banned from this community.",
"You've reached the maximum of {count, plural, one {# animated emoji} other {# animated emojis}}.",
"You've reached the maximum of {limit, plural, one {# application} other {# applications}}.",
"You've reached the maximum of {count, plural, one {# bookmark} other {# bookmarks}}.",
@@ -30827,7 +30823,14 @@
"minimum": 0,
"maximum": 7,
"format": "int32",
"description": "Number of days of messages to delete from the banned user (0-7)"
"description": "Number of days of messages to delete from the banned user (0-7). Deprecated in favor of delete_message_seconds."
},
"delete_message_seconds": {
"type": "integer",
"minimum": 0,
"maximum": 604800,
"format": "int32",
"description": "Number of seconds of messages to delete for the banned user (0-604800, default 0)"
},
"reason": {
"anyOf": [{"type": "string"}, {"type": "null"}],
+9 -14
View File
@@ -143,7 +143,7 @@ function buildApnsHeaders(params: {
'apns-topic': params.topic,
'apns-push-type': isClear ? APNS_PUSH_TYPE_BACKGROUND : APNS_PUSH_TYPE_ALERT,
'apns-priority': isClear ? '5' : '10',
'apns-expiration': String(Math.floor(Date.now() / 1000) + (isClear ? 300 : 86400)),
'apns-expiration': String(Math.floor(Date.now() / 1000) + (isClear ? 3600 : 86400)),
'content-type': 'application/json',
};
if (collapseId && Buffer.byteLength(collapseId) <= 64) {
@@ -155,18 +155,11 @@ function buildApnsHeaders(params: {
function buildApnsPayload(payload: Record<string, unknown>): Record<string, unknown> {
if (isClearNotificationPayload(payload)) {
const data = isRecord(payload.data) ? payload.data : {};
const badge = normalizeBadgeCount(data.badge_count);
const aps: Record<string, unknown> = {
'content-available': 1,
};
if (badge !== undefined) {
aps.badge = badge;
}
return {
...data,
type: 'notification_clear',
action: 'clear_channel',
aps,
aps: {'content-available': 1},
};
}
const data = isRecord(payload.data) ? payload.data : {};
@@ -177,16 +170,18 @@ function buildApnsPayload(payload: Record<string, unknown>): Record<string, unkn
const channelId = optionalString(data.channel_id);
const threadId =
optionalString(data.notification_tag) ?? (channelId ? `channel:${channelId}` : undefined) ?? 'fluxer-message';
const imageUrl = firstString([payload.image_url, notification.image, notification.icon, payload.icon]);
const imageUrl = firstString([payload.image_url, notification.image]);
const aps: Record<string, unknown> = {
alert: {title, body},
sound: APNS_DEFAULT_SOUND,
badge,
'thread-id': threadId,
category: APNS_CATEGORY_MESSAGE,
'interruption-level': 'active',
'relevance-score': 0.5,
};
if (badge !== undefined) {
aps.badge = badge;
}
if (imageUrl) {
aps['mutable-content'] = 1;
}
@@ -292,13 +287,13 @@ function isPermanentApnsFailure(statusCode: number, reason: string): boolean {
return reason === 'Unregistered';
}
function normalizeBadgeCount(value: unknown): number {
function normalizeBadgeCount(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value));
if (typeof value === 'string') {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? Math.max(0, parsed) : 0;
return Number.isFinite(parsed) ? Math.max(0, parsed) : undefined;
}
return 0;
return undefined;
}
function firstString(values: Array<unknown>): string | undefined {
@@ -38,7 +38,7 @@ describe('ApnsPushService', () => {
},
});
});
it('builds silent APNs clear payloads with badge when badge_count is present', () => {
it('builds silent APNs clear payloads as pure background pushes', () => {
const payload = ApnsPushServiceTestHooks.buildApnsPayload({
type: 'notification_clear',
action: 'clear_channel',
@@ -56,11 +56,11 @@ describe('ApnsPushService', () => {
badge_count: 0,
aps: {
'content-available': 1,
badge: 0,
},
});
expect(payload.aps).not.toHaveProperty('alert');
expect(payload.aps).not.toHaveProperty('sound');
expect(payload.aps).not.toHaveProperty('badge');
});
it('uses APNs push-type and priority headers that match alert versus background delivery', () => {
const alertHeaders = ApnsPushServiceTestHooks.buildApnsHeaders({
@@ -94,6 +94,43 @@ describe('ApnsPushService', () => {
'apns-priority': '5',
'apns-collapse-id': 'channel:123',
});
const now = Math.floor(Date.now() / 1000);
expect(Number(clearHeaders['apns-expiration']) - now).toBeGreaterThan(1800);
});
it('omits the badge entirely when no usable badge count is supplied', () => {
const payload = ApnsPushServiceTestHooks.buildApnsPayload({
tag: 'channel:123:456',
data: {channel_id: '123', message_id: '456'},
notification: {title: 'Alice', body: 'Hello'},
});
expect(payload.aps).not.toHaveProperty('badge');
const unparseable = ApnsPushServiceTestHooks.buildApnsPayload({
tag: 'channel:123:456',
data: {channel_id: '123', message_id: '456', badge_count: 'not-a-number'},
notification: {title: 'Alice', body: 'Hello'},
});
expect(unparseable.aps).not.toHaveProperty('badge');
});
it('keeps a genuine zero badge so reading the last message clears the app icon', () => {
const payload = ApnsPushServiceTestHooks.buildApnsPayload({
tag: 'channel:123:456',
data: {channel_id: '123', message_id: '456', badge_count: 0},
notification: {title: 'Alice', body: 'Hello'},
});
expect((payload.aps as Record<string, unknown>).badge).toBe(0);
});
it('does not use the sender avatar as the notification media image', () => {
const payload = ApnsPushServiceTestHooks.buildApnsPayload({
tag: 'channel:123:456',
data: {channel_id: '123', message_id: '456', author_avatar_url: 'https://cdn.example/avatar.png'},
notification: {title: 'Alice', body: 'Hello', icon: 'https://cdn.example/avatar.png'},
});
expect(payload.image_url).toBeUndefined();
expect(payload.aps).not.toHaveProperty('mutable-content');
expect(payload.author_avatar_url).toBe('https://cdn.example/avatar.png');
});
it('marks only permanent APNs token failures as subscription deletion signals', () => {
expect(ApnsPushServiceTestHooks.isPermanentApnsFailure(410, 'Unregistered')).toBe(true);
@@ -30,6 +30,7 @@ import {channelNeedsReindexing} from './ChannelIndexingUtils';
import type {IMessageSearchService} from './IMessageSearchService';
import {MessageSearchResponseMapper} from './MessageSearchResponseMapper';
import {searchExistingMessages} from './MessageSearchResultReconciler';
import {channelRequiresAgeVerification} from './SearchNsfwUtils';
const CHANNEL_INDEX_CHECK_CONCURRENCY = 32;
const CHANNEL_INDEX_JOB_ENQUEUE_CONCURRENCY = 16;
@@ -101,7 +102,7 @@ export class GlobalSearchService {
this.guildService.search.collectAccessibleGuildChannels(params.userId),
this.findDmScopeContextChannel(params.userId, params.includeChannelId),
]);
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels} = guildAccess;
const {accessibleChannels, unindexedChannelIds, guildNsfwLevels, parentCategories} = guildAccess;
if (unindexedChannelIds.size > 0) {
await this.queueIndexingChannels(unindexedChannelIds);
return {indexing: true};
@@ -133,7 +134,7 @@ export class GlobalSearchService {
if (guildIsAgeRestricted) {
return canIncludeNsfw;
}
if (channel.isNsfw) {
if (channelRequiresAgeVerification(channel, parentCategories, false)) {
return canIncludeNsfw;
}
return true;
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {type ChannelID, createMessageID, type GuildID, type MessageID} from '../BrandedTypes';
import {type ChannelID, createMessageID, type MessageID} from '../BrandedTypes';
import {Logger} from '../Logger';
import {getMessageSearchService} from '../SearchFactory';
import type {IMessageSearchService} from './IMessageSearchService';
@@ -59,18 +59,3 @@ export async function deleteChannelMessageSearchDocuments(
);
}
}
export async function deleteGuildMessageSearchDocuments(guildId: GuildID, options: CleanupOptions = {}): Promise<void> {
const searchService = resolveMessageSearchService(options.searchService);
if (!searchService) {
return;
}
try {
await searchService.deleteGuildMessages(guildId);
} catch (error) {
Logger.error(
{...options.context, guildId: guildId.toString(), error},
'Failed to delete guild message documents from search index',
);
}
}
@@ -0,0 +1,19 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ContentWarningLevel} from '@fluxer/constants/src/GuildConstants';
import {channelToContentWarningView, computeEffectiveChannelNsfw} from '../channel/utils/EffectiveContentWarning';
import type {Channel} from '../models/Channel';
export function channelRequiresAgeVerification(
channel: Channel,
channelsById: ReadonlyMap<string, Channel>,
guildNsfw: boolean,
): boolean {
const parentCategory =
channel.parentId != null ? (channelsById.get(channel.parentId.toString()) ?? null) : null;
return computeEffectiveChannelNsfw(
channelToContentWarningView(channel),
parentCategory ? channelToContentWarningView(parentCategory) : null,
{nsfw: guildNsfw, contentWarningLevel: ContentWarningLevel.INHERIT, contentWarningText: null},
);
}
@@ -123,6 +123,9 @@ export class StripeDisputeWebhookHandler {
reason: 'gift_refund',
chargeId: charge.id,
});
} else if (!giftCode.revokedAt) {
await this.userRepository.revokeGiftCode(giftCode.code);
Logger.debug({giftCode: giftCode.code, chargeId: charge.id}, 'Revoked unredeemed gift code after refund');
}
return;
}
@@ -196,6 +199,12 @@ export class StripeDisputeWebhookHandler {
{giftCode: giftCode.code, redeemerId: giftCode.redeemedByUserId},
'Premium revoked due to gift chargeback',
);
} else if (!giftCode.revokedAt) {
await this.userRepository.revokeGiftCode(giftCode.code);
Logger.debug(
{giftCode: giftCode.code, chargeId: extractId(dispute.charge)},
'Revoked unredeemed gift code after chargeback',
);
}
await this.paymentFraudService.enforceAccountFraudAction({
userId: giftCode.createdByUserId,
@@ -40,7 +40,7 @@ export class StripeGiftService {
async getGiftCode(code: string): Promise<GiftCode> {
const giftCode = await this.userRepository.findGiftCode(code);
if (!giftCode) {
if (!giftCode || giftCode.revokedAt) {
throw new UnknownGiftCodeError();
}
return giftCode;
@@ -62,7 +62,7 @@ export class StripeGiftService {
}
try {
const giftCode = await this.userRepository.findGiftCode(code);
if (!giftCode) {
if (!giftCode || giftCode.revokedAt) {
Logger.debug({userId, giftCode: code}, 'Gift code not found during redemption');
throw new UnknownGiftCodeError();
}
@@ -258,6 +258,7 @@ export class StripeGiftService {
const redeemedGracePeriodMs = 7 * 24 * 60 * 60 * 1000;
const cutoff = Date.now() - redeemedGracePeriodMs;
return gifts
.filter((gift) => gift.revokedAt === null)
.filter((gift) => gift.redeemedAt === null || gift.redeemedAt.getTime() > cutoff)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
@@ -252,7 +252,7 @@ export class StripeRefundService {
return;
}
const user = await this.userRepository.findUnique(userId);
if (!user || user.firstRefundAt) {
if (!user) {
return;
}
const subscriptionId = refund.metadata.subscription_id;
@@ -439,7 +439,7 @@ describe('Stripe Webhook - Invoice Events', () => {
test('skips zero-amount subscription_update invoice without granting an extra monthly cycle', async () => {
const account = await createTestAccount(harness);
const subscriptionId = `sub_test_${Date.now()}`;
const baselinePremiumUntil = new Date('2026-08-25T21:57:05.000Z');
const baselinePremiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({
@@ -481,7 +481,7 @@ describe('Stripe Webhook - Invoice Events', () => {
test('skips paid subscription_update invoices so interval switches do not grant extra time', async () => {
const account = await createTestAccount(harness);
const subscriptionId = `sub_test_${Date.now()}`;
const baselinePremiumUntil = new Date('2026-08-25T21:57:05.000Z');
const baselinePremiumUntil = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
await createBuilder(harness, account.token)
.post(`/test/users/${account.userId}/premium`)
.body({
@@ -37,6 +37,7 @@ export class TestMediaService extends IMediaService {
height: 128,
animated: stored ? this.isAnimatedImage(stored, format) : format === 'gif',
nsfw: false,
placeholder: this.fakePlaceholder(request.key),
};
}
if (request.type === 'upload') {
@@ -55,6 +56,7 @@ export class TestMediaService extends IMediaService {
height: 128,
animated: format === 'gif',
nsfw: false,
placeholder: this.fakePlaceholder(request.upload_filename),
};
}
if (request.type === 'external') {
@@ -67,11 +69,16 @@ export class TestMediaService extends IMediaService {
height: 128,
animated: false,
nsfw: false,
placeholder: this.fakePlaceholder(request.url),
};
}
return null;
}
private fakePlaceholder(seed: string): string {
return crypto.createHash('sha256').update(seed).digest('base64').slice(0, 24);
}
getExternalMediaProxyURL(): string {
return 'https://media-proxy.test';
}
@@ -47,6 +47,7 @@ function normaliseGiftCodeRowForWrite(data: GiftCodeRow): GiftCodeRow {
duration_type: durationType,
duration_quantity: durationQuantity,
duration_months: durationMonths,
revoked_at: data.revoked_at ?? null,
};
}
@@ -171,6 +172,10 @@ export class GiftCodeRepository {
await batch.execute();
}
async revokeGiftCode(code: string): Promise<void> {
await upsertOne(GiftCodes.patchByPk({code}, {revoked_at: Db.set(new Date())}));
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
const batch = new BatchBuilder();
const patch: Record<string, DbOp<unknown>> = {};
@@ -37,6 +37,7 @@ export interface IUserContentRepository {
findGiftCodesByRedeemer(userId: UserID): Promise<Array<GiftCode>>;
redeemGiftCode(code: string, userId: UserID): Promise<void>;
unredeemGiftCode(code: string, userId: UserID): Promise<void>;
revokeGiftCode(code: string): Promise<void>;
updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void>;
linkGiftCodeToCheckoutSession(code: string, checkoutSessionId: string): Promise<void>;
listPushSubscriptions(userId: UserID): Promise<Array<PushSubscription>>;
@@ -62,6 +62,10 @@ export class UserContentRepository implements IUserContentRepository {
return this.giftCodeRepository.unredeemGiftCode(code, userId);
}
async revokeGiftCode(code: string): Promise<void> {
return this.giftCodeRepository.revokeGiftCode(code);
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
return this.giftCodeRepository.updateGiftCode(code, data);
}
@@ -645,6 +645,10 @@ export class UserRepository implements IUserRepositoryAggregate {
return this.contentRepo.unredeemGiftCode(code, userId);
}
async revokeGiftCode(code: string): Promise<void> {
return this.contentRepo.revokeGiftCode(code);
}
async updateGiftCode(code: string, data: Partial<GiftCodeRow>): Promise<void> {
return this.contentRepo.updateGiftCode(code, data);
}
@@ -118,7 +118,7 @@ export class TokenRepository {
}
async createEmailRevertToken(tokenData: EmailRevertTokenRow): Promise<EmailRevertToken> {
await upsertOne(EmailRevertTokens.insert(tokenData));
await upsertOne(EmailRevertTokens.insertWithTtl(tokenData, seconds('24 hours')));
return new EmailRevertToken(tokenData);
}
@@ -89,14 +89,17 @@ export class UserAccountLookupService {
let guildMember: GuildMemberResponse | null = null;
let guildMemberDomain: GuildMember | null = null;
if (guildId != null) {
guildMemberDomain = await this.deps.guildRepository.getMember(guildId, targetId);
if (guildMemberDomain) {
guildMember = await this.deps.guildService.members.getMember({
userId,
targetId,
guildId,
requestCache,
});
const viewerMember = await this.deps.guildRepository.getMember(guildId, userId);
if (viewerMember) {
guildMemberDomain = await this.deps.guildRepository.getMember(guildId, targetId);
if (guildMemberDomain) {
guildMember = await this.deps.guildService.members.getMember({
userId,
targetId,
guildId,
requestCache,
});
}
}
}
let premiumType = user.premiumType ?? undefined;

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