mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-09 12:12:25 +09:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89fac5b088 | ||
|
|
4a34b942b7 | ||
|
|
fc3065ebe4 | ||
|
|
23493b4ac2 | ||
|
|
13344096b7 | ||
|
|
a800430997 | ||
|
|
509562e6da | ||
|
|
88d85919f1 | ||
|
|
154e223284 | ||
|
|
58732f7770 | ||
|
|
cb4c847d41 | ||
|
|
d3976e33f8 | ||
|
|
8e17970632 | ||
|
|
c0048504db | ||
|
|
5015452280 | ||
|
|
c504b68354 | ||
|
|
5d2e5932a4 | ||
|
|
cf1a7d7a8a | ||
|
|
14a935db81 | ||
|
|
f98a40062a | ||
|
|
f7ebc1492c | ||
|
|
da3922586a | ||
|
|
28184f8d4d | ||
|
|
a4e7522ca0 | ||
|
|
59b3d30323 | ||
|
|
53cac0b614 | ||
|
|
82c29398d3 | ||
|
|
6d289e31c7 | ||
|
|
5ec02c3089 | ||
|
|
9940273cd9 |
+10
-32
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -1,15 +1,3 @@
|
||||
> [!CAUTION]
|
||||
> As of this writing (15 June 2026), we are working to finalise the API and self-hosting documentation over the next few days.
|
||||
>
|
||||
> We apologise for the brief delay in open-source releases. We paused after spam waves created safety concerns while we built out Fluxer's trust and safety infrastructure. During that same stretch, we have been fixing hundreds of bugs, adding new features, and preparing a much improved audio and video system.
|
||||
>
|
||||
> You can already try that work in the Fluxer Canary client: [download Canary](https://canary.fluxer.app/download) or [open Canary on the web](https://web.canary.fluxer.app). The latest stable client remains out of date for now, but over the coming weeks we are finalising the remaining work needed to stabilise the current latest code out in the open.
|
||||
|
||||
> [!NOTE]
|
||||
> Learn about the developer behind Fluxer, the goals of the project, the tech stack, and what's coming next.
|
||||
>
|
||||
> [Read the launch blog post](https://blog.fluxer.app/how-i-built-fluxer-a-discord-like-chat-app/) | [View full roadmap](https://blog.fluxer.app/roadmap-2026/)
|
||||
|
||||
<p align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./fluxer_static/marketing/branding/logo-white.svg">
|
||||
|
||||
Vendored
+2
-7
@@ -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}
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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:-}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 {
|
||||
@@ -254,6 +274,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 +327,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: {
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -158,6 +158,7 @@ export interface APIConfig {
|
||||
webhookSecret?: string;
|
||||
fromEmail: string;
|
||||
fromName: string;
|
||||
appBaseUrl: string;
|
||||
smtp?: {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -204,6 +205,7 @@ export interface APIConfig {
|
||||
apiSecret?: string;
|
||||
webhookUrl?: string;
|
||||
url?: string;
|
||||
internalUrl?: string;
|
||||
defaultRegion?: {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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}}.",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createHash} from 'node:crypto';
|
||||
import type {WorkerTaskHandler, WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {z} from 'zod';
|
||||
import {
|
||||
@@ -19,6 +20,7 @@ import {Logger} from '../../Logger';
|
||||
import {getWorkerDependencies} from '../WorkerContext';
|
||||
|
||||
const MENTION_CHUNK_SIZE = 250;
|
||||
const MENTION_CHUNK_KEY_DIGEST_LENGTH = 32;
|
||||
const MENTION_CHUNK_ENQUEUE_CONCURRENCY = 16;
|
||||
const MENTION_SOURCE_PAGE_SIZE = 5000;
|
||||
const PayloadSchema = z.object({
|
||||
@@ -100,6 +102,14 @@ function toMentionChunkEntry(entry: GatewayMentionSourceEntry): MentionChunkEntr
|
||||
};
|
||||
}
|
||||
|
||||
function mentionChunkJobKey(messageId: string, chunk: Array<MentionChunkEntry>): string {
|
||||
const digest = createHash('sha256');
|
||||
for (const entry of chunk) {
|
||||
digest.update(`${entry.userId}:${entry.direct ? 1 : 0}:${entry.role ? 1 : 0}:${entry.everyone ? 1 : 0}\n`);
|
||||
}
|
||||
return `mention-chunk:${messageId}:${digest.digest('hex').slice(0, MENTION_CHUNK_KEY_DIGEST_LENGTH)}`;
|
||||
}
|
||||
|
||||
async function enqueueMentionChunks({
|
||||
chunks,
|
||||
channelId,
|
||||
@@ -122,7 +132,6 @@ async function enqueueMentionChunks({
|
||||
await Promise.all(
|
||||
chunkSlice.map((chunk, localIndex) => {
|
||||
const index = firstChunkIndex + offset + localIndex;
|
||||
const jobKeySuffix = chunkCount === undefined ? `${index}` : `${index}:${chunkCount}`;
|
||||
return addJob(
|
||||
'handleMentionChunk',
|
||||
{
|
||||
@@ -134,7 +143,7 @@ async function enqueueMentionChunks({
|
||||
mentions: chunk,
|
||||
},
|
||||
{
|
||||
jobKey: `mention-chunk:${messageId}:${jobKeySuffix}`,
|
||||
jobKey: mentionChunkJobKey(messageId, chunk),
|
||||
skipLedger: true,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -675,7 +675,9 @@ async function createAndUploadArchive(params: ArchiveParams): Promise<ArchiveRes
|
||||
output.on('close', resolve);
|
||||
output.on('error', reject);
|
||||
});
|
||||
const storageKey = `exports/${userId}/${harvestId}/user-data.zip`;
|
||||
const storageKey = isAdminArchive
|
||||
? `archives/users/${userId}/${harvestId}/user-data.zip`
|
||||
: `exports/${userId}/${harvestId}/user-data.zip`;
|
||||
const expiresAt = new Date(Date.now() + (isAdminArchive ? ms('1 year') : ZIP_EXPIRY_MS));
|
||||
const zipStat = await fs.promises.stat(zipPath);
|
||||
const fileSize = BigInt(zipStat.size);
|
||||
|
||||
@@ -230,6 +230,7 @@ export const ChannelItem = observer(
|
||||
const isVoiceDragActive = draggingChannel?.channelType === ChannelTypes.GUILD_VOICE;
|
||||
const shouldDimForVoiceDrag = Boolean(isVoiceDragActive && channelIsText && channel.parentId !== null);
|
||||
const unreadCount = ReadStates.getUnreadCount(channel.id);
|
||||
const hasUnread = ReadStates.hasUnread(channel.id);
|
||||
const connectedVoiceGuildId = channelIsVoice ? MediaEngine.guildId : null;
|
||||
const connectedVoiceChannelId = channelIsVoice ? MediaEngine.channelId : null;
|
||||
const canManageChannels = Permission.can(Permissions.MANAGE_CHANNELS, channel);
|
||||
@@ -265,6 +266,7 @@ export const ChannelItem = observer(
|
||||
type: channel.type,
|
||||
});
|
||||
const unreadState = getChannelUnreadState({
|
||||
hasUnread,
|
||||
unreadCount,
|
||||
mentionCount,
|
||||
isMuted: isChannelDirectlyMuted,
|
||||
|
||||
@@ -335,6 +335,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
);
|
||||
const hasVisibleUnreadInChannel = (channelId: string): boolean => {
|
||||
const unreadCount = ReadStates.getUnreadCount(channelId);
|
||||
const hasUnread = ReadStates.hasUnread(channelId);
|
||||
const mentionCount = ReadStates.getMentionCount(channelId);
|
||||
const isMuted =
|
||||
UserGuildSettings.isParentCategoryMuted(guild.id, channelId) ||
|
||||
@@ -349,6 +350,7 @@ export const ChannelListContent = observer(({guild, scrollY}: {guild: Guild; scr
|
||||
})
|
||||
: null;
|
||||
const unreadState = getChannelUnreadState({
|
||||
hasUnread,
|
||||
unreadCount,
|
||||
mentionCount,
|
||||
isMuted,
|
||||
|
||||
@@ -197,6 +197,7 @@ const FavoriteChannelResolvedItem = observer(
|
||||
);
|
||||
const refs = useMergeRefs([dragConnectorRef, dropConnectorRef, elementRef]);
|
||||
const unreadCount = ReadStates.getUnreadCount(channel.id);
|
||||
const hasUnread = ReadStates.hasUnread(channel.id);
|
||||
const mentionCount = ReadStates.getMentionCount(channel.id);
|
||||
const isGroupDM = channel.isGroupDM();
|
||||
const isDM = channel.isDM();
|
||||
@@ -220,6 +221,7 @@ const FavoriteChannelResolvedItem = observer(
|
||||
})
|
||||
: null;
|
||||
const unreadState = getChannelUnreadState({
|
||||
hasUnread,
|
||||
unreadCount,
|
||||
mentionCount,
|
||||
isMuted,
|
||||
|
||||
@@ -663,6 +663,7 @@ function resolveGuildTargetBounds({
|
||||
function getDMScrollIndicatorSeverity(channelId: string): ScrollIndicatorSeverity | null {
|
||||
const mentionCount = ReadStates.getPrivateChannelMentionCount(channelId);
|
||||
const unreadState = getChannelUnreadState({
|
||||
hasUnread: ReadStates.hasUnreadPrivateChannel(channelId),
|
||||
unreadCount: ReadStates.getPrivateChannelUnreadCount(channelId),
|
||||
mentionCount,
|
||||
isMuted: UserGuildSettings.isChannelDirectlyMuted(null, channelId),
|
||||
|
||||
@@ -70,6 +70,7 @@ const ACTIVE_CALL_DESCRIPTOR = msg({
|
||||
|
||||
export function resolveDMListItemUnreadState(channelId: string): ChannelUnreadState {
|
||||
return getChannelUnreadState({
|
||||
hasUnread: ReadStates.hasUnread(channelId),
|
||||
unreadCount: ReadStates.getUnreadCount(channelId),
|
||||
mentionCount: ReadStates.getMentionCount(channelId),
|
||||
isMuted: UserGuildSettings.isChannelDirectlyMuted(null, channelId),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {getChannelUnreadState} from './ChannelUnreadState';
|
||||
describe('getChannelUnreadState', () => {
|
||||
it('shows a normal unread indicator for all-messages unread badges', () => {
|
||||
const state = getChannelUnreadState({
|
||||
hasUnread: true,
|
||||
unreadCount: 3,
|
||||
mentionCount: 0,
|
||||
isMuted: false,
|
||||
@@ -19,6 +20,7 @@ describe('getChannelUnreadState', () => {
|
||||
});
|
||||
it('shows a muted unread indicator for only-mentions unread badges without highlighting the channel', () => {
|
||||
const state = getChannelUnreadState({
|
||||
hasUnread: true,
|
||||
unreadCount: 3,
|
||||
mentionCount: 0,
|
||||
isMuted: false,
|
||||
@@ -31,6 +33,7 @@ describe('getChannelUnreadState', () => {
|
||||
});
|
||||
it('hides unread and mention surfaces when unread badges are disabled', () => {
|
||||
const state = getChannelUnreadState({
|
||||
hasUnread: true,
|
||||
unreadCount: 3,
|
||||
mentionCount: 1,
|
||||
isMuted: false,
|
||||
@@ -43,6 +46,7 @@ describe('getChannelUnreadState', () => {
|
||||
});
|
||||
it('keeps legacy muted-channel fading for channels without an unread-badges level', () => {
|
||||
const hiddenState = getChannelUnreadState({
|
||||
hasUnread: true,
|
||||
unreadCount: 3,
|
||||
mentionCount: 0,
|
||||
isMuted: true,
|
||||
@@ -50,6 +54,7 @@ describe('getChannelUnreadState', () => {
|
||||
unreadBadgesLevel: null,
|
||||
});
|
||||
const fadedState = getChannelUnreadState({
|
||||
hasUnread: true,
|
||||
unreadCount: 3,
|
||||
mentionCount: 0,
|
||||
isMuted: true,
|
||||
@@ -60,4 +65,27 @@ describe('getChannelUnreadState', () => {
|
||||
expect(fadedState.shouldShowUnreadIndicator).toBe(true);
|
||||
expect(fadedState.isUnreadIndicatorMuted).toBe(true);
|
||||
});
|
||||
it('shows the indicator from the unread flag rather than the message count', () => {
|
||||
const state = getChannelUnreadState({
|
||||
hasUnread: true,
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
isMuted: false,
|
||||
showFadedUnreadOnMutedChannels: false,
|
||||
unreadBadgesLevel: null,
|
||||
});
|
||||
expect(state.shouldShowUnreadIndicator).toBe(true);
|
||||
});
|
||||
it('hides the indicator for a read channel even if a stale count survives', () => {
|
||||
const state = getChannelUnreadState({
|
||||
hasUnread: false,
|
||||
unreadCount: 7,
|
||||
mentionCount: 0,
|
||||
isMuted: false,
|
||||
showFadedUnreadOnMutedChannels: false,
|
||||
unreadBadgesLevel: null,
|
||||
});
|
||||
expect(state.shouldShowUnreadIndicator).toBe(false);
|
||||
expect(state.hasVisibleUnread).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {resolveChannelUnreadState} from './ChannelUnreadStateMachine';
|
||||
|
||||
export interface ChannelUnreadStateInput {
|
||||
hasUnread: boolean;
|
||||
unreadCount: number;
|
||||
mentionCount: number;
|
||||
isMuted: boolean;
|
||||
@@ -20,6 +21,7 @@ export interface ChannelUnreadState {
|
||||
}
|
||||
|
||||
export function getChannelUnreadState({
|
||||
hasUnread,
|
||||
unreadCount,
|
||||
mentionCount,
|
||||
isMuted,
|
||||
@@ -27,6 +29,7 @@ export function getChannelUnreadState({
|
||||
unreadBadgesLevel,
|
||||
}: ChannelUnreadStateInput): ChannelUnreadState {
|
||||
return resolveChannelUnreadState({
|
||||
hasUnread,
|
||||
unreadCount,
|
||||
mentionCount,
|
||||
isMuted,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
function input(overrides: Partial<ChannelUnreadStateInput> = {}): ChannelUnreadStateInput {
|
||||
return {
|
||||
hasUnread: false,
|
||||
unreadCount: 0,
|
||||
mentionCount: 0,
|
||||
isMuted: false,
|
||||
@@ -38,6 +39,7 @@ describe('channelUnreadStateMachine', () => {
|
||||
const snapshot = createChannelUnreadSnapshot(
|
||||
input({
|
||||
unreadBadgesLevel: MessageNotifications.NO_MESSAGES,
|
||||
hasUnread: true,
|
||||
unreadCount: 1,
|
||||
mentionCount: 1,
|
||||
}),
|
||||
@@ -55,6 +57,7 @@ describe('channelUnreadStateMachine', () => {
|
||||
it('transitions without preserving stale policy output', () => {
|
||||
const legacySnapshot = createChannelUnreadSnapshot(
|
||||
input({
|
||||
hasUnread: true,
|
||||
unreadCount: 2,
|
||||
isMuted: true,
|
||||
showFadedUnreadOnMutedChannels: false,
|
||||
@@ -66,6 +69,7 @@ describe('channelUnreadStateMachine', () => {
|
||||
type: 'channelUnread.updated',
|
||||
input: input({
|
||||
unreadBadgesLevel: MessageNotifications.ALL_MESSAGES,
|
||||
hasUnread: true,
|
||||
unreadCount: 2,
|
||||
isMuted: true,
|
||||
}),
|
||||
|
||||
@@ -86,7 +86,7 @@ export function transitionChannelUnreadSnapshot(
|
||||
|
||||
export function selectChannelUnreadState(snapshot: ChannelUnreadSnapshot): ChannelUnreadState {
|
||||
const context = snapshot.context;
|
||||
const hasUnreadMessages = context.unreadCount > 0;
|
||||
const hasUnreadMessages = context.hasUnread;
|
||||
const rawHasMentions = context.mentionCount > 0;
|
||||
switch (getUnreadStateValue(snapshot)) {
|
||||
case 'disabled':
|
||||
|
||||
@@ -23,6 +23,7 @@ interface BlockedMessageGroupsProps {
|
||||
messageGroups: Array<ChannelStreamItem>;
|
||||
onReveal: (messageId: string | null) => void;
|
||||
revealed: boolean;
|
||||
hasUnread?: boolean;
|
||||
compact: boolean;
|
||||
messageGroupSpacing: number;
|
||||
variant: 'blocked' | 'spammer';
|
||||
@@ -33,11 +34,13 @@ interface BlockedMessageGroupsProps {
|
||||
messageActionsClassName?: string;
|
||||
renderMessageActions?: (message: Message) => React.ReactNode;
|
||||
renderMessageWrapper?: (props: MessageGroupRenderWrapperProps) => React.ReactNode;
|
||||
suppressUnreadIndicator?: boolean;
|
||||
}
|
||||
|
||||
const arePropsEqual = (prevProps: BlockedMessageGroupsProps, nextProps: BlockedMessageGroupsProps): boolean => {
|
||||
if (prevProps.channel.id !== nextProps.channel.id) return false;
|
||||
if (prevProps.revealed !== nextProps.revealed) return false;
|
||||
if (prevProps.hasUnread !== nextProps.hasUnread) return false;
|
||||
if (prevProps.compact !== nextProps.compact) return false;
|
||||
if (prevProps.messageGroupSpacing !== nextProps.messageGroupSpacing) return false;
|
||||
if (prevProps.variant !== nextProps.variant) return false;
|
||||
@@ -49,6 +52,7 @@ const arePropsEqual = (prevProps: BlockedMessageGroupsProps, nextProps: BlockedM
|
||||
if (prevProps.messageActionsClassName !== nextProps.messageActionsClassName) return false;
|
||||
if (prevProps.renderMessageActions !== nextProps.renderMessageActions) return false;
|
||||
if (prevProps.renderMessageWrapper !== nextProps.renderMessageWrapper) return false;
|
||||
if (prevProps.suppressUnreadIndicator !== nextProps.suppressUnreadIndicator) return false;
|
||||
if (prevProps.messageGroups.length !== nextProps.messageGroups.length) return false;
|
||||
for (let i = 0; i < prevProps.messageGroups.length; i++) {
|
||||
const prevGroup = prevProps.messageGroups[i];
|
||||
@@ -69,6 +73,7 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
channel,
|
||||
compact,
|
||||
revealed,
|
||||
hasUnread = false,
|
||||
messageGroupSpacing,
|
||||
onReveal,
|
||||
variant,
|
||||
@@ -79,6 +84,7 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
messageActionsClassName,
|
||||
renderMessageActions,
|
||||
renderMessageWrapper,
|
||||
suppressUnreadIndicator,
|
||||
} = props;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const scrollToBottomFrameRef = useRef<number | null>(null);
|
||||
@@ -169,6 +175,12 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
};
|
||||
messageGroups.forEach((item, itemIndex) => {
|
||||
if (item.type === ChannelStreamType.DIVIDER) {
|
||||
if (item.unreadId && suppressUnreadIndicator) {
|
||||
return;
|
||||
}
|
||||
if (itemIndex === 0 && item.unreadId) {
|
||||
return;
|
||||
}
|
||||
flushGroup();
|
||||
nodes.push(
|
||||
<Divider
|
||||
@@ -205,13 +217,23 @@ export const BlockedMessageGroups = React.memo<BlockedMessageGroupsProps>((props
|
||||
messageActionsClassName,
|
||||
renderMessageActions,
|
||||
renderMessageWrapper,
|
||||
suppressUnreadIndicator,
|
||||
]);
|
||||
const leadingUnreadDivider = messageGroups[0]?.type === ChannelStreamType.DIVIDER && !!messageGroups[0].unreadId;
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={clsx(styles.container, className)}
|
||||
data-flx="channel.blocked-message-groups.container"
|
||||
>
|
||||
{hasUnread && (!revealed || leadingUnreadDivider) && (
|
||||
<Divider
|
||||
spacing={messageGroupSpacing}
|
||||
red={true}
|
||||
id="new-messages-bar"
|
||||
data-flx="channel.blocked-message-groups.collapsed-unread-divider"
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.toggle}
|
||||
|
||||
@@ -189,11 +189,13 @@ export function renderChannelStream(props: RenderChannelStreamProps): Array<Reac
|
||||
key={item.key}
|
||||
revealed={item.key === unblurredMessageId}
|
||||
messageGroups={item.content as Array<ChannelStreamItem>}
|
||||
hasUnread={item.hasUnread === true && !suppressUnreadIndicator}
|
||||
onReveal={onReveal ?? (() => {})}
|
||||
compact={messageDisplayCompact}
|
||||
channel={channel}
|
||||
messageGroupSpacing={messageGroupSpacing}
|
||||
variant={variant}
|
||||
suppressUnreadIndicator={suppressUnreadIndicator}
|
||||
data-flx="channel.channel-message-stream.render-channel-stream.blocked-message-groups"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -236,7 +236,7 @@ export const Messages = observer(function Messages({
|
||||
messages: safeMessages,
|
||||
channel,
|
||||
compact: state.messageDisplayCompact,
|
||||
hasPendingUnreads: state.unreadCount > 0,
|
||||
hasPendingUnreads: state.visualUnreadMessageId != null,
|
||||
focusAnchorId: null,
|
||||
unloadedSpacerHeight: selectChannelMessagesSpacerHeight(windowStatus, placeholderSpecs.totalHeight),
|
||||
allowHistoryFetch: true,
|
||||
|
||||
@@ -14,10 +14,11 @@ import {useVirtualRows} from '@app/features/channel/components/sticker_picker/ho
|
||||
import {StickerPickerCategoryList} from '@app/features/channel/components/sticker_picker/StickerPickerCategoryList';
|
||||
import {
|
||||
buildStickerRowOffsets,
|
||||
getStickerRowHeight,
|
||||
getFixedStickerRowHeight,
|
||||
getStickerGridColumns,
|
||||
getStickerRowWindow,
|
||||
STICKER_GRID_TRACK_WIDTH,
|
||||
STICKER_SECTION_GAP,
|
||||
STICKERS_PER_ROW,
|
||||
type StickerRowKind,
|
||||
} from '@app/features/channel/components/sticker_picker/StickerPickerConstants';
|
||||
import {StickerPickerInspector} from '@app/features/channel/components/sticker_picker/StickerPickerInspector';
|
||||
@@ -74,12 +75,12 @@ export const StickersPicker = observer(
|
||||
const [selectedColumn, setSelectedColumn] = useState(-1);
|
||||
const [shouldScrollOnSelection, setShouldScrollOnSelection] = useState(false);
|
||||
const scrollerRef = useRef<ScrollerHandle>(null);
|
||||
const {scrollTop, handleScroll, handleResize} = useScrollerViewport(scrollerRef);
|
||||
const {viewportSize, scrollTop, handleScroll, handleResize} = useScrollerViewport(scrollerRef);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const stickerRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
|
||||
const channel = channelId ? (Channels.getChannel(channelId) ?? null) : null;
|
||||
const rowListRef = useRef<HTMLDivElement>(null);
|
||||
const [listMetrics, setListMetrics] = useState({origin: 0, viewportHeight: 0, gridWidth: 0});
|
||||
const [listMetrics, setListMetrics] = useState({origin: 0, viewportHeight: 0});
|
||||
const [stickerDataVersion, setStickerDataVersion] = useState(0);
|
||||
const permissionVersion = useSyncExternalStore(Permission.subscribe.bind(Permission), () => Permission.version);
|
||||
const {shouldAnimate: shouldAnimateStickerPreview} = useStickerAnimation();
|
||||
@@ -138,13 +139,26 @@ export const StickersPicker = observer(
|
||||
allUpsell.accessibleItems,
|
||||
renderedStickers,
|
||||
);
|
||||
const zoomLevel = Accessibility.zoomLevel;
|
||||
const gridColumns = useMemo(() => getStickerGridColumns(viewportSize.width), [viewportSize.width, zoomLevel]);
|
||||
const previousGridColumnsRef = useRef(gridColumns);
|
||||
useLayoutEffect(() => {
|
||||
if (previousGridColumnsRef.current === gridColumns) {
|
||||
return;
|
||||
}
|
||||
previousGridColumnsRef.current = gridColumns;
|
||||
setHoveredSticker(null);
|
||||
setSelectedRow(-1);
|
||||
setSelectedColumn(-1);
|
||||
setShouldScrollOnSelection(false);
|
||||
}, [gridColumns]);
|
||||
const pickerRows = useVirtualRows(
|
||||
searchTerm,
|
||||
renderedStickers,
|
||||
favoriteStickers,
|
||||
frequentlyUsedStickers,
|
||||
stickersByGuildId,
|
||||
STICKERS_PER_ROW,
|
||||
gridColumns,
|
||||
);
|
||||
const hasNoStickersAtAll = allItems.length === 0;
|
||||
const isSearching = searchTerm.trim().length > 0;
|
||||
@@ -198,12 +212,8 @@ export const StickersPicker = observer(
|
||||
}
|
||||
return {stickerRowIndexes: indexes, stickerRowStarts: starts, categoryRowIndexes: categories};
|
||||
}, [pickerRows]);
|
||||
const zoomLevel = Accessibility.zoomLevel;
|
||||
const remScale = getAppRemScale();
|
||||
const stickerRowHeight = useMemo(
|
||||
() => getStickerRowHeight(listMetrics.gridWidth, STICKERS_PER_ROW, remScale),
|
||||
[listMetrics.gridWidth, remScale, zoomLevel],
|
||||
);
|
||||
const stickerRowHeight = useMemo(() => getFixedStickerRowHeight(remScale), [remScale, zoomLevel]);
|
||||
const rowOffsets = useMemo(() => {
|
||||
const rowKinds = pickerRows.map((row): StickerRowKind => row.type);
|
||||
return buildStickerRowOffsets(rowKinds, {remScale, stickerRowHeight, sectionGap: STICKER_SECTION_GAP});
|
||||
@@ -219,11 +229,8 @@ export const StickersPicker = observer(
|
||||
rowListNode.getBoundingClientRect().top - scrollerNode.getBoundingClientRect().top + scrollerNode.scrollTop,
|
||||
);
|
||||
const viewportHeight = scrollerNode.clientHeight;
|
||||
const gridWidth = rowListNode.clientWidth;
|
||||
setListMetrics((current) =>
|
||||
current.origin === origin && current.viewportHeight === viewportHeight && current.gridWidth === gridWidth
|
||||
? current
|
||||
: {origin, viewportHeight, gridWidth},
|
||||
current.origin === origin && current.viewportHeight === viewportHeight ? current : {origin, viewportHeight},
|
||||
);
|
||||
});
|
||||
const rowWindow = useMemo(
|
||||
@@ -394,7 +401,8 @@ export const StickersPicker = observer(
|
||||
row={row}
|
||||
handleHover={handleHover}
|
||||
handleSelect={handleStickerSelect}
|
||||
gridColumns={STICKERS_PER_ROW}
|
||||
gridColumns={gridColumns}
|
||||
cellTrackWidth={STICKER_GRID_TRACK_WIDTH}
|
||||
selectedRow={selectedRow}
|
||||
selectedColumn={selectedColumn}
|
||||
stickerRowIndex={stickerRowIndexes[rowIndex]!}
|
||||
|
||||
@@ -200,18 +200,15 @@ export const UserMessage = observer(() => {
|
||||
finishEditing();
|
||||
return;
|
||||
}
|
||||
MessageCommands.edit(
|
||||
finishEditing();
|
||||
void MessageCommands.edit(
|
||||
channel.id,
|
||||
message.id,
|
||||
'',
|
||||
undefined,
|
||||
message._allowedMentions,
|
||||
buildExistingAttachmentEditReferences(message),
|
||||
).then((result) => {
|
||||
if (result) {
|
||||
finishEditing();
|
||||
}
|
||||
});
|
||||
);
|
||||
return;
|
||||
}
|
||||
handleDelete();
|
||||
@@ -220,11 +217,8 @@ export const UserMessage = observer(() => {
|
||||
if (checkCustomEmojiAvailability(content)) {
|
||||
return;
|
||||
}
|
||||
MessageCommands.edit(channel.id, message.id, content, undefined, message._allowedMentions).then((result) => {
|
||||
if (result) {
|
||||
finishEditing();
|
||||
}
|
||||
});
|
||||
finishEditing();
|
||||
void MessageCommands.edit(channel.id, message.id, content, undefined, message._allowedMentions);
|
||||
},
|
||||
[
|
||||
channel.id,
|
||||
|
||||
+2
-2
@@ -14,9 +14,9 @@ import {msg} from '@lingui/core/macro';
|
||||
import type {IconProps} from '@phosphor-icons/react';
|
||||
import {
|
||||
ChatCenteredDotsIcon,
|
||||
ChatCircleIcon,
|
||||
ClockClockwiseIcon,
|
||||
ClockCounterClockwiseIcon,
|
||||
EnvelopeSimpleIcon,
|
||||
GlobeIcon,
|
||||
HashIcon,
|
||||
SparkleIcon,
|
||||
@@ -162,7 +162,7 @@ export const getSortModeOptions = (i18n: I18n): Array<{mode: ChannelSearchSortMo
|
||||
];
|
||||
const SCOPE_ICON_COMPONENTS: Record<MessageSearchScope, React.ComponentType<IconProps>> = {
|
||||
current: HashIcon,
|
||||
all_dms: EnvelopeSimpleIcon,
|
||||
all_dms: ChatCircleIcon,
|
||||
open_dms: ChatCenteredDotsIcon,
|
||||
all_guilds: GlobeIcon,
|
||||
all: UsersIcon,
|
||||
|
||||
@@ -125,7 +125,7 @@ export const AddFriendForm: React.FC<AddFriendFormProps> = observer(({onSuccess}
|
||||
};
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const [username, discriminator] = parseInput(input);
|
||||
const [username, discriminator] = parseInput(input['trim']());
|
||||
if (!username || !discriminator || !/^\d{4}$/.test(discriminator)) {
|
||||
setResultStatus('error');
|
||||
setErrorCode(APIErrorCodes.NO_USERS_WITH_FLUXERTAG_EXIST);
|
||||
|
||||
@@ -135,6 +135,7 @@ const ResolvedDMListItem = observer(function ResolvedDMListItem({
|
||||
const isMuted = UserGuildSettings.isChannelDirectlyMuted(null, channel.id);
|
||||
const mentionCount = ReadStates.getMentionCount(channel.id);
|
||||
const unreadState = getChannelUnreadState({
|
||||
hasUnread: ReadStates.hasUnread(channel.id),
|
||||
unreadCount: ReadStates.getUnreadCount(channel.id),
|
||||
mentionCount,
|
||||
isMuted,
|
||||
|
||||
+2
-1
@@ -208,9 +208,10 @@ export const AttachmentGridItem: FC<AttachmentGridItemProps> = observer(
|
||||
messageId: message?.id,
|
||||
message,
|
||||
sourceChannel: messageViewContext?.channel,
|
||||
allowAttachmentDelete: !isPreview && snapshotIndex === undefined,
|
||||
});
|
||||
},
|
||||
[attachment, message, messageViewContext?.channel, mediaAttachments, shouldBlur],
|
||||
[attachment, message, messageViewContext?.channel, mediaAttachments, shouldBlur, isPreview, snapshotIndex],
|
||||
);
|
||||
const openInBrowser = useOpenInBrowserOnMiddleClick(attachment.url ?? attachment.proxy_url ?? '', !shouldBlur);
|
||||
const handleFavoriteClick = useCallback(
|
||||
|
||||
@@ -181,6 +181,7 @@ const useImagePreview = ({
|
||||
message,
|
||||
sourceChannel,
|
||||
providerName,
|
||||
allowAttachmentDelete = false,
|
||||
}: {
|
||||
proxyUrl: string;
|
||||
embedUrl: string;
|
||||
@@ -195,6 +196,7 @@ const useImagePreview = ({
|
||||
message?: Message;
|
||||
sourceChannel?: Channel | null;
|
||||
providerName?: string;
|
||||
allowAttachmentDelete?: boolean;
|
||||
}): {viewerItem: MediaViewerItem; openPreview: (event: React.MouseEvent | React.KeyboardEvent) => void} => {
|
||||
const viewerItem = useMemo<MediaViewerItem>(
|
||||
() => ({
|
||||
@@ -223,9 +225,10 @@ const useImagePreview = ({
|
||||
messageId,
|
||||
message,
|
||||
sourceChannel,
|
||||
allowAttachmentDelete,
|
||||
});
|
||||
},
|
||||
[viewerItem, channelId, messageId, message, sourceChannel],
|
||||
[viewerItem, channelId, messageId, message, sourceChannel, allowAttachmentDelete],
|
||||
);
|
||||
return {viewerItem, openPreview};
|
||||
};
|
||||
@@ -461,6 +464,7 @@ export const EmbedGifv: FC<
|
||||
message,
|
||||
sourceChannel: messageViewContext?.channel,
|
||||
providerName,
|
||||
allowAttachmentDelete: !isPreview && snapshotIndex === undefined,
|
||||
});
|
||||
const handleDeleteClick = useDeleteAttachment(message, attachmentId);
|
||||
const handleDownloadClick = useCallback(
|
||||
@@ -795,6 +799,7 @@ export const EmbedGif: FC<
|
||||
contentHash,
|
||||
message,
|
||||
sourceChannel: messageViewContext?.channel,
|
||||
allowAttachmentDelete: !isPreview && snapshotIndex === undefined,
|
||||
});
|
||||
const {scheduleViewerWarm, cancelViewerWarm} = useMediaViewerHoverWarm(viewerItem, {
|
||||
allowAnimated: gifAutoPlay,
|
||||
|
||||
@@ -104,6 +104,7 @@ interface ImagePreviewHandlerProps {
|
||||
message?: Message;
|
||||
animated?: boolean;
|
||||
mediaAttachments?: ReadonlyArray<MessageAttachment>;
|
||||
allowAttachmentDelete?: boolean;
|
||||
onViewerWarmEnter?: () => void;
|
||||
onViewerWarmLeave?: () => void;
|
||||
children: React.ReactNode;
|
||||
@@ -143,6 +144,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
|
||||
message,
|
||||
animated,
|
||||
mediaAttachments = NO_MEDIA_ATTACHMENTS,
|
||||
allowAttachmentDelete = false,
|
||||
onViewerWarmEnter,
|
||||
onViewerWarmLeave,
|
||||
children,
|
||||
@@ -176,6 +178,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
|
||||
messageId,
|
||||
message,
|
||||
sourceChannel: messageViewContext?.channel,
|
||||
allowAttachmentDelete,
|
||||
});
|
||||
} else {
|
||||
MediaViewerCommands.openMediaViewer(
|
||||
@@ -187,6 +190,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
|
||||
naturalHeight,
|
||||
type: 'image' as const,
|
||||
contentHash,
|
||||
attachmentId,
|
||||
embedIndex,
|
||||
expiresAt: undefined,
|
||||
expired: undefined,
|
||||
@@ -199,6 +203,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
|
||||
messageId,
|
||||
message,
|
||||
sourceChannel: messageViewContext?.channel,
|
||||
allowAttachmentDelete,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -217,6 +222,7 @@ const ImagePreviewHandler: FC<ImagePreviewHandlerProps> = observer(
|
||||
message,
|
||||
messageViewContext?.channel,
|
||||
mediaAttachments,
|
||||
allowAttachmentDelete,
|
||||
],
|
||||
);
|
||||
const openInBrowser = useOpenInBrowserOnMiddleClick(originalSrc || src);
|
||||
@@ -381,6 +387,7 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
|
||||
[originalSrc, src],
|
||||
);
|
||||
const handleDeleteClick = useDeleteAttachment(message, attachmentId);
|
||||
const allowAttachmentDelete = !isPreview && snapshotIndex === undefined;
|
||||
const [mediaSheetOpen, setMediaSheetOpen] = useState(false);
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
@@ -524,6 +531,7 @@ export const EmbedImage: FC<EmbedImageProps> = observer(
|
||||
attachmentId={attachmentId}
|
||||
message={message}
|
||||
mediaAttachments={mediaAttachments}
|
||||
allowAttachmentDelete={allowAttachmentDelete}
|
||||
animated={animated}
|
||||
onViewerWarmEnter={scheduleViewerWarm}
|
||||
onViewerWarmLeave={cancelViewerWarm}
|
||||
|
||||
@@ -272,6 +272,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
|
||||
[src],
|
||||
);
|
||||
const handleDeleteClick = useDeleteAttachment(message, attachmentId);
|
||||
const allowAttachmentDelete = !isPreview && snapshotIndex === undefined;
|
||||
const handleContextMenu = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!message) return;
|
||||
@@ -396,6 +397,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
|
||||
messageId,
|
||||
message,
|
||||
sourceChannel: messageViewContext?.channel,
|
||||
allowAttachmentDelete,
|
||||
});
|
||||
} else {
|
||||
MediaViewerCommands.openMediaViewer(
|
||||
@@ -407,12 +409,13 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
|
||||
naturalHeight: viewerVideoDimensions.height,
|
||||
type: 'video' as const,
|
||||
contentHash,
|
||||
attachmentId,
|
||||
embedIndex,
|
||||
duration,
|
||||
},
|
||||
],
|
||||
0,
|
||||
{channelId, messageId, message, sourceChannel: messageViewContext?.channel},
|
||||
{channelId, messageId, message, sourceChannel: messageViewContext?.channel, allowAttachmentDelete},
|
||||
);
|
||||
}
|
||||
}, [
|
||||
@@ -420,6 +423,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
|
||||
messageId,
|
||||
message,
|
||||
messageViewContext?.channel,
|
||||
allowAttachmentDelete,
|
||||
mediaAttachments,
|
||||
attachmentId,
|
||||
effectiveSrc,
|
||||
@@ -451,6 +455,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
|
||||
messageId,
|
||||
message,
|
||||
sourceChannel: messageViewContext?.channel,
|
||||
allowAttachmentDelete,
|
||||
});
|
||||
} else {
|
||||
MediaViewerCommands.openMediaViewer(
|
||||
@@ -462,13 +467,14 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
|
||||
naturalHeight: viewerVideoDimensions.height,
|
||||
type: 'video' as const,
|
||||
contentHash,
|
||||
attachmentId,
|
||||
embedIndex,
|
||||
duration,
|
||||
initialTime: currentTime,
|
||||
},
|
||||
],
|
||||
0,
|
||||
{channelId, messageId, message, sourceChannel: messageViewContext?.channel},
|
||||
{channelId, messageId, message, sourceChannel: messageViewContext?.channel, allowAttachmentDelete},
|
||||
);
|
||||
}
|
||||
setIsPlayingInline(false);
|
||||
@@ -477,6 +483,7 @@ const EmbedVideo: FC<EmbedVideoProps> = observer(
|
||||
messageId,
|
||||
message,
|
||||
messageViewContext?.channel,
|
||||
allowAttachmentDelete,
|
||||
mediaAttachments,
|
||||
attachmentId,
|
||||
effectiveSrc,
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
import {AutocompleteOption} from '@app/features/channel/components/message_search_bar/AutocompleteOption';
|
||||
import styles from '@app/features/channel/components/message_search_bar/MessageSearchBar.module.css';
|
||||
import {resolveChannelSuggestionDisplayName} from '@app/features/channel/components/message_search_bar/MessageSearchBarUtils';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
|
||||
import {resolveSearchChannelDisplayName} from '@app/features/search/utils/SearchQueryParser';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
@@ -72,7 +72,7 @@ export const ChannelsSection: React.FC<ChannelsSectionProps> = observer(
|
||||
className={styles.channelName}
|
||||
data-flx="channel.message-search-bar.channels-section.channel-name"
|
||||
>
|
||||
{resolveSearchChannelDisplayName(channelOption) || 'Unnamed Channel'}
|
||||
{resolveChannelSuggestionDisplayName(channelOption) || 'Unnamed Channel'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+46
-2
@@ -5,25 +5,34 @@ import type {
|
||||
HistoryFilterRow,
|
||||
} from '@app/features/channel/components/message_search_bar/MessageSearchBarTypes';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
|
||||
import type {Guild} from '@app/features/guild/models/Guild';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import type {GuildMember} from '@app/features/member/models/GuildMember';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import {resolveSearchChannelDisplayName} from '@app/features/search/utils/SearchQueryParser';
|
||||
import type {SearchSegment} from '@app/features/search/utils/SearchSegmentManager';
|
||||
import type {MessageSearchScope, SearchFilterOption} from '@app/features/search/utils/SearchUtils';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {IconProps} from '@phosphor-icons/react';
|
||||
import {ChatCenteredDotsIcon, EnvelopeSimpleIcon, GlobeIcon, HashIcon, UsersIcon} from '@phosphor-icons/react';
|
||||
import {ChatCenteredDotsIcon, ChatCircleIcon, GlobeIcon, HashIcon, UsersIcon} from '@phosphor-icons/react';
|
||||
|
||||
export const SCOPE_ICON_COMPONENTS: Record<MessageSearchScope, React.ComponentType<IconProps>> = {
|
||||
current: HashIcon,
|
||||
all_dms: EnvelopeSimpleIcon,
|
||||
all_dms: ChatCircleIcon,
|
||||
open_dms: ChatCenteredDotsIcon,
|
||||
all_guilds: GlobeIcon,
|
||||
all: UsersIcon,
|
||||
open_dms_and_all_guilds: UsersIcon,
|
||||
};
|
||||
|
||||
export function resolveChannelSuggestionDisplayName(channel: Channel): string {
|
||||
if (channel.type === ChannelTypes.GROUP_DM || channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
|
||||
return ChannelUtils.getDMDisplayName(channel);
|
||||
}
|
||||
return resolveSearchChannelDisplayName(channel);
|
||||
}
|
||||
|
||||
export function filterRequiresValue(filter: SearchFilterOption): boolean {
|
||||
return Boolean(filter.requiresValue) || (filter.values?.length ?? 0) > 0;
|
||||
}
|
||||
@@ -274,6 +283,41 @@ export function isUserFilterKey(filterKey: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export type DmChannelSuggestionMode = 'none' | 'current' | 'open' | 'all';
|
||||
export type GuildChannelSuggestionMode = 'none' | 'current_guild' | 'all_guilds';
|
||||
|
||||
export interface ChannelSuggestionSearchPlan {
|
||||
dmMode: DmChannelSuggestionMode;
|
||||
guildMode: GuildChannelSuggestionMode;
|
||||
currentGuildId?: string;
|
||||
}
|
||||
|
||||
export function getChannelSuggestionSearchPlan(
|
||||
scope: MessageSearchScope,
|
||||
currentGuildId: string | undefined,
|
||||
): ChannelSuggestionSearchPlan {
|
||||
switch (scope) {
|
||||
case 'current':
|
||||
return currentGuildId
|
||||
? {dmMode: 'none', guildMode: 'current_guild', currentGuildId}
|
||||
: {dmMode: 'current', guildMode: 'none'};
|
||||
case 'all_dms':
|
||||
return {dmMode: 'all', guildMode: 'none'};
|
||||
case 'open_dms':
|
||||
return {dmMode: 'open', guildMode: 'none'};
|
||||
case 'all':
|
||||
return {dmMode: 'all', guildMode: 'all_guilds'};
|
||||
case 'open_dms_and_all_guilds':
|
||||
return {dmMode: 'open', guildMode: 'all_guilds'};
|
||||
case 'all_guilds':
|
||||
return {dmMode: 'none', guildMode: 'all_guilds'};
|
||||
default: {
|
||||
const exhaustiveScope: never = scope;
|
||||
throw new Error(`Unsupported message search scope: ${exhaustiveScope}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type GuildSearchMode = 'none' | 'current_guild' | 'all_guilds';
|
||||
|
||||
export interface UserGuildSearchPlan {
|
||||
|
||||
@@ -4,8 +4,8 @@ import {AutocompleteOption} from '@app/features/channel/components/message_searc
|
||||
import {FilterOption} from '@app/features/channel/components/message_search_bar/FilterOption';
|
||||
import styles from '@app/features/channel/components/message_search_bar/MessageSearchBar.module.css';
|
||||
import type {PlaintextAutocompleteRow} from '@app/features/channel/components/message_search_bar/MessageSearchBarTypes';
|
||||
import {resolveChannelSuggestionDisplayName} from '@app/features/channel/components/message_search_bar/MessageSearchBarUtils';
|
||||
import * as ChannelUtils from '@app/features/channel/utils/ChannelUtils';
|
||||
import {resolveSearchChannelDisplayName} from '@app/features/search/utils/SearchQueryParser';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import {StatusAwareAvatar} from '@app/features/ui/components/StatusAwareAvatar';
|
||||
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
|
||||
@@ -143,7 +143,7 @@ export const PlaintextSection: React.FC<PlaintextSectionProps> = observer(
|
||||
className={styles.channelName}
|
||||
data-flx="channel.message-search-bar.plaintext-section.channel-name"
|
||||
>
|
||||
{resolveSearchChannelDisplayName(row.channel)}
|
||||
{resolveChannelSuggestionDisplayName(row.channel)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
+101
-33
@@ -12,6 +12,7 @@ import {
|
||||
buildHistoryFilterRows,
|
||||
buildUserSearchBoosters,
|
||||
filterRequiresValue,
|
||||
getChannelSuggestionSearchPlan,
|
||||
getUserGuildSearchPlan,
|
||||
isDateFilterKey,
|
||||
isSearchFilterOptionEligible,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
PLAINTEXT_SUGGESTION_FILTER_KEYS,
|
||||
PLAINTEXT_SUGGESTIONS_PER_FILTER,
|
||||
replaceSearchTokenAtCursor,
|
||||
resolveChannelSuggestionDisplayName,
|
||||
resolveMessageSearchCurrentWord,
|
||||
type SearchTokenReplacementResult,
|
||||
} from '@app/features/channel/components/message_search_bar/MessageSearchBarUtils';
|
||||
@@ -32,6 +34,7 @@ import type {LexicalSearchInputHandle} from '@app/features/lexical/search/Lexica
|
||||
import MemberSearch, {type SearchContext} from '@app/features/member/state/MemberSearch';
|
||||
import {isIMEComposing} from '@app/features/messaging/utils/IMECompositionUtils';
|
||||
import SelectedChannel from '@app/features/navigation/state/SelectedChannel';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||
import SearchHistory, {
|
||||
SEARCH_HISTORY_DISPLAY_LIMIT,
|
||||
@@ -41,7 +44,7 @@ import {
|
||||
buildSearchSegmentsFromHints,
|
||||
formatSearchHistoryEntryForStreamerMode,
|
||||
} from '@app/features/search/utils/SearchPrivacyUtils';
|
||||
import {matchSearchDateWords, resolveSearchChannelDisplayName} from '@app/features/search/utils/SearchQueryParser';
|
||||
import {matchSearchDateWords} from '@app/features/search/utils/SearchQueryParser';
|
||||
import type {SearchSegment} from '@app/features/search/utils/SearchSegmentManager';
|
||||
import type {MessageSearchScope, SearchFilterOption} from '@app/features/search/utils/SearchUtils';
|
||||
import {getSearchFilterOptions} from '@app/features/search/utils/SearchUtils';
|
||||
@@ -52,7 +55,7 @@ import Users from '@app/features/user/state/Users';
|
||||
import {getCurrentLocale} from '@app/features/user/utils/LocaleUtils';
|
||||
import * as NicknameUtils from '@app/features/user/utils/NicknameUtils';
|
||||
import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
import {GUILD_TEXT_BASED_CHANNEL_TYPES} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ChannelTypes, GUILD_TEXT_BASED_CHANNEL_TYPES, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {DateTime} from 'luxon';
|
||||
@@ -63,6 +66,18 @@ const MAX_FILTER_VALUE_SUGGESTIONS = 12;
|
||||
const MAX_MEMBER_SEARCH_RESULTS = 25;
|
||||
const MAX_DATE_WORD_SUGGESTIONS = 10;
|
||||
|
||||
function isDmSearchChannel(channel: Channel): boolean {
|
||||
return channel.type === ChannelTypes.DM || channel.type === ChannelTypes.GROUP_DM;
|
||||
}
|
||||
|
||||
function isGuildSearchChannel(channel: Channel): boolean {
|
||||
return (
|
||||
channel.guildId != null &&
|
||||
GUILD_TEXT_BASED_CHANNEL_TYPES.has(channel.type) &&
|
||||
Permission.can(Permissions.VIEW_CHANNEL | Permissions.READ_MESSAGE_HISTORY, channel)
|
||||
);
|
||||
}
|
||||
|
||||
function isHistoryFilterRow(option: AutocompleteOption): option is HistoryFilterRow {
|
||||
if (typeof option !== 'object' || option === null || !('kind' in option)) {
|
||||
return false;
|
||||
@@ -396,27 +411,79 @@ export function useMessageSearchAutocomplete({
|
||||
);
|
||||
const resolveChannelSuggestions = useCallback(
|
||||
(searchTerm: string, limit: number): Array<Channel> => {
|
||||
const guildIdForChannels = channelGuildId ?? (routeGuildId === ME ? undefined : routeGuildId);
|
||||
if (!guildIdForChannels) {
|
||||
const privateChannels = Channels.getPrivateChannels().filter((c) => resolveSearchChannelDisplayName(c) !== '');
|
||||
return matchSorter(privateChannels, searchTerm, {
|
||||
keys: [(c: Channel) => resolveSearchChannelDisplayName(c)],
|
||||
}).slice(0, limit);
|
||||
const currentGuildId = channelGuildId ?? (routeGuildId === ME ? undefined : routeGuildId);
|
||||
const plan = getChannelSuggestionSearchPlan(activeScope, currentGuildId);
|
||||
const candidatesById = new Map<string, Channel>();
|
||||
const addCandidate = (candidate: Channel | undefined) => {
|
||||
if (candidate) {
|
||||
candidatesById.set(candidate.id, candidate);
|
||||
}
|
||||
};
|
||||
if (!hidePersonalInformation) {
|
||||
switch (plan.dmMode) {
|
||||
case 'none':
|
||||
break;
|
||||
case 'current':
|
||||
if (channel && channel.guildId == null) {
|
||||
addCandidate(channel);
|
||||
}
|
||||
break;
|
||||
case 'open':
|
||||
case 'all':
|
||||
for (const privateChannel of Channels.getPrivateChannels()) {
|
||||
addCandidate(privateChannel);
|
||||
}
|
||||
if (channel && isDmSearchChannel(channel)) {
|
||||
addCandidate(channel);
|
||||
}
|
||||
break;
|
||||
default: {
|
||||
const exhaustiveDmMode: never = plan.dmMode;
|
||||
throw new Error(`Unsupported DM channel suggestion mode: ${exhaustiveDmMode}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
const channels = Channels.getGuildChannels(guildIdForChannels).filter((c) =>
|
||||
GUILD_TEXT_BASED_CHANNEL_TYPES.has(c.type),
|
||||
switch (plan.guildMode) {
|
||||
case 'none':
|
||||
break;
|
||||
case 'current_guild':
|
||||
if (!plan.currentGuildId) {
|
||||
throw new Error('Current guild channel suggestion mode requires a guild ID');
|
||||
}
|
||||
for (const guildChannel of Channels.getGuildChannels(plan.currentGuildId)) {
|
||||
if (isGuildSearchChannel(guildChannel)) {
|
||||
addCandidate(guildChannel);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'all_guilds':
|
||||
for (const guildChannels of Channels.channelGroups.byGuild.values()) {
|
||||
for (const guildChannel of guildChannels) {
|
||||
if (isGuildSearchChannel(guildChannel)) {
|
||||
addCandidate(guildChannel);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
default: {
|
||||
const exhaustiveGuildMode: never = plan.guildMode;
|
||||
throw new Error(`Unsupported guild channel suggestion mode: ${exhaustiveGuildMode}`);
|
||||
}
|
||||
}
|
||||
const candidates = Array.from(candidatesById.values()).filter(
|
||||
(candidate) => resolveChannelSuggestionDisplayName(candidate) !== '',
|
||||
);
|
||||
const recentVisitsForGuild = SelectedChannel.recentlyVisitedChannels
|
||||
.filter((visit) => visit.guildId === guildIdForChannels)
|
||||
.sort((a, b) => b.timestamp - a.timestamp);
|
||||
const recencyRank = new Map<string, number>();
|
||||
recentVisitsForGuild.forEach((visit, index) => {
|
||||
SelectedChannel.sortedRecentVisits.forEach((visit, index) => {
|
||||
if (!recencyRank.has(visit.channelId)) {
|
||||
recencyRank.set(visit.channelId, index);
|
||||
}
|
||||
});
|
||||
const currentChannelId = channel?.id;
|
||||
const matches = matchSorter(channels, searchTerm, {keys: ['name']});
|
||||
const matches = matchSorter(candidates, searchTerm, {
|
||||
keys: [(candidate: Channel) => resolveChannelSuggestionDisplayName(candidate)],
|
||||
});
|
||||
const matchRank = new Map(matches.map((candidate, index) => [candidate.id, index]));
|
||||
const orderedMatches = [...matches].sort((a, b) => {
|
||||
const resolveRank = (ch: Channel) => {
|
||||
if (ch.id === currentChannelId) return -1;
|
||||
@@ -426,11 +493,11 @@ export function useMessageSearchAutocomplete({
|
||||
if (rankDifference !== 0) {
|
||||
return rankDifference;
|
||||
}
|
||||
return (a.name ?? '').localeCompare(b.name ?? '');
|
||||
return (matchRank.get(a.id) ?? Number.MAX_SAFE_INTEGER) - (matchRank.get(b.id) ?? Number.MAX_SAFE_INTEGER);
|
||||
});
|
||||
return orderedMatches.slice(0, limit);
|
||||
},
|
||||
[channelGuildId, routeGuildId, channel],
|
||||
[activeScope, channelGuildId, routeGuildId, channel, hidePersonalInformation],
|
||||
);
|
||||
const getPlaintextRows = useCallback((): Array<PlaintextAutocompleteRow> => {
|
||||
const cursorPosition = resolveInputCursorPosition(inputRef, value);
|
||||
@@ -462,7 +529,7 @@ export function useMessageSearchAutocomplete({
|
||||
}
|
||||
return rows;
|
||||
}, [value, filterOptions, filterEligibility, resolveChannelSuggestions, resolveUserSuggestions]);
|
||||
const getAutocompleteOptions = useCallback((): Array<AutocompleteOption> => {
|
||||
const resolveAutocompleteOptions = useCallback((): Array<AutocompleteOption> => {
|
||||
const cursorPos = resolveInputCursorPosition(inputRef, value);
|
||||
const currentWord = resolveMessageSearchCurrentWord({value, cursorPosition: cursorPos});
|
||||
switch (autocompleteType) {
|
||||
@@ -548,13 +615,17 @@ export function useMessageSearchAutocomplete({
|
||||
() => buildHistoryFilterRows(filterOptions, filterEligibility),
|
||||
[filterOptions, filterEligibility],
|
||||
);
|
||||
const autocompleteOptions = resolveAutocompleteOptions();
|
||||
const totalOptions = autocompleteType
|
||||
? autocompleteOptions.length + (autocompleteType === 'history' ? historyFilterRows.length : 0)
|
||||
: 0;
|
||||
const getAutocompleteOptions = useCallback(
|
||||
(): Array<AutocompleteOption> => autocompleteOptions,
|
||||
[autocompleteOptions],
|
||||
);
|
||||
const getTotalOptions = useCallback((): number => {
|
||||
if (!autocompleteType) return 0;
|
||||
if (autocompleteType === 'history') {
|
||||
return historyFilterRows.length + getAutocompleteOptions().length;
|
||||
}
|
||||
return getAutocompleteOptions().length;
|
||||
}, [autocompleteType, getAutocompleteOptions, historyFilterRows]);
|
||||
return totalOptions;
|
||||
}, [totalOptions]);
|
||||
const hasAnyOptions = useCallback((): boolean => {
|
||||
return getTotalOptions() > 0;
|
||||
}, [getTotalOptions]);
|
||||
@@ -564,13 +635,11 @@ export function useMessageSearchAutocomplete({
|
||||
if (selectedIndex < historyFilterRows.length) {
|
||||
return historyFilterRows[selectedIndex] ?? null;
|
||||
}
|
||||
const historyOptions = getAutocompleteOptions();
|
||||
const historyIndex = selectedIndex - historyFilterRows.length;
|
||||
return historyOptions[historyIndex] ?? null;
|
||||
return autocompleteOptions[historyIndex] ?? null;
|
||||
}
|
||||
const options = getAutocompleteOptions();
|
||||
return options[selectedIndex] ?? null;
|
||||
}, [selectedIndex, autocompleteType, getAutocompleteOptions, historyFilterRows]);
|
||||
return autocompleteOptions[selectedIndex] ?? null;
|
||||
}, [selectedIndex, autocompleteType, autocompleteOptions, historyFilterRows]);
|
||||
useEffect(() => {
|
||||
if (!isFocused || suppressAutoOpen) {
|
||||
setAutocompleteType(null);
|
||||
@@ -643,11 +712,10 @@ export function useMessageSearchAutocomplete({
|
||||
setCurrentFilter(null);
|
||||
}, [value, isFocused, isInGuildChannel, suppressAutoOpen, filterOptions, filterEligibility]);
|
||||
useEffect(() => {
|
||||
const totalOptions = getTotalOptions();
|
||||
if (totalOptions > 0 && (selectedIndex >= totalOptions || selectedIndex < -1)) {
|
||||
setSelectedIndex(-1);
|
||||
}
|
||||
}, [autocompleteType, selectedIndex, getTotalOptions]);
|
||||
}, [selectedIndex, totalOptions]);
|
||||
const handleOptionMouseEnter = (index: number) => {
|
||||
setHoverIndex(index);
|
||||
setHasInteracted(true);
|
||||
@@ -729,7 +797,7 @@ export function useMessageSearchAutocomplete({
|
||||
case 'channels': {
|
||||
const ch = option as Channel;
|
||||
const filter = requireCurrentFilter();
|
||||
const displayName = resolveSearchChannelDisplayName(ch);
|
||||
const displayName = resolveChannelSuggestionDisplayName(ch);
|
||||
const name = displayName === '' ? i18n._(UNNAMED_DESCRIPTOR) : displayName;
|
||||
replacement = replaceSearchTokenAtCursor({
|
||||
value,
|
||||
@@ -815,7 +883,7 @@ export function useMessageSearchAutocomplete({
|
||||
shouldSubmit = true;
|
||||
break;
|
||||
}
|
||||
const rowDisplayName = resolveSearchChannelDisplayName(row.channel);
|
||||
const rowDisplayName = resolveChannelSuggestionDisplayName(row.channel);
|
||||
const channelName = rowDisplayName === '' ? i18n._(UNNAMED_DESCRIPTOR) : rowDisplayName;
|
||||
replacement = replaceSearchTokenAtCursor({
|
||||
value,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {getRemScaleForDocument} from '@app/features/theme/layout/RemFromPx';
|
||||
|
||||
export const STICKERS_PER_ROW = 4;
|
||||
export const STICKERS_PER_ROW_MOBILE = 5;
|
||||
export const STICKER_CATEGORY_HEADER_HEIGHT = 32;
|
||||
export const STICKER_SECTION_GAP = 12;
|
||||
export const STICKER_GRID_TRACK_WIDTH = 120;
|
||||
|
||||
const STICKER_CATEGORY_HEADER_GAP = 8;
|
||||
const STICKER_GRID_GAP = 8;
|
||||
@@ -23,6 +26,20 @@ export interface StickerRowWindow {
|
||||
lastRow: number;
|
||||
}
|
||||
|
||||
export function getStickerGridColumns(containerWidth: number): number {
|
||||
if (containerWidth <= 0) {
|
||||
return STICKERS_PER_ROW;
|
||||
}
|
||||
const remScale = getRemScaleForDocument(typeof document === 'undefined' ? null : document);
|
||||
const trackWidth = STICKER_GRID_TRACK_WIDTH * remScale;
|
||||
const gap = STICKER_GRID_GAP * remScale;
|
||||
return Math.max(1, Math.floor((containerWidth + gap) / (trackWidth + gap)));
|
||||
}
|
||||
|
||||
export function getFixedStickerRowHeight(remScale: number): number {
|
||||
return (STICKER_GRID_TRACK_WIDTH + STICKER_GRID_BLOCK_END_PADDING) * remScale;
|
||||
}
|
||||
|
||||
export function getStickerRowHeight(gridWidth: number, gridColumns: number, remScale: number): number {
|
||||
if (!(gridWidth > 0) || gridColumns <= 0) {
|
||||
return 0;
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
padding: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.stickerGridFixedTrack {
|
||||
grid-auto-flow: column;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.stickerButton {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -27,6 +27,7 @@ interface VirtualRowRendererProps {
|
||||
handleHover: (sticker: GuildSticker | null, row?: number, column?: number) => void;
|
||||
handleSelect: (sticker: GuildSticker, shiftKey?: boolean) => void;
|
||||
gridColumns?: number;
|
||||
cellTrackWidth?: number;
|
||||
selectedRow: number;
|
||||
selectedColumn: number;
|
||||
stickerRowIndex: number;
|
||||
@@ -126,6 +127,7 @@ const VirtualRowRendererBase: React.FC<VirtualRowRendererProps> = React.memo(
|
||||
handleHover,
|
||||
handleSelect,
|
||||
gridColumns = 4,
|
||||
cellTrackWidth,
|
||||
selectedRow,
|
||||
selectedColumn,
|
||||
stickerRowIndex,
|
||||
@@ -220,11 +222,14 @@ const VirtualRowRendererBase: React.FC<VirtualRowRendererProps> = React.memo(
|
||||
);
|
||||
}
|
||||
if (row.type === 'sticker-row') {
|
||||
const hasFixedTrack = cellTrackWidth != null && cellTrackWidth > 0;
|
||||
return (
|
||||
<div
|
||||
className={styles.stickerGrid}
|
||||
className={hasFixedTrack ? `${styles.stickerGrid} ${styles.stickerGridFixedTrack}` : styles.stickerGrid}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${gridColumns}, minmax(0, 1fr))`,
|
||||
gridTemplateColumns: hasFixedTrack
|
||||
? `repeat(auto-fill, ${remFromPx(cellTrackWidth)})`
|
||||
: `repeat(${gridColumns}, minmax(0, 1fr))`,
|
||||
}}
|
||||
data-flx="channel.sticker-picker.virtual-row.virtual-row-renderer-base.sticker-grid"
|
||||
>
|
||||
@@ -286,6 +291,7 @@ interface VirtualRowWrapperProps {
|
||||
handleHover: (sticker: GuildSticker | null, row?: number, column?: number) => void;
|
||||
handleSelect: (sticker: GuildSticker, shiftKey?: boolean) => void;
|
||||
gridColumns?: number;
|
||||
cellTrackWidth?: number;
|
||||
selectedRow: number;
|
||||
selectedColumn: number;
|
||||
stickerRowIndex: number;
|
||||
@@ -300,6 +306,7 @@ export const VirtualRowWrapper: React.FC<VirtualRowWrapperProps> = observer(
|
||||
handleHover,
|
||||
handleSelect,
|
||||
gridColumns,
|
||||
cellTrackWidth,
|
||||
selectedRow,
|
||||
selectedColumn,
|
||||
stickerRowIndex,
|
||||
@@ -313,6 +320,7 @@ export const VirtualRowWrapper: React.FC<VirtualRowWrapperProps> = observer(
|
||||
handleHover={handleHover}
|
||||
handleSelect={handleSelect}
|
||||
gridColumns={gridColumns}
|
||||
cellTrackWidth={cellTrackWidth}
|
||||
selectedRow={selectedRow}
|
||||
selectedColumn={selectedColumn}
|
||||
stickerRowIndex={stickerRowIndex}
|
||||
|
||||
@@ -139,6 +139,16 @@ function $reconcileParagraph(paragraph: ParagraphNode, parserFlags?: number): vo
|
||||
}
|
||||
}
|
||||
|
||||
function $isEscapedAtOffset(source: string, offset: number): boolean {
|
||||
let backslashes = 0;
|
||||
let cursor = offset - 1;
|
||||
while (cursor >= 0 && source[cursor] === '\\') {
|
||||
backslashes += 1;
|
||||
cursor -= 1;
|
||||
}
|
||||
return backslashes % 2 === 1;
|
||||
}
|
||||
|
||||
function $reconcileLine(
|
||||
line: Array<LexicalNode>,
|
||||
parserFlags?: number,
|
||||
@@ -153,13 +163,15 @@ function $reconcileLine(
|
||||
const nodeText = $nodeWireText(node);
|
||||
const nodeEnd = sourceOffset + nodeText.length;
|
||||
if ($isComposerMentionNode(node) || $isComposerCustomEmojiNode(node) || $isComposerStandardEmojiNode(node)) {
|
||||
const literal = spans.some(
|
||||
(span) =>
|
||||
span.role === 'content' &&
|
||||
(span.format & MarkdownHl.code) !== 0 &&
|
||||
span.start <= sourceOffset &&
|
||||
span.end >= nodeEnd,
|
||||
);
|
||||
const literal =
|
||||
$isEscapedAtOffset(source, sourceOffset) ||
|
||||
spans.some(
|
||||
(span) =>
|
||||
span.role === 'content' &&
|
||||
(span.format & MarkdownHl.code) !== 0 &&
|
||||
span.start <= sourceOffset &&
|
||||
span.end >= nodeEnd,
|
||||
);
|
||||
if (node.isLiteral() !== literal) {
|
||||
node.setLiteral(literal);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
import {resolveMessagePageState} from '@app/features/messaging/commands/MessagePageStateMachine';
|
||||
import {MessageDeleteFailedModal} from '@app/features/messaging/components/alerts/MessageDeleteFailedModal';
|
||||
import {MessageDeleteTooQuickModal} from '@app/features/messaging/components/alerts/MessageDeleteTooQuickModal';
|
||||
import {MessageEditFailedModal} from '@app/features/messaging/components/alerts/MessageEditFailedModal';
|
||||
import {MessageEditTooQuickModal} from '@app/features/messaging/components/alerts/MessageEditTooQuickModal';
|
||||
import type {Message as MessageModel} from '@app/features/messaging/models/MessagingMessage';
|
||||
import type {JumpOptions} from '@app/features/messaging/state/ChannelMessages';
|
||||
import MessageEdit from '@app/features/messaging/state/MessageEdit';
|
||||
@@ -34,8 +36,10 @@ import {
|
||||
import {
|
||||
type ApiAttachmentMetadata,
|
||||
type ApiMessageEditAttachmentMetadata,
|
||||
buildMessageEditRequest,
|
||||
normalizeMessageContent,
|
||||
} from '@app/features/messaging/utils/MessageRequestUtils';
|
||||
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
|
||||
import * as IARCommands from '@app/features/moderation/commands/IARCommands';
|
||||
import * as NavigationCommands from '@app/features/navigation/commands/NavigationCommands';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
@@ -80,6 +84,8 @@ const ALSO_REPORT_TO_SAFETY_TEAM_DESCRIPTOR = msg({
|
||||
'Toggle-switch label in the moderator delete-message confirmation dialog. When enabled, the message is reported (category: other) before being deleted. {productName} is the product name (e.g., Fluxer).',
|
||||
});
|
||||
const logger = new Logger('MessageCommands');
|
||||
const MESSAGE_EDIT_MAX_RETRIES = 5;
|
||||
const MESSAGE_EDIT_TIMEOUT_MS = 30_000;
|
||||
const pendingDeletePromises = new Map<string, Promise<void>>();
|
||||
const pendingFetchPromises = new Map<string, Promise<Array<WireMessage>>>();
|
||||
|
||||
@@ -601,7 +607,42 @@ function showDeleteFailureModal(error: unknown, messageId: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
export function edit(
|
||||
function showEditFailureModal(error: unknown): void {
|
||||
if (error instanceof HttpError) {
|
||||
const errorCode = failureCode(error);
|
||||
if (error.status === 429) {
|
||||
const retryAfterMs = resolveRetryAfterMs(error);
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<MessageEditTooQuickModal
|
||||
retryAfter={retryAfterMs === null ? undefined : Math.ceil(retryAfterMs / 1000)}
|
||||
data-flx="messaging.message-commands.message-edit-too-quick-modal"
|
||||
/>
|
||||
)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (error.status === 403 && errorCode === APIErrorCodes.FEATURE_TEMPORARILY_DISABLED) {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<FeatureTemporarilyDisabledModal data-flx="messaging.message-commands.message-edit-feature-temporarily-disabled-modal" />
|
||||
)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (errorCode === APIErrorCodes.CONTENT_BLOCKED) {
|
||||
void import('@app/features/auth/components/ContentBlockedHandler').then((module) =>
|
||||
module.showContentBlockedModal(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
ModalCommands.push(
|
||||
modal(() => <MessageEditFailedModal data-flx="messaging.message-commands.message-edit-failed-modal" />),
|
||||
);
|
||||
}
|
||||
|
||||
export async function edit(
|
||||
channelId: string,
|
||||
messageId: string,
|
||||
content?: string,
|
||||
@@ -609,31 +650,22 @@ export function edit(
|
||||
allowedMentions?: AllowedMentions,
|
||||
attachments?: Array<ApiMessageEditAttachmentMetadata>,
|
||||
): Promise<WireMessage | null> {
|
||||
return new Promise<WireMessage | null>((resolve) => {
|
||||
logger.debug(`Enqueueing edit for message ${messageId} in channel ${channelId}`);
|
||||
MessageQueue.enqueue(
|
||||
{
|
||||
type: 'edit',
|
||||
channelId,
|
||||
messageId,
|
||||
content,
|
||||
allowedMentions,
|
||||
flags,
|
||||
attachments,
|
||||
},
|
||||
(result, error) => {
|
||||
if (result?.body) {
|
||||
logger.debug(`Message edited successfully: ${messageId} in channel ${channelId}`);
|
||||
resolve(result.body);
|
||||
} else {
|
||||
if (error) {
|
||||
logger.debug(`Message edit failed: ${messageId} in channel ${channelId}`, error);
|
||||
}
|
||||
resolve(null);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
logger.debug(`Editing message ${messageId} in channel ${channelId}`);
|
||||
try {
|
||||
const response = await http.patch<WireMessage>(Endpoints.CHANNEL_MESSAGE(channelId, messageId), {
|
||||
body: buildMessageEditRequest({content, flags, allowedMentions, attachments}),
|
||||
mode: 'auto-retry',
|
||||
retries: MESSAGE_EDIT_MAX_RETRIES,
|
||||
timeoutMs: MESSAGE_EDIT_TIMEOUT_MS,
|
||||
suppressContentBlockedModal: true,
|
||||
});
|
||||
logger.debug(`Message edited successfully: ${messageId} in channel ${channelId}`);
|
||||
return response.body ?? null;
|
||||
} catch (error) {
|
||||
logger.error(`Message edit failed: ${messageId} in channel ${channelId}`, error);
|
||||
showEditFailureModal(error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(channelId: string, messageId: string): Promise<void> {
|
||||
|
||||
@@ -95,6 +95,7 @@ export const MediaModal: FC<MediaModalProps> = observer(
|
||||
onOpenInBrowser,
|
||||
onCopyLink,
|
||||
onCopyMedia,
|
||||
onDeleteAttachment,
|
||||
onReply,
|
||||
onForward,
|
||||
children,
|
||||
@@ -636,6 +637,7 @@ export const MediaModal: FC<MediaModalProps> = observer(
|
||||
onOpenInBrowser={onOpenInBrowser}
|
||||
onCopyLink={onCopyLink}
|
||||
onCopyMedia={onCopyMedia}
|
||||
onDeleteAttachment={onDeleteAttachment}
|
||||
onReset={handleResetMedia}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import Accessibility from '@app/features/accessibility/state/Accessibility';
|
||||
import {useBottomSheetBackHandler} from '@app/features/app/hooks/useBottomSheetBackHandler';
|
||||
import {deriveDefaultNameFromMessage} from '@app/features/channel/components/embeds/EmbedUtils';
|
||||
import {useMessageActionMenuData} from '@app/features/channel/components/MessageActionMenu';
|
||||
@@ -14,6 +15,7 @@ import type {ForwardModalSuccess} from '@app/features/messaging/components/modal
|
||||
import {MediaModal} from '@app/features/messaging/components/modals/MediaModal';
|
||||
import styles from '@app/features/messaging/components/modals/MediaViewerModal.module.css';
|
||||
import {getMediaViewerPortalRoot} from '@app/features/messaging/components/modals/MediaViewerPortal';
|
||||
import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttachment';
|
||||
import {useMediaFavorite} from '@app/features/messaging/hooks/useMediaFavorite';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import {formatAttachmentDate} from '@app/features/messaging/utils/AttachmentExpiryUtils';
|
||||
@@ -273,7 +275,8 @@ const MobileMediaOptionsSheet: FC<MobileMediaOptionsSheetProps> = observer(funct
|
||||
});
|
||||
const MediaViewerModalComponent: FC = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const {isOpen, items, currentIndex, channelId, messageId, message, sourceChannel} = MediaViewer;
|
||||
const {isOpen, items, currentIndex, channelId, messageId, message, sourceChannel, allowAttachmentDelete} =
|
||||
MediaViewer;
|
||||
const {enabled: isMobile} = MobileLayout;
|
||||
const [isMediaMenuOpen, setIsMediaMenuOpen] = useState(false);
|
||||
const currentItem = items[currentIndex];
|
||||
@@ -438,6 +441,22 @@ const MediaViewerModalComponent: FC = observer(() => {
|
||||
() => (message ? untracked(() => getMessagePermissions(message, sourceChannel)) : null),
|
||||
[message, sourceChannel],
|
||||
);
|
||||
const deletableAttachmentId =
|
||||
Accessibility.showMediaDeleteButton &&
|
||||
allowAttachmentDelete &&
|
||||
permissions?.canDeleteAttachment &&
|
||||
currentItem?.attachmentId &&
|
||||
message?.attachments.some((attachment) => attachment.id === currentItem.attachmentId)
|
||||
? currentItem.attachmentId
|
||||
: undefined;
|
||||
const handleAttachmentDeleted = useCallback(() => {
|
||||
if (MediaViewer.isOpen && MediaViewer.items === items) {
|
||||
MediaViewerCommands.closeMediaViewer();
|
||||
}
|
||||
}, [items]);
|
||||
const handleDeleteAttachment = useDeleteAttachment(message, deletableAttachmentId, {
|
||||
onDeleted: handleAttachmentDeleted,
|
||||
});
|
||||
const forwardMediaSelection = useMemo<MessageCommands.ForwardMediaSelection | undefined>(() => {
|
||||
if (!currentItem) return undefined;
|
||||
if (currentItem.attachmentId) {
|
||||
@@ -672,6 +691,7 @@ const MediaViewerModalComponent: FC = observer(() => {
|
||||
onOpenInBrowser={handleOpenInBrowser}
|
||||
onCopyLink={handleCopyLink}
|
||||
onCopyMedia={handleCopyMedia}
|
||||
onDeleteAttachment={deletableAttachmentId ? handleDeleteAttachment : undefined}
|
||||
onReply={permissions?.canSendMessages ? handleReply : undefined}
|
||||
onForward={canForwardCurrentMedia ? handleForward : undefined}
|
||||
enablePanZoom={currentItem.type === 'image' || currentItem.type === 'gif' || currentItem.type === 'gifv'}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
ADD_TO_FAVORITES_DESCRIPTOR,
|
||||
COPY_LINK_DESCRIPTOR,
|
||||
DELETE_ATTACHMENT_DESCRIPTOR,
|
||||
REMOVE_FROM_FAVORITES_DESCRIPTOR,
|
||||
ZOOM_IN_DESCRIPTOR,
|
||||
ZOOM_OUT_DESCRIPTOR,
|
||||
@@ -36,16 +37,17 @@ import {
|
||||
MagnifyingGlassMinusIcon,
|
||||
MagnifyingGlassPlusIcon,
|
||||
StarIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
import {type FC, forwardRef, type ReactNode, type Ref} from 'react';
|
||||
import {type FC, forwardRef, type MouseEventHandler, type ReactNode, type Ref} from 'react';
|
||||
|
||||
interface ControlButtonProps {
|
||||
icon: ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
onClick: MouseEventHandler<HTMLButtonElement>;
|
||||
variant?: 'default' | 'primary' | 'danger';
|
||||
active?: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -119,6 +121,7 @@ interface MediaOverlayActionsProps {
|
||||
onOpenInBrowser?: () => void;
|
||||
onCopyLink?: () => void;
|
||||
onCopyMedia?: () => void;
|
||||
onDeleteAttachment?: MouseEventHandler<HTMLButtonElement>;
|
||||
onReset?: () => void;
|
||||
onZoomIn?: () => void;
|
||||
onZoomOut?: () => void;
|
||||
@@ -144,6 +147,7 @@ export const MediaOverlayActions: FC<MediaOverlayActionsProps> = observer(
|
||||
onOpenInBrowser,
|
||||
onCopyLink,
|
||||
onCopyMedia,
|
||||
onDeleteAttachment,
|
||||
onReset,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
@@ -250,6 +254,21 @@ export const MediaOverlayActions: FC<MediaOverlayActionsProps> = observer(
|
||||
data-flx="messaging.media-modal.media-controls.media-overlay-actions.overlay-tooltip-button.download"
|
||||
/>
|
||||
)}
|
||||
{onDeleteAttachment && (
|
||||
<OverlayTooltipButton
|
||||
icon={
|
||||
<TrashIcon
|
||||
size={20}
|
||||
weight="bold"
|
||||
data-flx="messaging.media-modal.media-overlay-actions.delete-attachment-icon"
|
||||
/>
|
||||
}
|
||||
label={i18n._(DELETE_ATTACHMENT_DESCRIPTOR)}
|
||||
onClick={onDeleteAttachment}
|
||||
variant="danger"
|
||||
data-flx="messaging.media-modal.media-controls.media-overlay-actions.overlay-tooltip-button.delete-attachment"
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={styles.overlayActionGap}
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import type {ReactNode} from 'react';
|
||||
import type {MouseEventHandler, ReactNode} from 'react';
|
||||
|
||||
export type ZoomState = 'fit' | 'zoomed';
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface MediaModalProps {
|
||||
onOpenInBrowser?: () => void;
|
||||
onCopyLink?: () => void;
|
||||
onCopyMedia?: () => void;
|
||||
onDeleteAttachment?: MouseEventHandler<HTMLButtonElement>;
|
||||
onReply?: () => void;
|
||||
onForward?: () => void;
|
||||
onInfo?: () => void;
|
||||
|
||||
@@ -9,14 +9,26 @@ import {modal} from '@app/features/ui/commands/ModalCommands';
|
||||
import {Trans, useLingui} from '@lingui/react/macro';
|
||||
import {useCallback} from 'react';
|
||||
|
||||
export function useDeleteAttachment(message: Message | null | undefined, attachmentId: string | null | undefined) {
|
||||
interface DeleteAttachmentOptions {
|
||||
onDeleted?: () => void;
|
||||
}
|
||||
|
||||
export function useDeleteAttachment(
|
||||
message: Message | null | undefined,
|
||||
attachmentId: string | null | undefined,
|
||||
options?: DeleteAttachmentOptions,
|
||||
) {
|
||||
const {i18n} = useLingui();
|
||||
const onDeleted = options?.onDeleted;
|
||||
return useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!message || !attachmentId) return;
|
||||
const deleteAttachment = () => MessageCommands.deleteAttachment(message.channelId, message.id, attachmentId);
|
||||
const deleteAttachment = async () => {
|
||||
await MessageCommands.deleteAttachment(message.channelId, message.id, attachmentId);
|
||||
onDeleted?.();
|
||||
};
|
||||
if (e.shiftKey) {
|
||||
void deleteAttachment();
|
||||
return;
|
||||
@@ -39,6 +51,6 @@ export function useDeleteAttachment(message: Message | null | undefined, attachm
|
||||
)),
|
||||
);
|
||||
},
|
||||
[message, attachmentId, i18n],
|
||||
[message, attachmentId, i18n, onDeleted],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -481,18 +481,15 @@ export const useTextareaSubmit = ({
|
||||
finishMobileEdit();
|
||||
return;
|
||||
}
|
||||
MessageCommands.edit(
|
||||
finishMobileEdit();
|
||||
void MessageCommands.edit(
|
||||
channelId,
|
||||
editingMessage.id,
|
||||
'',
|
||||
undefined,
|
||||
editingMessage._allowedMentions,
|
||||
buildExistingAttachmentEditReferences(editingMessage),
|
||||
).then((result) => {
|
||||
if (result) {
|
||||
finishMobileEdit();
|
||||
}
|
||||
});
|
||||
);
|
||||
return;
|
||||
}
|
||||
MessageCommands.showDeleteConfirmation(i18n, {
|
||||
@@ -506,17 +503,14 @@ export const useTextareaSubmit = ({
|
||||
if (checkCustomEmojiAvailability(resolvedContent)) {
|
||||
return;
|
||||
}
|
||||
MessageCommands.edit(
|
||||
finishMobileEdit();
|
||||
void MessageCommands.edit(
|
||||
channelId,
|
||||
editingMessage.id,
|
||||
resolvedContent,
|
||||
undefined,
|
||||
editingMessage._allowedMentions,
|
||||
).then((result) => {
|
||||
if (result) {
|
||||
finishMobileEdit();
|
||||
}
|
||||
});
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!hasVisibleMessageContent(resolvedContent) && uploadAttachmentsLength === 0 && !hasPendingSticker) {
|
||||
|
||||
@@ -10,15 +10,11 @@ import * as DraftCommands from '@app/features/messaging/commands/DraftCommands';
|
||||
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
|
||||
import {AttachmentUploadConnectivityModal} from '@app/features/messaging/components/alerts/AttachmentUploadConnectivityModal';
|
||||
import {FileSizeTooLargeModal} from '@app/features/messaging/components/alerts/FileSizeTooLargeModal';
|
||||
import {MessageEditFailedModal} from '@app/features/messaging/components/alerts/MessageEditFailedModal';
|
||||
import {MessageEditTooQuickModal} from '@app/features/messaging/components/alerts/MessageEditTooQuickModal';
|
||||
import {MessageSendFailedModal} from '@app/features/messaging/components/alerts/MessageSendFailedModal';
|
||||
import {MessageSendTooQuickModal} from '@app/features/messaging/components/alerts/MessageSendTooQuickModal';
|
||||
import {
|
||||
type MessageLocalSendRateLimitState,
|
||||
type MessageQueueRequestOutcomeStatus,
|
||||
resolveMessageLocalSendRateLimitDecision,
|
||||
resolveMessageQueuePayloadRouteDecision,
|
||||
resolveMessageQueueRequestOutcomeDecision,
|
||||
resolveMessageQueueSendExecutionDecision,
|
||||
} from '@app/features/messaging/state/MessageQueueStateMachine';
|
||||
@@ -33,11 +29,8 @@ import {exceedsMultipartFallbackRequestSize} from '@app/features/messaging/utils
|
||||
import {prepareAttachmentsForNonce} from '@app/features/messaging/utils/MessageAttachmentUtils';
|
||||
import {
|
||||
type ApiAttachmentMetadata,
|
||||
type ApiMessageEditAttachmentMetadata,
|
||||
buildMessageCreateRequest,
|
||||
type MessageCreateRequest,
|
||||
type MessageEditRequest,
|
||||
normalizeMessageEditContent,
|
||||
} from '@app/features/messaging/utils/MessageRequestUtils';
|
||||
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
|
||||
import {MatureContentRejectedModal} from '@app/features/moderation/components/alerts/MatureContentRejectedModal';
|
||||
@@ -120,17 +113,7 @@ interface SendMessagePayload extends BaseMessagePayload {
|
||||
tts?: boolean;
|
||||
}
|
||||
|
||||
interface EditMessagePayload extends BaseMessagePayload {
|
||||
type: 'edit';
|
||||
messageId: string;
|
||||
rateLimitRetryCount?: number;
|
||||
content?: string;
|
||||
allowedMentions?: AllowedMentions;
|
||||
flags?: number;
|
||||
attachments?: Array<ApiMessageEditAttachmentMetadata>;
|
||||
}
|
||||
|
||||
export type MessageQueuePayload = SendMessagePayload | EditMessagePayload;
|
||||
export type MessageQueuePayload = SendMessagePayload;
|
||||
type MessageQueueCompletion<TResult> = {
|
||||
retry: RetryError | null;
|
||||
result?: TResult;
|
||||
@@ -260,11 +243,6 @@ function isRateLimitError(error: HttpError): boolean {
|
||||
return !isSlowmodeError(error);
|
||||
}
|
||||
|
||||
function getRequestErrorOutcomeStatus(error: HttpError): MessageQueueRequestOutcomeStatus {
|
||||
if (isRateLimitError(error)) return 'rateLimit';
|
||||
return 'failure';
|
||||
}
|
||||
|
||||
function isSlowmodeError(error: HttpError): boolean {
|
||||
if (error.status !== 400 && error.status !== 429) return false;
|
||||
const body = getApiErrorBody(error);
|
||||
@@ -351,13 +329,7 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
|
||||
}
|
||||
const error = new Error(`Message queue capacity of ${this.maxSize} entries was reached`);
|
||||
logger.error('Rejected message queue entry because capacity was reached', error);
|
||||
if (message.type === 'send') {
|
||||
this.handleSendError(message.channelId, message.nonce, error, i18n, message.hasAttachments);
|
||||
} else {
|
||||
ModalCommands.push(
|
||||
modal(() => <MessageEditFailedModal data-flx="messaging.message-queue.message-edit-failed-modal--capacity" />),
|
||||
);
|
||||
}
|
||||
this.handleSendError(message.channelId, message.nonce, error, i18n, message.hasAttachments);
|
||||
try {
|
||||
success(undefined, error);
|
||||
} catch (callbackError) {
|
||||
@@ -407,19 +379,7 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
|
||||
message: MessageQueuePayload,
|
||||
completed: (err: RetryError | null, result?: RestResponse<Message>, error?: unknown) => void,
|
||||
): Promise<unknown> | undefined {
|
||||
const route = resolveMessageQueuePayloadRouteDecision({
|
||||
payloadType: (message as {type?: string}).type,
|
||||
});
|
||||
switch (route.type) {
|
||||
case 'send':
|
||||
return this.handleSend(message as SendMessagePayload, completed);
|
||||
case 'edit':
|
||||
return this.handleEdit(message as EditMessagePayload, completed);
|
||||
case 'unknown':
|
||||
logger.error('Unknown message type, completing with null');
|
||||
completed(null, undefined, new Error('Unknown message queue payload'));
|
||||
return undefined;
|
||||
}
|
||||
return this.handleSend(message, completed);
|
||||
}
|
||||
|
||||
private consumeLocalSendAllowance(channelId: string): boolean {
|
||||
@@ -1429,127 +1389,6 @@ export class MessageQueue extends Queue<MessageQueuePayload, RestResponse<Messag
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async handleEdit(
|
||||
payload: EditMessagePayload,
|
||||
completed: (err: RetryError | null, result?: RestResponse<Message>, error?: unknown) => void,
|
||||
): Promise<void> {
|
||||
const {channelId, messageId, content, allowedMentions, flags, attachments} = payload;
|
||||
const abortController = new AbortController();
|
||||
this.abortControllers.set(messageId, abortController);
|
||||
try {
|
||||
logger.debug(`Editing message ${messageId} in channel ${channelId}`);
|
||||
const body = this.buildEditRequestBody(content, allowedMentions, flags, attachments);
|
||||
const response = await http.patch<Message>(Endpoints.CHANNEL_MESSAGE(channelId, messageId), {
|
||||
body,
|
||||
signal: abortController.signal,
|
||||
suppressContentBlockedModal: true,
|
||||
});
|
||||
logger.debug(`Successfully edited message ${messageId} in channel ${channelId}`);
|
||||
completed(null, response);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to edit message ${messageId} in channel ${channelId}:`, error);
|
||||
if (!(error instanceof HttpError)) {
|
||||
ModalCommands.push(
|
||||
modal(() => <MessageEditFailedModal data-flx="messaging.message-queue.message-edit-failed-modal--request" />),
|
||||
);
|
||||
completed(null, undefined, error);
|
||||
return;
|
||||
}
|
||||
const responseErr = error;
|
||||
const outcomeDecision = resolveMessageQueueRequestOutcomeDecision({
|
||||
status: getRequestErrorOutcomeStatus(responseErr),
|
||||
});
|
||||
switch (outcomeDecision.type) {
|
||||
case 'retryRateLimit':
|
||||
this.handleEditRateLimit(payload, responseErr, completed);
|
||||
break;
|
||||
case 'completeFailure':
|
||||
case 'completeSuccess':
|
||||
this.showEditErrorModal(responseErr);
|
||||
completed(null, undefined, responseErr);
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
this.abortControllers.delete(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
private buildEditRequestBody(
|
||||
content?: string,
|
||||
allowedMentions?: AllowedMentions,
|
||||
flags?: number,
|
||||
attachments?: Array<ApiMessageEditAttachmentMetadata>,
|
||||
): MessageEditRequest {
|
||||
const body: MessageEditRequest = {};
|
||||
if (content !== undefined) {
|
||||
body.content = normalizeMessageEditContent(content);
|
||||
}
|
||||
if (allowedMentions !== undefined) {
|
||||
body.allowed_mentions = allowedMentions;
|
||||
}
|
||||
if (flags !== undefined) {
|
||||
body.flags = flags;
|
||||
}
|
||||
if (attachments !== undefined) {
|
||||
body.attachments = attachments;
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private handleEditRateLimit(
|
||||
payload: EditMessagePayload,
|
||||
error: HttpError,
|
||||
completed: (err: RetryError | null, result?: RestResponse<Message>, error?: unknown) => void,
|
||||
): void {
|
||||
const retry = resolveMessageRateLimitRetry(error);
|
||||
const retryCount = payload.rateLimitRetryCount === undefined ? 0 : payload.rateLimitRetryCount;
|
||||
if (retry.automaticRetryDelayMs !== null && retryCount < MESSAGE_SEND_RATE_LIMIT_MAX_AUTOMATIC_RETRIES) {
|
||||
payload.rateLimitRetryCount = retryCount + 1;
|
||||
completed({retryAfter: retry.automaticRetryDelayMs}, undefined, error);
|
||||
return;
|
||||
}
|
||||
completed(null, undefined, error);
|
||||
this.handleEditRateLimitError(retryAfterMsToWholeSeconds(retry.retryAfterMs));
|
||||
}
|
||||
|
||||
private showEditErrorModal(error: HttpError): void {
|
||||
if (isFeatureDisabledError(error)) {
|
||||
ModalCommands.push(
|
||||
modal(() => (
|
||||
<FeatureTemporarilyDisabledModal data-flx="messaging.message-queue.feature-temporarily-disabled-modal--2" />
|
||||
)),
|
||||
);
|
||||
} else if (getApiErrorBody(error)?.code === APIErrorCodes.CONTENT_BLOCKED) {
|
||||
void import('@app/features/auth/components/ContentBlockedHandler').then((m) => m.showContentBlockedModal());
|
||||
} else {
|
||||
ModalCommands.push(
|
||||
modal(() => <MessageEditFailedModal data-flx="messaging.message-queue.message-edit-failed-modal" />),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private handleEditRateLimitError(retryAfter: number | null, onRetry?: () => void): void {
|
||||
ModalCommands.push(
|
||||
modal(() => {
|
||||
if (retryAfter === null) {
|
||||
return (
|
||||
<MessageEditTooQuickModal
|
||||
onRetry={onRetry}
|
||||
data-flx="messaging.message-queue.message-edit-too-quick-modal"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MessageEditTooQuickModal
|
||||
retryAfter={retryAfter}
|
||||
onRetry={onRetry}
|
||||
data-flx="messaging.message-queue.message-edit-too-quick-modal"
|
||||
/>
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default new MessageQueue();
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import type {ChannelMessages} from '@app/features/messaging/state/ChannelMessages';
|
||||
import {ChannelStreamType, createChannelStream} from '@app/features/messaging/utils/MessageGroupingUtils';
|
||||
import {describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@app/features/auth/state/Authentication', () => ({default: {currentUserId: 'me'}}));
|
||||
vi.mock('@app/features/moderation/state/LocalUserSpamOverride', () => ({
|
||||
default: {isUserMarkedAsSpammer: () => false},
|
||||
}));
|
||||
vi.mock('@app/features/user/utils/DateFormatting', () => ({
|
||||
getFormattedFullDate: (date: Date) => date.toISOString().slice(0, 10),
|
||||
}));
|
||||
|
||||
const AUTHOR = {id: 'author-1', flags: 0} as Message['author'];
|
||||
|
||||
const ID = {
|
||||
first: '1519773906704011264',
|
||||
second: '1519773906708205568',
|
||||
third: '1519773906712399872',
|
||||
fourth: '1519773906716594176',
|
||||
} as const;
|
||||
|
||||
function message(id: string, minute: number, blocked = false): Message {
|
||||
return {
|
||||
id,
|
||||
author: AUTHOR,
|
||||
blocked,
|
||||
ignored: false,
|
||||
timestamp: new Date(Date.UTC(2026, 7, 27, 12, minute)),
|
||||
type: 0,
|
||||
webhookId: null,
|
||||
mentions: [],
|
||||
mentionRoles: [],
|
||||
mentionEveryone: false,
|
||||
isUserMessage: () => true,
|
||||
hasFlag: () => false,
|
||||
} as unknown as Message;
|
||||
}
|
||||
|
||||
function channelMessages(messages: Array<Message>): ChannelMessages {
|
||||
return {
|
||||
forEach: (callback: (message: Message) => boolean | undefined) => {
|
||||
for (const entry of messages) {
|
||||
if (callback(entry) === false) return;
|
||||
}
|
||||
},
|
||||
jumpDestinationId: null,
|
||||
jumpHighlight: false,
|
||||
jumpTicket: null,
|
||||
} as unknown as ChannelMessages;
|
||||
}
|
||||
|
||||
const channel = {id: 'channel-1', isPrivate: () => false, getGuildId: () => 'guild-1'} as unknown as Channel;
|
||||
|
||||
function buildStream(messages: Array<Message>, oldestUnreadMessageId: string | null) {
|
||||
return createChannelStream({
|
||||
channel,
|
||||
messages: channelMessages(messages),
|
||||
oldestUnreadMessageId,
|
||||
treatSpam: false,
|
||||
});
|
||||
}
|
||||
|
||||
function countUnreadMarkers(stream: Array<ReturnType<typeof buildStream>[number]>): number {
|
||||
let count = 0;
|
||||
for (const item of stream) {
|
||||
if (item.type === ChannelStreamType.DIVIDER && item.unreadId != null) count++;
|
||||
if (item.type === ChannelStreamType.MESSAGE && item.showUnreadDividerBefore) count++;
|
||||
if (Array.isArray(item.content)) {
|
||||
count += countUnreadMarkers(item.content as Array<ReturnType<typeof buildStream>[number]>);
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function findUnreadDivider(stream: Array<ReturnType<typeof buildStream>[number]>) {
|
||||
for (const item of stream) {
|
||||
if (item.type === ChannelStreamType.DIVIDER && item.unreadId != null) return item;
|
||||
if (item.type === ChannelStreamType.MESSAGE && item.showUnreadDividerBefore) return item;
|
||||
if (item.type === ChannelStreamType.MESSAGE_GROUP_BLOCKED) {
|
||||
const nested = item.content as Array<ReturnType<typeof buildStream>[number]>;
|
||||
const divider = nested.find((entry) => entry.type === ChannelStreamType.DIVIDER && entry.unreadId != null);
|
||||
if (divider) return divider;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
describe('createChannelStream unread divider', () => {
|
||||
it('marks the unread boundary on a plain message', () => {
|
||||
const messages = [message(ID.first, 0), message(ID.second, 1), message(ID.third, 2)];
|
||||
expect(findUnreadDivider(buildStream(messages, ID.second))).not.toBeNull();
|
||||
});
|
||||
|
||||
it('emits a divider inside the collapsed group when the boundary is a blocked message', () => {
|
||||
const messages = [message(ID.first, 0), message(ID.second, 1, true), message(ID.third, 2, true)];
|
||||
const stream = buildStream(messages, ID.second);
|
||||
const group = stream.find((item) => item.type === ChannelStreamType.MESSAGE_GROUP_BLOCKED);
|
||||
expect(group).toBeDefined();
|
||||
const nested = group!.content as Array<(typeof stream)[number]>;
|
||||
expect(nested.some((item) => item.type === ChannelStreamType.DIVIDER && item.unreadId === ID.second)).toBe(true);
|
||||
expect(group!.hasUnread).toBe(true);
|
||||
});
|
||||
|
||||
it('emits a divider inside the collapsed group when the stored boundary is no longer loaded', () => {
|
||||
const messages = [message(ID.third, 0, true), message(ID.fourth, 1, true)];
|
||||
const stream = buildStream(messages, ID.first);
|
||||
const group = stream.find((item) => item.type === ChannelStreamType.MESSAGE_GROUP_BLOCKED);
|
||||
expect(group).toBeDefined();
|
||||
const nested = group!.content as Array<(typeof stream)[number]>;
|
||||
expect(nested.some((item) => item.type === ChannelStreamType.DIVIDER && item.unreadId === ID.third)).toBe(true);
|
||||
});
|
||||
|
||||
it('never leaves the boundary unrepresented when there is an unread message id', () => {
|
||||
const messages = [message(ID.first, 0), message(ID.second, 1, true), message(ID.third, 2)];
|
||||
expect(findUnreadDivider(buildStream(messages, ID.second))).not.toBeNull();
|
||||
});
|
||||
|
||||
it('merges the boundary into the date divider when it is the first message of a day', () => {
|
||||
const dayOne = message(ID.first, 0);
|
||||
const dayTwo = message(ID.third, 1);
|
||||
(dayTwo as {timestamp: Date}).timestamp = new Date(Date.UTC(2026, 7, 28, 9, 0));
|
||||
const stream = buildStream([dayOne, dayTwo], ID.third);
|
||||
const dateDivider = stream.find((item) => item.type === ChannelStreamType.DIVIDER && item.unreadId === ID.third);
|
||||
expect(dateDivider).toBeDefined();
|
||||
expect(countUnreadMarkers(stream)).toBe(1);
|
||||
});
|
||||
|
||||
it('marks the boundary exactly once', () => {
|
||||
const messages = [message(ID.first, 0), message(ID.second, 1, true), message(ID.third, 2)];
|
||||
expect(countUnreadMarkers(buildStream(messages, ID.second))).toBe(1);
|
||||
expect(countUnreadMarkers(buildStream([message(ID.first, 0), message(ID.second, 1)], ID.second))).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -197,18 +197,22 @@ export function createChannelStream(props: {
|
||||
}
|
||||
}
|
||||
let shouldShowUnreadDividerBefore = false;
|
||||
let shouldOpenCollapsedGroupWithUnreadDivider = false;
|
||||
if (oldestUnreadMessageId === message.id && unreadTimestamp != null) {
|
||||
if (lastItem?.type === ChannelStreamType.DIVIDER) {
|
||||
lastItem.unreadId = message.id;
|
||||
} else if (collapsedGroupItem !== null) {
|
||||
shouldOpenCollapsedGroupWithUnreadDivider = true;
|
||||
} else {
|
||||
shouldShowUnreadDividerBefore = true;
|
||||
if (collapsedGroupItem !== null) {
|
||||
collapsedGroupItem.hasUnread = true;
|
||||
}
|
||||
}
|
||||
unreadTimestamp = null;
|
||||
} else if (unreadTimestamp != null && extractTimestamp(message.id) > unreadTimestamp) {
|
||||
shouldShowUnreadDividerBefore = true;
|
||||
if (collapsedGroupItem !== null) {
|
||||
shouldOpenCollapsedGroupWithUnreadDivider = true;
|
||||
} else {
|
||||
shouldShowUnreadDividerBefore = true;
|
||||
}
|
||||
unreadTimestamp = null;
|
||||
}
|
||||
let prevMessageForGrouping: Message | undefined;
|
||||
@@ -242,7 +246,12 @@ export function createChannelStream(props: {
|
||||
messageItem.jumpTarget = true;
|
||||
}
|
||||
if (collapsedGroupItem !== null) {
|
||||
(collapsedGroupItem.content as Array<ChannelStreamItem>).push(messageItem);
|
||||
const collapsedContentItems = collapsedGroupItem.content as Array<ChannelStreamItem>;
|
||||
if (shouldOpenCollapsedGroupWithUnreadDivider) {
|
||||
collapsedContentItems.push({type: ChannelStreamType.DIVIDER, content: '', unreadId: message.id});
|
||||
collapsedGroupItem.hasUnread = true;
|
||||
}
|
||||
collapsedContentItems.push(messageItem);
|
||||
if (messageItem.jumpTarget) {
|
||||
collapsedGroupItem.hasJumpTarget = true;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,13 @@ export interface MessageEditRequest {
|
||||
flags?: number;
|
||||
}
|
||||
|
||||
export interface MessageEditPayload {
|
||||
content?: string;
|
||||
attachments?: Array<ApiMessageEditAttachmentMetadata>;
|
||||
allowedMentions?: AllowedMentions;
|
||||
flags?: number;
|
||||
}
|
||||
|
||||
export interface MessageCreatePayload {
|
||||
content?: string | null;
|
||||
nonce?: string;
|
||||
@@ -121,6 +128,24 @@ export function buildMessageCreateRequest(payload: MessageCreatePayload): Messag
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
export function buildMessageEditRequest(payload: MessageEditPayload): MessageEditRequest {
|
||||
const {content, attachments, allowedMentions, flags} = payload;
|
||||
const requestBody: MessageEditRequest = {};
|
||||
if (content !== undefined) {
|
||||
requestBody.content = normalizeMessageEditContent(content);
|
||||
}
|
||||
if (attachments !== undefined) {
|
||||
requestBody.attachments = attachments;
|
||||
}
|
||||
if (allowedMentions !== undefined) {
|
||||
requestBody.allowed_mentions = allowedMentions;
|
||||
}
|
||||
if (flags !== undefined) {
|
||||
requestBody.flags = flags;
|
||||
}
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
const isSilentMessage = (content: string): boolean => {
|
||||
return content.startsWith('@silent ');
|
||||
};
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {initializeEmojiParser} from '@app/features/messaging/utils/markdown/EmojiProviderSetup';
|
||||
import {ParserFlags} from '@app/features/messaging/utils/markdown/parser/Enums';
|
||||
import {parseMarkdownAstWithWasm} from '@app/features/messaging/utils/markdown/parser/MarkdownParserWasm';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {beforeAll, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
vi.mock('@app/features/expressions/utils/EmojiUtils', () => ({
|
||||
getEmojiURL: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@lingui/core/macro', () => ({
|
||||
msg: (descriptor: unknown) => descriptor,
|
||||
}));
|
||||
|
||||
describe('markdown parser wasm blockquotes', () => {
|
||||
it('preserves consecutive empty blockquote lines', () => {
|
||||
@@ -62,3 +71,36 @@ describe('markdown parser wasm headings', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdown parser wasm default-presentation emoji', () => {
|
||||
beforeAll(() => {
|
||||
initializeEmojiParser();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['⌚', 'watch', '231a'],
|
||||
['⌛', 'hourglass', '231b'],
|
||||
['⏩', 'fast_forward', '23e9'],
|
||||
['⏪', 'rewind', '23ea'],
|
||||
['⏫', 'arrow_double_up', '23eb'],
|
||||
['⏬', 'arrow_double_down', '23ec'],
|
||||
['⏰', 'alarm_clock', '23f0'],
|
||||
['⏳', 'hourglass_flowing_sand', '23f3'],
|
||||
])('renders %s as an emoji without a variation selector', (raw, name, codepoints) => {
|
||||
expect(parseMarkdownAstWithWasm(raw, 0)).toEqual({
|
||||
nodes: [{type: 'Emoji', kind: {kind: 'Standard', raw, codepoints, name}}],
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps text-presentation symbols as plain text when typed bare', () => {
|
||||
expect(parseMarkdownAstWithWasm('❤', 0)).toEqual({
|
||||
nodes: [{type: 'Text', content: '❤'}],
|
||||
});
|
||||
});
|
||||
|
||||
it('still renders text-presentation symbols carrying a variation selector', () => {
|
||||
expect(parseMarkdownAstWithWasm('❤️', 0)).toEqual({
|
||||
nodes: [{type: 'Emoji', kind: {kind: 'Standard', raw: '❤️', codepoints: '2764', name: 'heart'}}],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,15 +217,6 @@ const SPECIAL_SHORTCODES: Record<string, string> = {
|
||||
registered: '®',
|
||||
};
|
||||
|
||||
function needsVariationSelector(codePoint: number): boolean {
|
||||
return (
|
||||
(codePoint >= 0x2190 && codePoint <= 0x21ff) ||
|
||||
(codePoint >= 0x2300 && codePoint <= 0x23ff) ||
|
||||
(codePoint >= 0x2600 && codePoint <= 0x27bf) ||
|
||||
(codePoint >= 0x2900 && codePoint <= 0x297f)
|
||||
);
|
||||
}
|
||||
|
||||
function appendContextLine(parts: Array<string>): string {
|
||||
return `${parts.join('\t')}\n`;
|
||||
}
|
||||
@@ -244,10 +235,6 @@ function buildEmojiContext(input: string): string {
|
||||
while ((match = emojiRegex.exec(input)) !== null) {
|
||||
const candidate = match[0];
|
||||
if (!candidate || PLAINTEXT_SYMBOLS.has(candidate)) continue;
|
||||
const hasVariationSelector = candidate.includes('️');
|
||||
const codePoint = candidate.codePointAt(0) || 0;
|
||||
const isDingbat = codePoint >= 0x2600 && codePoint <= 0x27bf;
|
||||
if (!isDingbat && needsVariationSelector(codePoint) && !hasVariationSelector) continue;
|
||||
const name = provider.getSurrogateName(candidate);
|
||||
if (!name) continue;
|
||||
const candidateBytes = textEncoder.encode(candidate).byteLength;
|
||||
|
||||
@@ -308,11 +308,14 @@ export class AuthSessionManager {
|
||||
}
|
||||
this.send({type: 'initialize.start'});
|
||||
try {
|
||||
await this.loadStoredAccounts();
|
||||
const storedToken = parseStoredSessionValue(this.deps.appStorage.getItem(AuthSessionStorageKey.Token));
|
||||
const storedUserId = parseStoredSessionValue(this.deps.appStorage.getItem(AuthSessionStorageKey.UserId));
|
||||
await this.loadStoredAccounts();
|
||||
logger.debug(`Loaded from storage: token=${storedToken ? 'present' : 'null'}, userId=${storedUserId ?? 'null'}`);
|
||||
const storedAccount = storedUserId ? this._snapshot.context.accounts.get(storedUserId) : undefined;
|
||||
let storedAccount = storedUserId ? this._snapshot.context.accounts.get(storedUserId) : undefined;
|
||||
if (storedToken && storedUserId && storedAccount?.token !== storedToken) {
|
||||
storedAccount = this.recoverStoredActiveAccount(storedToken, storedUserId, storedAccount);
|
||||
}
|
||||
if (storedToken && storedUserId && storedAccount?.token === storedToken) {
|
||||
this.send({type: 'initialize.tokenLoaded', token: storedToken, userId: storedUserId});
|
||||
this.deps.restoreLocalPresenceIntent(storedAccount.presenceIntent ?? null);
|
||||
@@ -328,6 +331,25 @@ export class AuthSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
private recoverStoredActiveAccount(token: string, userId: string, existing?: Account): Account {
|
||||
const instance = this.deps.getRuntimeSnapshot();
|
||||
const account: Account = {
|
||||
userId,
|
||||
token,
|
||||
userData: existing?.userData,
|
||||
presenceIntent: existing?.presenceIntent ?? null,
|
||||
lastActive: this.deps.now(),
|
||||
instance,
|
||||
isValid: true,
|
||||
};
|
||||
this.send({type: 'account.upsert', account});
|
||||
void this.deps.accountStorage
|
||||
.stashAccountData(userId, token, account.userData, instance, account.presenceIntent)
|
||||
.catch((error) => logger.warn('Failed to persist recovered active account', error));
|
||||
logger.info('Recovered stored active account after local account data was unavailable');
|
||||
return account;
|
||||
}
|
||||
|
||||
private async loadStoredAccounts(): Promise<void> {
|
||||
try {
|
||||
const stored = await this.deps.accountStorage.getAllAccounts();
|
||||
|
||||
@@ -78,6 +78,63 @@ type AttemptDecision =
|
||||
| {next: 'retry-after'; delayMs: number; mode: 'backoff' | 'fixed'}
|
||||
| {next: 'fail'; error: unknown};
|
||||
|
||||
interface OnlineWaiter {
|
||||
resolve: () => void;
|
||||
signal: AbortSignal | undefined;
|
||||
onAbort: () => void;
|
||||
}
|
||||
|
||||
const onlineWaiters = new Set<OnlineWaiter>();
|
||||
let onlineListenerActive = false;
|
||||
|
||||
function createRequestAbortError(): DOMException {
|
||||
return new DOMException('Request aborted', 'AbortError');
|
||||
}
|
||||
|
||||
function removeOnlineListener(): void {
|
||||
if (!onlineListenerActive) return;
|
||||
window.removeEventListener('online', resolveOnlineWaiters);
|
||||
onlineListenerActive = false;
|
||||
}
|
||||
|
||||
function releaseOnlineWaiter(waiter: OnlineWaiter): boolean {
|
||||
if (!onlineWaiters.delete(waiter)) return false;
|
||||
waiter.signal?.removeEventListener('abort', waiter.onAbort);
|
||||
if (onlineWaiters.size === 0) removeOnlineListener();
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveOnlineWaiters(): void {
|
||||
const pending = Array.from(onlineWaiters);
|
||||
onlineWaiters.clear();
|
||||
removeOnlineListener();
|
||||
for (const waiter of pending) {
|
||||
waiter.signal?.removeEventListener('abort', waiter.onAbort);
|
||||
waiter.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
function waitUntilOnline(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) return Promise.reject(createRequestAbortError());
|
||||
if (navigator.onLine) return Promise.resolve();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const waiter: OnlineWaiter = {
|
||||
resolve,
|
||||
signal,
|
||||
onAbort: () => {
|
||||
if (releaseOnlineWaiter(waiter)) reject(createRequestAbortError());
|
||||
},
|
||||
};
|
||||
onlineWaiters.add(waiter);
|
||||
if (signal) signal.addEventListener('abort', waiter.onAbort, {once: true});
|
||||
if (!onlineListenerActive) {
|
||||
window.addEventListener('online', resolveOnlineWaiters);
|
||||
onlineListenerActive = true;
|
||||
}
|
||||
if (navigator.onLine) queueMicrotask(resolveOnlineWaiters);
|
||||
});
|
||||
}
|
||||
|
||||
export class RestClient {
|
||||
private readonly state: RuntimeState = {
|
||||
baseUrl: '/api',
|
||||
@@ -216,6 +273,7 @@ async function runRetryLoop<T>(
|
||||
attempt: number,
|
||||
): Promise<RestResponse<T>> {
|
||||
const plan = composePlan(state, method, path, options, sudoApplied);
|
||||
if (plan.retries > 0) await waitUntilOnline(plan.signal);
|
||||
const pacingHit = consultPacing(state.pacing, plan.rateLimitKey);
|
||||
if (pacingHit) {
|
||||
if (plan.mode === 'auto-retry') {
|
||||
@@ -240,7 +298,11 @@ async function runRetryLoop<T>(
|
||||
return finalizeAfterRetriesExhausted<T>(state, plan, outcome);
|
||||
}
|
||||
const wait = decision.mode === 'backoff' ? computeBackoffMs(attempt) : decision.delayMs;
|
||||
await delay(wait, plan.signal);
|
||||
if (outcome.status === 'transport-error' && !navigator.onLine) {
|
||||
await waitUntilOnline(plan.signal);
|
||||
} else {
|
||||
await delay(wait, plan.signal);
|
||||
}
|
||||
return runRetryLoop<T>(state, method, path, options, sudoApplied, attempt + 1);
|
||||
}
|
||||
case 'fail':
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {JumpTypes} from '@fluxer/constants/src/JumpConstants';
|
||||
import {NEW_MESSAGES_BAR_BUFFER} from '@fluxer/constants/src/LimitConstants';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {
|
||||
CENTRE_ALIGNMENT_LIFT,
|
||||
InitialScrollIntent,
|
||||
MESSAGE_REVEAL_PADDING,
|
||||
resolveContainerResizeShift,
|
||||
resolveInitialScrollIntent,
|
||||
type ScrollerState,
|
||||
shouldAnimateMessageJump,
|
||||
} from './shared';
|
||||
@@ -83,3 +86,42 @@ describe('resolveContainerResizeShift', () => {
|
||||
expect(resolve(10, state(1400, 400, 2000))).toEqual({kind: 'none'});
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveInitialScrollIntent', () => {
|
||||
it('anchors to the unread boundary only when the divider is expected on screen', () => {
|
||||
expect(
|
||||
resolveInitialScrollIntent({
|
||||
channelType: ChannelTypes.GUILD_TEXT,
|
||||
rememberedScrollTop: 900,
|
||||
hasPendingUnreads: true,
|
||||
}),
|
||||
).toBe(InitialScrollIntent.UNREAD_BOUNDARY);
|
||||
expect(
|
||||
resolveInitialScrollIntent({
|
||||
channelType: ChannelTypes.GUILD_TEXT,
|
||||
rememberedScrollTop: 900,
|
||||
hasPendingUnreads: false,
|
||||
}),
|
||||
).toBe(InitialScrollIntent.SAVED_OFFSET);
|
||||
});
|
||||
|
||||
it('falls back to the bottom rather than the top when there is no unread boundary and no saved offset', () => {
|
||||
expect(
|
||||
resolveInitialScrollIntent({
|
||||
channelType: ChannelTypes.GUILD_TEXT,
|
||||
rememberedScrollTop: null,
|
||||
hasPendingUnreads: false,
|
||||
}),
|
||||
).toBe(InitialScrollIntent.BOTTOM);
|
||||
});
|
||||
|
||||
it('never anchors to the unread boundary in voice channels', () => {
|
||||
expect(
|
||||
resolveInitialScrollIntent({
|
||||
channelType: ChannelTypes.GUILD_VOICE,
|
||||
rememberedScrollTop: null,
|
||||
hasPendingUnreads: true,
|
||||
}),
|
||||
).toBe(InitialScrollIntent.BOTTOM);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const makeChannel = (id: string) => ({
|
||||
id,
|
||||
type: 0,
|
||||
guildId: 'guild-1',
|
||||
isPrivate: () => false,
|
||||
getGuildId: () => 'guild-1',
|
||||
});
|
||||
const loadedMessages: Array<{id: string; author: {id: string}}> = [];
|
||||
let hasMoreBefore = false;
|
||||
let hasNewestMessages = true;
|
||||
|
||||
vi.mock('@app/features/channel/state/Channels', () => ({
|
||||
default: {getChannel: (id: string) => makeChannel(id), getBasicChannel: (id: string) => makeChannel(id)},
|
||||
}));
|
||||
vi.mock('@app/features/messaging/state/MessagingMessages', () => ({
|
||||
default: {
|
||||
getMessages: () => ({
|
||||
get hasMoreBefore() {
|
||||
return hasMoreBefore;
|
||||
},
|
||||
get length() {
|
||||
return loadedMessages.length;
|
||||
},
|
||||
jumpDestinationId: null,
|
||||
hasNewestMessages: () => hasNewestMessages,
|
||||
has: (id: string) => loadedMessages.some((m) => m.id === id),
|
||||
last: () => loadedMessages[loadedMessages.length - 1],
|
||||
forEachBuffered: (cb: (m: unknown) => void) => {
|
||||
for (const m of loadedMessages) cb(m);
|
||||
},
|
||||
}),
|
||||
},
|
||||
}));
|
||||
vi.mock('@app/features/user/state/Users', () => ({default: {getCurrentUser: () => ({id: 'me'})}}));
|
||||
vi.mock('@app/features/relationship/state/Relationships', () => ({default: {isBlocked: () => false}}));
|
||||
vi.mock('@app/features/member/state/GuildMembers', () => ({default: {getMember: () => null}}));
|
||||
vi.mock('@app/features/user/state/UserGuildSettings', () => ({
|
||||
default: {
|
||||
isEveryoneMentionSuppressed: () => false,
|
||||
isRoleMentionSuppressed: () => false,
|
||||
isGuildOrChannelMuted: () => false,
|
||||
},
|
||||
}));
|
||||
vi.mock('@app/features/ui/state/Dimension', () => ({default: {channelPinnedToEnd: () => false}}));
|
||||
vi.mock('@app/features/notification/state/NotificationAutoAck', () => ({
|
||||
default: {isAutomaticAckEnabled: () => false},
|
||||
}));
|
||||
vi.mock('@app/features/platform/transport/RestTransport', () => ({http: {post: vi.fn(), get: vi.fn()}}));
|
||||
|
||||
const {default: ReadStates} = await import('@app/features/read_state/state/ReadStates');
|
||||
|
||||
const ID = {
|
||||
ack: '1519773906704011264',
|
||||
newer: '1519773906708205568',
|
||||
};
|
||||
|
||||
let nextChannelId = 0;
|
||||
|
||||
function seedReadChannel() {
|
||||
const channelId = `channel-${++nextChannelId}`;
|
||||
const state = ReadStates.get(channelId);
|
||||
state.readStateKnown = true;
|
||||
state.ackMessageId = ID.ack;
|
||||
state.lastMessageId = ID.ack;
|
||||
state.unreadCount = 0;
|
||||
state.oldestUnreadMessageId = null;
|
||||
return {channelId, state};
|
||||
}
|
||||
|
||||
describe('ReadStates unread invariant', () => {
|
||||
beforeEach(() => {
|
||||
loadedMessages.length = 0;
|
||||
hasMoreBefore = false;
|
||||
hasNewestMessages = true;
|
||||
});
|
||||
|
||||
it('never reports a positive unread count without an unread anchor after a passive update', () => {
|
||||
const {channelId} = seedReadChannel();
|
||||
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.newer}, 'guild-1');
|
||||
const count = ReadStates.getUnreadCount(channelId);
|
||||
const anchor = ReadStates.getVisualUnreadMessageId(channelId);
|
||||
expect(count > 0).toBe(anchor != null);
|
||||
});
|
||||
|
||||
it('keeps the channel unread for the sidebar even with no anchor to draw a divider at', () => {
|
||||
const {channelId} = seedReadChannel();
|
||||
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.newer}, 'guild-1');
|
||||
expect(ReadStates.hasUnread(channelId)).toBe(true);
|
||||
});
|
||||
|
||||
it('clears a stale unread once the server walks the last message id back', () => {
|
||||
const {channelId} = seedReadChannel();
|
||||
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.newer}, 'guild-1');
|
||||
expect(ReadStates.hasUnread(channelId)).toBe(true);
|
||||
ReadStates.handlePassiveLastMessageUpdates({[channelId]: ID.ack}, 'guild-1');
|
||||
expect(ReadStates.hasUnread(channelId)).toBe(false);
|
||||
expect(ReadStates.getUnreadCount(channelId)).toBe(0);
|
||||
expect(ReadStates.getVisualUnreadMessageId(channelId)).toBeNull();
|
||||
});
|
||||
|
||||
it('anchors the divider when a window is loaded whose ack sits outside it', () => {
|
||||
const {channelId, state} = seedReadChannel();
|
||||
state.lastMessageId = ID.newer;
|
||||
hasMoreBefore = true;
|
||||
loadedMessages.push({id: ID.newer, author: {id: 'someone'}});
|
||||
ReadStates.handleLoadMessages({channelId, messages: []});
|
||||
expect(ReadStates.getVisualUnreadMessageId(channelId)).toBe(ID.newer);
|
||||
expect(ReadStates.getUnreadCount(channelId) > 0).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -141,16 +141,11 @@ class ReadStates {
|
||||
this.refreshMentionChannel(state.channelId);
|
||||
}
|
||||
|
||||
private refreshUnreadEstimate(state: ReadStateEntry): void {
|
||||
private clearUnreadStateIfRead(state: ReadStateEntry): void {
|
||||
if (!state.hasUnread()) {
|
||||
state.estimated = false;
|
||||
state.unreadCount = 0;
|
||||
state.oldestUnreadMessageId = null;
|
||||
return;
|
||||
}
|
||||
if (state.unreadCount === 0) {
|
||||
state.estimated = true;
|
||||
state.unreadCount = Math.max(1, state.mentionCount);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -416,7 +411,7 @@ class ReadStates {
|
||||
if (!channelsWithReadState.has(channel.id as ChannelId)) {
|
||||
this.setMentionCount(state, 0);
|
||||
}
|
||||
this.refreshUnreadEstimate(state);
|
||||
this.clearUnreadStateIfRead(state);
|
||||
}
|
||||
this.notifyChange(undefined, {global: true});
|
||||
});
|
||||
@@ -436,7 +431,7 @@ class ReadStates {
|
||||
state.lastMessageId = channel.last_message_id ?? null;
|
||||
state.lastPinTimestamp = parseTimestamp(channel.last_pin_timestamp);
|
||||
state.storedGuildId = action.guild.id;
|
||||
this.refreshUnreadEstimate(state);
|
||||
this.clearUnreadStateIfRead(state);
|
||||
this.refreshMentionChannel(channel.id);
|
||||
}
|
||||
}
|
||||
@@ -452,11 +447,16 @@ class ReadStates {
|
||||
if (newestMessage != null && isNewerMessageId(newestMessage.id, state.lastMessageId)) {
|
||||
state.lastMessageId = newestMessage.id;
|
||||
}
|
||||
if (state.hasUnread()) {
|
||||
const landedOnNewestWindow = messages.hasNewestMessages();
|
||||
const landedOnAck = state.ackMessageId != null && messages.jumpDestinationId === state.ackMessageId;
|
||||
if (state.hasUnread() || landedOnNewestWindow || landedOnAck) {
|
||||
state.rebuild();
|
||||
this.refreshUnreadEstimate(state);
|
||||
this.clearUnreadStateIfRead(state);
|
||||
} else if (action.isAfter && state.ackMessageId != null && messages.has(state.ackMessageId, true)) {
|
||||
state.unreadCount += action.messages.length;
|
||||
if (state.oldestUnreadMessageId == null) {
|
||||
state.rebuild();
|
||||
}
|
||||
}
|
||||
this.notifyChange(action.channelId);
|
||||
}
|
||||
@@ -533,6 +533,7 @@ class ReadStates {
|
||||
}
|
||||
|
||||
handleMessageDelete(action: {channelId: string}): void {
|
||||
this.getIfExists(action.channelId)?.rebuild();
|
||||
this.notifyChange(action.channelId);
|
||||
}
|
||||
|
||||
@@ -560,7 +561,7 @@ class ReadStates {
|
||||
state.readStateKnown = true;
|
||||
state.ackMessageId = action.channel.last_message_id;
|
||||
} else if (GUILD_TEXT_BASED_CHANNEL_TYPES.has(action.channel.type) && state.hasUnread()) {
|
||||
this.refreshUnreadEstimate(state);
|
||||
this.clearUnreadStateIfRead(state);
|
||||
}
|
||||
this.notifyChange(action.channel.id);
|
||||
}
|
||||
@@ -582,10 +583,10 @@ class ReadStates {
|
||||
changed = state.guildId !== guildId;
|
||||
state.storedGuildId = guildId;
|
||||
}
|
||||
if (isNewerMessageId(lastMessageId, state.lastMessageId)) {
|
||||
if (lastMessageId !== state.lastMessageId) {
|
||||
state.lastMessageId = lastMessageId;
|
||||
changed = true;
|
||||
this.refreshUnreadEstimate(state);
|
||||
this.clearUnreadStateIfRead(state);
|
||||
}
|
||||
if (changed) {
|
||||
changedChannels.push(channelId as ChannelId);
|
||||
@@ -747,7 +748,7 @@ class ReadStates {
|
||||
this.setMentionCount(state, mentionCount);
|
||||
}
|
||||
if (decision.shouldRefreshUnreadEstimate) {
|
||||
this.refreshUnreadEstimate(state);
|
||||
this.clearUnreadStateIfRead(state);
|
||||
}
|
||||
if (decision.shouldNotify) {
|
||||
this.notifyChange(action.channelId);
|
||||
@@ -1042,7 +1043,7 @@ class ReadStates {
|
||||
state.serverVersion = readState.version ?? state.serverVersion;
|
||||
this.setMentionCount(state, readState.mention_count ?? 0);
|
||||
state.rebuild(null, {recomputeMentions: manual});
|
||||
this.refreshUnreadEstimate(state);
|
||||
this.clearUnreadStateIfRead(state);
|
||||
this.notifyChange(readState.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -274,13 +274,13 @@ export class ReadStateEntry {
|
||||
});
|
||||
const hasUnreadBoundary = foundAckMessage || loadedOlderMessages || !messages.hasMoreBefore;
|
||||
const hasNewestMessages = messages.hasNewestMessages();
|
||||
this.estimated = !hasNewestMessages || !hasUnreadBoundary;
|
||||
this.estimated = !hasNewestMessages || (!hasUnreadBoundary && messages.length === loadedUnreadCount);
|
||||
if (this.estimated) {
|
||||
this.unreadCount = Math.max(previousUnreadCount, loadedUnreadCount);
|
||||
} else {
|
||||
this.unreadCount = loadedUnreadCount;
|
||||
}
|
||||
this.oldestUnreadMessageId = hasUnreadBoundary ? (this.storedOldestUnreadMessageId ?? oldestUnread) : null;
|
||||
this.oldestUnreadMessageId = this.storedOldestUnreadMessageId ?? oldestUnread;
|
||||
}
|
||||
|
||||
shouldMentionFor(message: MessageModel | WireMessage, userId: string, isPrivate: boolean): boolean {
|
||||
|
||||
@@ -9,14 +9,7 @@ import {Scroller} from '@app/features/ui/components/Scroller';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import type {IconProps} from '@phosphor-icons/react';
|
||||
import {
|
||||
ChatCenteredDotsIcon,
|
||||
CheckIcon,
|
||||
EnvelopeSimpleIcon,
|
||||
GlobeIcon,
|
||||
HashIcon,
|
||||
UsersIcon,
|
||||
} from '@phosphor-icons/react';
|
||||
import {ChatCenteredDotsIcon, ChatCircleIcon, CheckIcon, GlobeIcon, HashIcon, UsersIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
import type React from 'react';
|
||||
|
||||
@@ -26,7 +19,7 @@ const SEARCH_IN_DESCRIPTOR = msg({
|
||||
});
|
||||
const SCOPE_ICON_COMPONENTS: Record<MessageSearchScope, React.ComponentType<IconProps>> = {
|
||||
current: HashIcon,
|
||||
all_dms: EnvelopeSimpleIcon,
|
||||
all_dms: ChatCircleIcon,
|
||||
open_dms: ChatCenteredDotsIcon,
|
||||
all_guilds: GlobeIcon,
|
||||
all: UsersIcon,
|
||||
|
||||
@@ -61,10 +61,7 @@ function getChannelRecency(channel: {id: string; lastMessageId: string | null}):
|
||||
}
|
||||
|
||||
function getChannelSortWeight(channelId: string, baseWeight: number): number {
|
||||
const unreadCount = ReadStates.getUnreadCount(channelId);
|
||||
const mentionCount = ReadStates.getMentionCount(channelId);
|
||||
const hasUnread = unreadCount > 0 || mentionCount > 0;
|
||||
return hasUnread ? baseWeight + UNREAD_SORT_WEIGHT_BOOST : baseWeight;
|
||||
return ReadStates.isUnreadOrMentioned(channelId) ? baseWeight + UNREAD_SORT_WEIGHT_BOOST : baseWeight;
|
||||
}
|
||||
|
||||
export function buildChannelCandidate(
|
||||
|
||||
@@ -182,7 +182,7 @@ export function useGuildMenuData(guild: Guild, options: UseGuildMenuDataOptions)
|
||||
() => ({
|
||||
handleMarkAsRead: () => {
|
||||
const channelIds = channels
|
||||
.filter((channel) => ReadStates.getUnreadCount(channel.id) > 0)
|
||||
.filter((channel) => ReadStates.isUnreadOrMentioned(channel.id))
|
||||
.map((channel) => channel.id);
|
||||
if (channelIds.length > 0) {
|
||||
void ReadStateCommands.bulkAckChannels(channelIds);
|
||||
|
||||
@@ -100,7 +100,7 @@ export const MarkAsReadMenuItem: React.FC<GuildMenuItemProps> = observer(({guild
|
||||
}, [channels]);
|
||||
const handleMarkAsRead = useCallback(() => {
|
||||
const channelIds = channels
|
||||
.filter((channel) => ReadStates.getUnreadCount(channel.id) > 0)
|
||||
.filter((channel) => ReadStates.isUnreadOrMentioned(channel.id))
|
||||
.map((channel) => channel.id);
|
||||
if (channelIds.length > 0) {
|
||||
void ReadStateCommands.bulkAckChannels(channelIds);
|
||||
|
||||
@@ -9,6 +9,7 @@ interface MediaViewerOpenOptions {
|
||||
messageId?: string;
|
||||
message?: Message;
|
||||
sourceChannel?: Channel | null;
|
||||
allowAttachmentDelete?: boolean;
|
||||
}
|
||||
|
||||
type MediaViewerIntent =
|
||||
@@ -26,6 +27,7 @@ function dispatchMediaViewerIntent(intent: MediaViewerIntent): void {
|
||||
intent.options?.messageId,
|
||||
intent.options?.message,
|
||||
intent.options?.sourceChannel,
|
||||
intent.options?.allowAttachmentDelete,
|
||||
);
|
||||
return;
|
||||
case 'close':
|
||||
|
||||
@@ -36,6 +36,7 @@ class MediaViewer {
|
||||
messageId?: string = undefined;
|
||||
message?: Message = undefined;
|
||||
sourceChannel?: Channel = undefined;
|
||||
allowAttachmentDelete: boolean = false;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(
|
||||
@@ -56,6 +57,7 @@ class MediaViewer {
|
||||
messageId?: string,
|
||||
message?: Message,
|
||||
sourceChannel?: Channel | null,
|
||||
allowAttachmentDelete: boolean = false,
|
||||
): void {
|
||||
this.isOpen = true;
|
||||
this.items = copyMediaViewerItems(items);
|
||||
@@ -64,6 +66,7 @@ class MediaViewer {
|
||||
this.messageId = messageId;
|
||||
this.message = message;
|
||||
this.sourceChannel = sourceChannel ?? undefined;
|
||||
this.allowAttachmentDelete = allowAttachmentDelete;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
@@ -74,6 +77,7 @@ class MediaViewer {
|
||||
this.messageId = undefined;
|
||||
this.message = undefined;
|
||||
this.sourceChannel = undefined;
|
||||
this.allowAttachmentDelete = false;
|
||||
}
|
||||
|
||||
navigate(index: number): void {
|
||||
|
||||
@@ -10,17 +10,15 @@ import {
|
||||
selectSyncedFieldMachineModel,
|
||||
transitionSyncedFieldMachineSnapshot,
|
||||
} from '@app/features/user/state/SyncedFieldStateMachine';
|
||||
import type {SyncedPreferences, SyncedPreferencesField} from '@app/features/user/state/SyncedPreferencesEngine';
|
||||
import {create, equals, type Message, type MessageInitShape, toBinary} from '@bufbuild/protobuf';
|
||||
import type {GenMessage} from '@bufbuild/protobuf/codegenv2';
|
||||
import type {SyncedPreferences} from '@fluxer/schema/src/domains/user/SyncedPreferencesCodec';
|
||||
import {comparer, reaction, runInAction} from 'mobx';
|
||||
|
||||
export {verifyRoundtripStability};
|
||||
|
||||
const logger = new Logger('SyncedField');
|
||||
|
||||
export type SyncedPreferencesField = Exclude<keyof SyncedPreferences, '$typeName' | '$unknown' | 'sanitizeUrls'>;
|
||||
|
||||
function isMessageSchema(value: unknown): value is GenMessage<Message> {
|
||||
return (
|
||||
value != null &&
|
||||
|
||||
@@ -10,8 +10,6 @@ export type SyncedPreferencesField = Exclude<keyof SyncedPreferences, '$typeName
|
||||
|
||||
export type {SyncedPreferences};
|
||||
|
||||
export type SyncedPreferencesSubField = Exclude<SyncedPreferencesField, 'sanitizeUrls'>;
|
||||
|
||||
interface TopLevelFieldChunk {
|
||||
field: number;
|
||||
bytes: Uint8Array;
|
||||
@@ -225,6 +223,10 @@ export function changedSyncedPreferenceFields(
|
||||
return toFieldNames(changedFieldNumbers(preferencesToBytes(left), preferencesToBytes(right)));
|
||||
}
|
||||
|
||||
export function syncedPreferencesEqual(left: SyncedPreferences, right: SyncedPreferences): boolean {
|
||||
return changedFieldNumbers(preferencesToBytes(left), preferencesToBytes(right)).length === 0;
|
||||
}
|
||||
|
||||
export function copySyncedPreferenceField(
|
||||
target: SyncedPreferences,
|
||||
source: SyncedPreferences,
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
SYNCED_PREFERENCES_FIELDS,
|
||||
type SyncedPreferences,
|
||||
type SyncedPreferencesField,
|
||||
syncedPreferencesEqual,
|
||||
} from '@app/features/user/state/SyncedPreferencesEngine';
|
||||
import type {StatusType} from '@fluxer/constants/src/StatusConstants';
|
||||
import {normalizeStatus, StatusTypes} from '@fluxer/constants/src/StatusConstants';
|
||||
@@ -54,8 +55,6 @@ import isPlainObject from 'lodash/isPlainObject';
|
||||
import snakeCase from 'lodash/snakeCase';
|
||||
import {action, makeAutoObservable, reaction, runInAction} from 'mobx';
|
||||
|
||||
type SyncedPreferencesSubField = Exclude<SyncedPreferencesField, 'sanitizeUrls'>;
|
||||
|
||||
function restoreSettingValue<K extends keyof UserSettings>(target: UserSettings, source: UserSettings, key: K): void {
|
||||
target[key] = source[key];
|
||||
}
|
||||
@@ -781,12 +780,12 @@ class UserSettingsState {
|
||||
);
|
||||
}
|
||||
|
||||
getSubPreference<F extends SyncedPreferencesSubField>(field: F): SyncedPreferences[F] | undefined {
|
||||
getSubPreference<F extends SyncedPreferencesField>(field: F): SyncedPreferences[F] | undefined {
|
||||
const value = this.syncedPreferences[field];
|
||||
return value === undefined ? undefined : value;
|
||||
}
|
||||
|
||||
async setSubPreference<F extends SyncedPreferencesSubField>(
|
||||
async setSubPreference<F extends SyncedPreferencesField>(
|
||||
field: F,
|
||||
value: NonNullable<SyncedPreferences[F]>,
|
||||
): Promise<void> {
|
||||
@@ -876,8 +875,8 @@ class UserSettingsState {
|
||||
for (const field of dirtyFields) {
|
||||
this.markSyncedPreferenceFieldDirty(field);
|
||||
}
|
||||
const wireChanged = changedSyncedPreferenceFields(nextWire, this.wireSyncedPreferences).length > 0;
|
||||
const localChanged = changedSyncedPreferenceFields(merged, this.syncedPreferences).length > 0;
|
||||
const wireChanged = !syncedPreferencesEqual(nextWire, this.wireSyncedPreferences);
|
||||
const localChanged = !syncedPreferencesEqual(merged, this.syncedPreferences);
|
||||
if (!wireChanged && !localChanged) {
|
||||
if (shouldSyncMigratedMessageGroupSpacing) {
|
||||
this.markSyncedPreferenceFieldDirty('accessibility');
|
||||
|
||||
@@ -314,5 +314,5 @@ export function getSortedLocales(): Array<TranslatedLocaleInfo> {
|
||||
...locale,
|
||||
name: i18n._(locale.name),
|
||||
}))
|
||||
.sort((a, b) => a.nativeName.localeCompare(b.nativeName));
|
||||
.sort((a, b) => a.code.localeCompare(b.code));
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@ pub async fn proxy_assets(
|
||||
response
|
||||
}
|
||||
|
||||
async fn serve_local_asset(
|
||||
pub(super) async fn serve_local_asset(
|
||||
static_dir: &str,
|
||||
relative_path: &str,
|
||||
request_headers: &HeaderMap,
|
||||
|
||||
@@ -19,6 +19,7 @@ use axum::{
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::assets_proxy::serve_local_asset;
|
||||
use super::spa_static::{CORS_ALLOW_ANY_VALUE, guess_mime, is_font_mime};
|
||||
|
||||
const ACCEPT_CH_VALUE: &str = "DPR, Sec-CH-DPR, Sec-CH-Width, Save-Data, ECT, Downlink";
|
||||
@@ -35,6 +36,14 @@ pub async fn spa_catch_all(
|
||||
if let Some(cache_control) = static_root_file_cache_control(request_path) {
|
||||
return serve_static_file(&state.config.static_dir, request_path, cache_control).await;
|
||||
}
|
||||
if is_static_asset_path(request_path) {
|
||||
return serve_local_asset(
|
||||
&state.config.static_dir,
|
||||
request_path.trim_start_matches('/'),
|
||||
&headers,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
serve_spa_index(&state, &headers, request_path).await
|
||||
}
|
||||
@@ -42,6 +51,16 @@ pub async fn spa_catch_all(
|
||||
const CRAWL_CONTROL_CACHE_CONTROL: &str = "public, max-age=300, must-revalidate";
|
||||
|
||||
const STATIC_ROOT_FILES: &[(&str, &str)] = &[("/robots.txt", CRAWL_CONTROL_CACHE_CONTROL)];
|
||||
const STATIC_ASSET_PREFIXES: &[&str] = &[
|
||||
"/avatars/",
|
||||
"/badges/",
|
||||
"/desktop/",
|
||||
"/embeds/",
|
||||
"/emoji/",
|
||||
"/libs/",
|
||||
"/marketing/",
|
||||
"/web/",
|
||||
];
|
||||
|
||||
fn static_root_file_cache_control(request_path: &str) -> Option<&'static str> {
|
||||
STATIC_ROOT_FILES
|
||||
@@ -50,6 +69,12 @@ fn static_root_file_cache_control(request_path: &str) -> Option<&'static str> {
|
||||
.map(|(_, cache_control)| *cache_control)
|
||||
}
|
||||
|
||||
fn is_static_asset_path(request_path: &str) -> bool {
|
||||
STATIC_ASSET_PREFIXES
|
||||
.iter()
|
||||
.any(|prefix| request_path.starts_with(prefix))
|
||||
}
|
||||
|
||||
async fn serve_static_file(
|
||||
static_dir: &str,
|
||||
request_path: &str,
|
||||
|
||||
@@ -14,6 +14,7 @@ const packageName = isCanary ? 'fluxer_desktop_canary' : 'fluxer_desktop';
|
||||
const linuxPackageName = isCanary ? 'fluxer-canary' : 'fluxer';
|
||||
const linuxDesktopActionIds = ['open-settings', 'new-dm'];
|
||||
const linuxDesktopActionList = `${linuxDesktopActionIds.join(';')};`;
|
||||
const linuxGlibcBaseline = Object.freeze({major: 2, minor: 35, patch: 0, name: 'GLIBC_2.35'});
|
||||
const rpmBuildIdFilePrefix = '/usr/lib/.build-id';
|
||||
const rpmBuildIdLinkFpmArgs = [
|
||||
'--rpm-rpmbuild-define',
|
||||
@@ -814,11 +815,165 @@ async function addLinuxLegacyBinarySymlink(context) {
|
||||
}
|
||||
}
|
||||
|
||||
async function isElfFile(filePath) {
|
||||
const handle = await fs.open(filePath, 'r');
|
||||
try {
|
||||
const magic = Buffer.alloc(4);
|
||||
const {bytesRead} = await handle.read(magic, 0, magic.length, 0);
|
||||
return bytesRead === magic.length && magic.equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]));
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function findPackagedElfFiles(rootDir) {
|
||||
const results = [];
|
||||
async function visit(directory) {
|
||||
const entries = await fs.readdir(directory, {withFileTypes: true});
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await visit(entryPath);
|
||||
} else if (entry.isFile() && (await isElfFile(entryPath))) {
|
||||
results.push(entryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
await visit(rootDir);
|
||||
return results.sort();
|
||||
}
|
||||
|
||||
function compareGlibcVersions(left, right) {
|
||||
for (const key of ['major', 'minor', 'patch']) {
|
||||
if (left[key] !== right[key]) return left[key] - right[key];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function inspectElfGlibcRequirements(elfPath) {
|
||||
let stdout;
|
||||
try {
|
||||
({stdout} = await execFileAsync('readelf', ['--version-info', '--dynamic', '--wide', elfPath], {
|
||||
env: {...process.env, LC_ALL: 'C'},
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
throw new Error(`Cannot verify Linux glibc compatibility: readelf executable is not available.`);
|
||||
}
|
||||
const stderr = typeof error?.stderr === 'string' ? error.stderr.trim() : '';
|
||||
throw new Error(`Cannot inspect ELF file ${elfPath}: ${stderr || error?.message || String(error)}`);
|
||||
}
|
||||
const versions = new Map();
|
||||
const unsupportedRequirements = new Set();
|
||||
const hasGlibcRequirement = /\bGLIBC_[A-Za-z0-9_.]+\b/.test(stdout);
|
||||
let readingVersionNeeds = false;
|
||||
let foundVersionNeeds = false;
|
||||
let hasVersionNeedsTag = false;
|
||||
let needsGlibc = false;
|
||||
let usesDtRelr = false;
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (/\(VERNEED\)/.test(trimmed)) {
|
||||
hasVersionNeedsTag = true;
|
||||
}
|
||||
if (/^0x0*(?:23|24|25)\s+\(/i.test(trimmed) || /\((?:RELR|RELRSZ|RELRENT)\)/.test(trimmed)) {
|
||||
usesDtRelr = true;
|
||||
}
|
||||
if (/\(NEEDED\)/.test(trimmed)) {
|
||||
const neededMatch = /\(NEEDED\)\s+Shared library: \[([^\]]+)\]/.exec(trimmed);
|
||||
if (!neededMatch) {
|
||||
throw new Error(`Cannot parse a required library for ELF file ${elfPath}: ${trimmed}`);
|
||||
}
|
||||
if (neededMatch[1] === 'libc.so.6') needsGlibc = true;
|
||||
}
|
||||
if (trimmed.startsWith('Version needs section ')) {
|
||||
readingVersionNeeds = true;
|
||||
foundVersionNeeds = true;
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith('Version symbols section ') || trimmed.startsWith('Version definition section ')) {
|
||||
readingVersionNeeds = false;
|
||||
continue;
|
||||
}
|
||||
if (!readingVersionNeeds) continue;
|
||||
const requirement = /\bName:\s+(\S+)/.exec(trimmed)?.[1];
|
||||
if (!requirement?.startsWith('GLIBC_')) continue;
|
||||
const match = /^GLIBC_(\d+)\.(\d+)(?:\.(\d+))?$/.exec(requirement);
|
||||
if (!match) {
|
||||
unsupportedRequirements.add(requirement);
|
||||
continue;
|
||||
}
|
||||
const version = {
|
||||
major: Number.parseInt(match[1], 10),
|
||||
minor: Number.parseInt(match[2], 10),
|
||||
patch: Number.parseInt(match[3] ?? '0', 10),
|
||||
name: requirement,
|
||||
};
|
||||
if (![version.major, version.minor, version.patch].every(Number.isSafeInteger)) {
|
||||
throw new Error(`readelf returned an invalid glibc version for ${elfPath}: ${requirement}`);
|
||||
}
|
||||
versions.set(version.name, version);
|
||||
}
|
||||
if ((hasGlibcRequirement || hasVersionNeedsTag) && !foundVersionNeeds) {
|
||||
throw new Error(`Cannot verify glibc requirements for ELF file ${elfPath}: readelf omitted version needs.`);
|
||||
}
|
||||
if (needsGlibc && (!foundVersionNeeds || (versions.size === 0 && unsupportedRequirements.size === 0))) {
|
||||
throw new Error(`Cannot verify glibc requirements for ELF file ${elfPath}: readelf returned no version needs.`);
|
||||
}
|
||||
return {
|
||||
versions: Array.from(versions.values()),
|
||||
unsupportedRequirements: Array.from(unsupportedRequirements).sort(),
|
||||
usesDtRelr,
|
||||
};
|
||||
}
|
||||
|
||||
async function findLinuxGlibcCompatibilityViolations(elfFiles) {
|
||||
const violations = [];
|
||||
for (const elfPath of elfFiles) {
|
||||
const {versions, unsupportedRequirements, usesDtRelr} = await inspectElfGlibcRequirements(elfPath);
|
||||
const maximum = versions.sort(compareGlibcVersions).at(-1);
|
||||
const requirements = [...unsupportedRequirements];
|
||||
if (maximum && compareGlibcVersions(maximum, linuxGlibcBaseline) > 0) {
|
||||
requirements.push(maximum.name);
|
||||
}
|
||||
if (usesDtRelr) requirements.push('DT_RELR (glibc 2.36+)');
|
||||
if (requirements.length > 0) violations.push({elfPath, requirements});
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
function throwLinuxGlibcCompatibilityError(violations, formatPath) {
|
||||
if (violations.length === 0) return;
|
||||
const lines = [
|
||||
`Linux package exceeds the supported ${linuxGlibcBaseline.name} ABI baseline.`,
|
||||
'Build Linux artifacts on Ubuntu 22.04 and keep every shipped ELF at or below that glibc requirement.',
|
||||
];
|
||||
for (const {elfPath, requirements} of violations) {
|
||||
lines.push(` - ${formatPath(elfPath)} requires ${requirements.join(', ')}`);
|
||||
}
|
||||
throw new Error(lines.join('\n'));
|
||||
}
|
||||
|
||||
async function verifyLinuxGlibcCompatibility(context) {
|
||||
if (context.electronPlatformName !== 'linux') return;
|
||||
const elfFiles = await findPackagedElfFiles(context.appOutDir);
|
||||
if (elfFiles.length === 0) {
|
||||
throw new Error(`Linux package output contains no ELF files: ${context.appOutDir}`);
|
||||
}
|
||||
const violations = await findLinuxGlibcCompatibilityViolations(elfFiles);
|
||||
throwLinuxGlibcCompatibilityError(violations, (elfPath) => path.relative(context.appOutDir, elfPath));
|
||||
console.log(
|
||||
`Verified ${elfFiles.length} packaged Linux ELF files against the ${linuxGlibcBaseline.name} ABI baseline.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function afterPack(context) {
|
||||
await copyMissingPackagedNativeArtifacts(context);
|
||||
await cleanupNativeBuildIntermediates(context);
|
||||
await addLinuxLegacyBinarySymlink(context);
|
||||
await verifyPackagedNativeArtifacts(context);
|
||||
await verifyLinuxGlibcCompatibility(context);
|
||||
}
|
||||
|
||||
async function listRpmPackageFiles(artifactPath) {
|
||||
@@ -922,12 +1077,19 @@ async function verifyLinuxPackagesContainAppArmorProfile(buildResult) {
|
||||
async function readElfNeededLibraries(artifactPath) {
|
||||
try {
|
||||
const {stdout} = await execFileAsync('readelf', ['-d', artifactPath], {
|
||||
env: {...process.env, LC_ALL: 'C'},
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
});
|
||||
return stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.match(/\(NEEDED\)\s+Shared library: \[([^\]]+)\]/)?.[1])
|
||||
.filter(Boolean);
|
||||
const neededLibraries = [];
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
if (!/\(NEEDED\)/.test(line)) continue;
|
||||
const match = /\(NEEDED\).*\[([^\]]+)\]/.exec(line);
|
||||
if (!match) {
|
||||
throw new Error(`Cannot parse a required library for AppImage artifact ${artifactPath}: ${line.trim()}`);
|
||||
}
|
||||
neededLibraries.push(match[1]);
|
||||
}
|
||||
return neededLibraries;
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
throw new Error(`Cannot inspect AppImage artifact ${artifactPath}: readelf executable is not available.`);
|
||||
@@ -937,6 +1099,14 @@ async function readElfNeededLibraries(artifactPath) {
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyAppImageArtifactsGlibcCompatibility(buildResult) {
|
||||
const appImageArtifacts = (buildResult.artifactPaths ?? []).filter(
|
||||
(artifactPath) => path.extname(artifactPath) === '.AppImage',
|
||||
);
|
||||
const violations = await findLinuxGlibcCompatibilityViolations(appImageArtifacts);
|
||||
throwLinuxGlibcCompatibilityError(violations, (artifactPath) => path.basename(artifactPath));
|
||||
}
|
||||
|
||||
async function verifyAppImageArtifactsDoNotNeedFuse2(buildResult) {
|
||||
const appImageArtifacts = (buildResult.artifactPaths ?? []).filter(
|
||||
(artifactPath) => path.extname(artifactPath) === '.AppImage',
|
||||
@@ -1072,6 +1242,7 @@ async function verifyAppImageArtifactsUseSandboxAwareLauncher(buildResult) {
|
||||
async function verifyLinuxArtifactContracts(buildResult) {
|
||||
await verifyRpmArtifactsDoNotOwnBuildIds(buildResult);
|
||||
await verifyLinuxPackagesContainAppArmorProfile(buildResult);
|
||||
await verifyAppImageArtifactsGlibcCompatibility(buildResult);
|
||||
await verifyAppImageArtifactsDoNotNeedFuse2(buildResult);
|
||||
await verifyAppImageArtifactsUseSandboxAwareLauncher(buildResult);
|
||||
}
|
||||
|
||||
@@ -42,10 +42,9 @@ const BACKEND_REASON: &str = "Windows WebAuthn broker backend (webauthn.dll). \
|
||||
webauthn.dll is delay-loaded via /DELAYLOAD, so the addon still loads on \
|
||||
older builds but `isSupported()` returns false there.";
|
||||
#[cfg(target_os = "linux")]
|
||||
const BACKEND_REASON: &str = "Linux WebAuthn backend (libfido2). Requires \
|
||||
libfido2 >= 1.5.0 (HID transport, U2F fallback disabled) and a CTAP \
|
||||
authenticator on USB-HID; `isSupported()` returns false when no \
|
||||
authenticator is enumerated.";
|
||||
const BACKEND_REASON: &str = "Linux WebAuthn backend (bundled libfido2, HID transport, U2F \
|
||||
fallback disabled). Requires a CTAP authenticator on USB-HID; \
|
||||
`isSupported()` returns false when no authenticator is enumerated.";
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
const BACKEND_REASON: &str = "no WebAuthn backend is compiled in for this platform";
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"devDependencies": {
|
||||
"@types/node": "24.10.9",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260130.1",
|
||||
"electron": "43.4.0",
|
||||
"electron": "42.10.0",
|
||||
"electron-builder": "26.9.0",
|
||||
"esbuild": "0.27.2",
|
||||
"typescript": "5.9.3"
|
||||
|
||||
+28
-22
@@ -3,6 +3,7 @@ set -euo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
VENV="$HERE/.venv"
|
||||
REQUIREMENTS_STAMP="$VENV/.requirements.sha256"
|
||||
ADDR="${ZENSICAL_DEV_ADDR:-0.0.0.0:8000}"
|
||||
LOG="${ZENSICAL_LOG:-/tmp/zensical-serve.log}"
|
||||
PORT="${ADDR##*:}"
|
||||
@@ -14,29 +15,34 @@ ensure_env() {
|
||||
python3 -m venv "$VENV"
|
||||
"$VENV/bin/python" -m pip install --quiet --upgrade pip
|
||||
fi
|
||||
"$VENV/bin/python" -m pip install --quiet --require-virtualenv -r "$HERE/requirements.txt"
|
||||
python_fingerprint="$(python3 -c 'import platform, sys; print(f"{sys.implementation.cache_tag}:{platform.machine()}")')"
|
||||
requirements_hash="$(printf '%s\0%s\n' "$python_fingerprint" "$(sha256sum "$HERE/requirements.txt" | cut -d' ' -f1)" | sha256sum | cut -d' ' -f1)"
|
||||
if [ ! -f "$REQUIREMENTS_STAMP" ] || [ "$(cat "$REQUIREMENTS_STAMP")" != "$requirements_hash" ]; then
|
||||
"$VENV/bin/python" -m pip install --quiet --require-virtualenv -r "$HERE/requirements.txt"
|
||||
printf '%s\n' "$requirements_hash" >"$REQUIREMENTS_STAMP"
|
||||
fi
|
||||
}
|
||||
|
||||
case "${1:-serve}" in
|
||||
--bootstrap)
|
||||
ensure_env
|
||||
;;
|
||||
--daemon)
|
||||
ensure_env
|
||||
if curl -sf -o /dev/null "http://127.0.0.1:${PORT}/" 2>/dev/null; then
|
||||
echo "zensical already serving on ${ADDR}"
|
||||
exit 0
|
||||
fi
|
||||
setsid "$VENV/bin/zensical" serve -a "$ADDR" >"$LOG" 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
echo "zensical serving on ${ADDR} (logs: ${LOG})"
|
||||
;;
|
||||
serve)
|
||||
ensure_env
|
||||
exec "$VENV/bin/zensical" serve -a "$ADDR"
|
||||
;;
|
||||
*)
|
||||
ensure_env
|
||||
exec "$VENV/bin/zensical" "$@"
|
||||
;;
|
||||
--bootstrap)
|
||||
ensure_env
|
||||
;;
|
||||
--daemon)
|
||||
ensure_env
|
||||
if curl -sf -o /dev/null "http://127.0.0.1:${PORT}/" 2>/dev/null; then
|
||||
echo "zensical already serving on ${ADDR}"
|
||||
exit 0
|
||||
fi
|
||||
setsid "$VENV/bin/zensical" serve -a "$ADDR" >"$LOG" 2>&1 &
|
||||
disown 2>/dev/null || true
|
||||
echo "zensical serving on ${ADDR} (logs: ${LOG})"
|
||||
;;
|
||||
serve)
|
||||
ensure_env
|
||||
exec "$VENV/bin/zensical" serve -a "$ADDR"
|
||||
;;
|
||||
*)
|
||||
ensure_env
|
||||
exec "$VENV/bin/zensical" "$@"
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -12,22 +12,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
make \
|
||||
cmake \
|
||||
gcc \
|
||||
g++ \
|
||||
libc6-dev \
|
||||
libssl-dev \
|
||||
libuv1-dev \
|
||||
zlib1g-dev \
|
||||
pkg-config \
|
||||
gettext-base \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN curl -fsSL https://github.com/erlang/rebar3/releases/download/3.24.0/rebar3 -o /usr/local/bin/rebar3 && \
|
||||
RUN curl --retry 5 --retry-delay 2 --retry-all-errors -fsSL https://github.com/erlang/rebar3/releases/download/3.24.0/rebar3 -o /usr/local/bin/rebar3 && \
|
||||
chmod +x /usr/local/bin/rebar3
|
||||
|
||||
RUN curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs | \
|
||||
RUN curl --retry 5 --retry-delay 2 --retry-all-errors --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs | \
|
||||
sh -s -- -y --profile minimal --default-toolchain "${RUST_TOOLCHAIN}"
|
||||
|
||||
COPY . .
|
||||
@@ -47,8 +42,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
libstdc++6 \
|
||||
libuv1t64 \
|
||||
zlib1g \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /usr/src/app/fluxer_gateway/_build/prod/rel/fluxer_gateway .
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
{jose, "1.11.10"},
|
||||
{ezstd, "1.1.0"},
|
||||
{enats, "1.2.0"},
|
||||
{erlcass, "4.1.3"},
|
||||
{eqwalizer_support,
|
||||
{git_subdir, "https://github.com/whatsapp/eqwalizer.git",
|
||||
{ref, "c57aa9e3a05553d1ba66ea647a2177eaa469211b"}, "eqwalizer_support"}}
|
||||
@@ -32,7 +31,6 @@
|
||||
{relx, [
|
||||
{release, {fluxer_gateway, "0.0.0"}, [
|
||||
fluxer_gateway,
|
||||
{erlcass, load},
|
||||
enats,
|
||||
sasl
|
||||
]},
|
||||
@@ -90,7 +88,7 @@
|
||||
]},
|
||||
{plt_apps, all_deps},
|
||||
{plt_extra_apps, [
|
||||
crypto, enats, erlcass, inets, jose, public_key, ranch, ssl
|
||||
crypto, enats, inets, jose, public_key, ranch, ssl
|
||||
]},
|
||||
{warnings_file, "dialyzer.ignore-warnings"}
|
||||
]}.
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
{ref,"c57aa9e3a05553d1ba66ea647a2177eaa469211b"},
|
||||
"eqwalizer_support"},
|
||||
0},
|
||||
{<<"erlcass">>,{pkg,<<"erlcass">>,<<"4.1.3">>},0},
|
||||
{<<"ezstd">>,{pkg,<<"ezstd">>,<<"1.1.0">>},0},
|
||||
{<<"jose">>,{pkg,<<"jose">>,<<"1.11.10">>},0},
|
||||
{<<"opentelemetry_api">>,{pkg,<<"opentelemetry_api">>,<<"1.4.0">>},1},
|
||||
@@ -21,7 +20,6 @@
|
||||
{<<"cowlib">>, <<"54592074EBBBB92EE4746C8A8846E5605052F29309D3A873468D76CDF932076F">>},
|
||||
{<<"enats">>, <<"D7459C804013CAFA4AF880B18D446C48890D28D372D62AD66C76187E5779248D">>},
|
||||
{<<"enats_msg">>, <<"50631124F37D88BE76A91A5B96A6565C5981EBF917CD819F0175CA658A966F43">>},
|
||||
{<<"erlcass">>, <<"17AC8F39B0A4703B12A8994663920993E191757831E125CF2C7EFB267DB66E7D">>},
|
||||
{<<"ezstd">>, <<"D3B483D6ACFADFB65DBA4015371E6D54526DBF3D9EF0941B5ADD8BF5890731F4">>},
|
||||
{<<"jose">>, <<"A903F5227417BD2A08C8A00A0CBCC458118BE84480955E8D251297A425723F83">>},
|
||||
{<<"opentelemetry_api">>, <<"63CA1742F92F00059298F478048DFB826F4B20D49534493D6919A0DB39B6DB04">>},
|
||||
@@ -32,7 +30,6 @@
|
||||
{<<"cowlib">>, <<"7F478D80D66B747344F0EA7708C187645CFCC08B11AA424632F78E25BF05DB51">>},
|
||||
{<<"enats">>, <<"20DEB3CB1D3E960194DF8B136C40D2DB085B485BBA5E493B340AB5F9FD2BED22">>},
|
||||
{<<"enats_msg">>, <<"C4F2139E5144FABC99AFE01B8B016AD9DA278CDDC60857AC5D5AFB0AD1283534">>},
|
||||
{<<"erlcass">>, <<"37237C12ABE5745E0F900180D0ADB129634619BD6B2F57995BA020E63DDC6BEC">>},
|
||||
{<<"ezstd">>, <<"28CFA0ED6CC3922095AD5BA0F23392A1664273358B17184BAA909868361184E7">>},
|
||||
{<<"jose">>, <<"0D6CD36FF8BA174DB29148FC112B5842186B68A90CE9FC2B3EC3AFE76593E614">>},
|
||||
{<<"opentelemetry_api">>, <<"3DFBBFAA2C2ED3121C5C483162836C4F9027DEF469C41578AF5EF32589FCFC58">>},
|
||||
|
||||
@@ -59,7 +59,7 @@ env_services_config() ->
|
||||
|
||||
-spec env_gateway_config() -> map().
|
||||
env_gateway_config() ->
|
||||
maps:merge(env_gateway_base_config(), env_gateway_hotpatch_config()).
|
||||
env_gateway_base_config().
|
||||
|
||||
-spec env_gateway_base_config() -> map().
|
||||
env_gateway_base_config() ->
|
||||
@@ -107,36 +107,6 @@ env_gateway_base_config() ->
|
||||
)
|
||||
}.
|
||||
|
||||
-spec env_gateway_hotpatch_config() -> map().
|
||||
env_gateway_hotpatch_config() ->
|
||||
#{
|
||||
<<"hotpatch_enabled">> => env_bool("FLUXER_GATEWAY_HOTPATCH_ENABLED", false),
|
||||
<<"hotpatch_cassandra_hosts">> => env_optional_binary(
|
||||
"FLUXER_GATEWAY_HOTPATCH_CASSANDRA_HOSTS"
|
||||
),
|
||||
<<"hotpatch_cassandra_port">> => env_int(
|
||||
"FLUXER_GATEWAY_HOTPATCH_CASSANDRA_PORT", 9042
|
||||
),
|
||||
<<"hotpatch_cassandra_keyspace">> => env_binary(
|
||||
"FLUXER_GATEWAY_HOTPATCH_CASSANDRA_KEYSPACE", <<"fluxer">>
|
||||
),
|
||||
<<"hotpatch_cassandra_username">> => env_optional_binary(
|
||||
"FLUXER_GATEWAY_HOTPATCH_CASSANDRA_USERNAME"
|
||||
),
|
||||
<<"hotpatch_cassandra_password">> => env_optional_binary(
|
||||
"FLUXER_GATEWAY_HOTPATCH_CASSANDRA_PASSWORD"
|
||||
),
|
||||
<<"hotpatch_public_keys">> => env_optional_binary(
|
||||
"FLUXER_GATEWAY_HOTPATCH_PUBLIC_KEYS"
|
||||
),
|
||||
<<"hotpatch_poll_interval_ms">> => env_int(
|
||||
"FLUXER_GATEWAY_HOTPATCH_POLL_INTERVAL_MS", 5000
|
||||
),
|
||||
<<"hotpatch_startup_sync_timeout_ms">> => env_int(
|
||||
"FLUXER_GATEWAY_HOTPATCH_STARTUP_SYNC_TIMEOUT_MS", 30000
|
||||
)
|
||||
}.
|
||||
|
||||
-spec env_nats_config() -> map().
|
||||
env_nats_config() ->
|
||||
#{
|
||||
@@ -295,20 +265,6 @@ build_http_config(Service) ->
|
||||
),
|
||||
gateway_http_recovery_timeout_ms =>
|
||||
get_int(Service, <<"gateway_http_recovery_timeout_ms">>, 15000),
|
||||
hotpatch_enabled => get_bool(Service, <<"hotpatch_enabled">>, false),
|
||||
hotpatch_cassandra_hosts =>
|
||||
optional_string(get_optional_binary(Service, <<"hotpatch_cassandra_hosts">>)),
|
||||
hotpatch_cassandra_port => get_int(Service, <<"hotpatch_cassandra_port">>, 9042),
|
||||
hotpatch_cassandra_keyspace =>
|
||||
get_binary(Service, <<"hotpatch_cassandra_keyspace">>, <<"fluxer">>),
|
||||
hotpatch_cassandra_username =>
|
||||
get_optional_binary(Service, <<"hotpatch_cassandra_username">>),
|
||||
hotpatch_cassandra_password =>
|
||||
get_optional_binary(Service, <<"hotpatch_cassandra_password">>),
|
||||
hotpatch_public_keys => get_optional_binary(Service, <<"hotpatch_public_keys">>),
|
||||
hotpatch_poll_interval_ms => get_int(Service, <<"hotpatch_poll_interval_ms">>, 5000),
|
||||
hotpatch_startup_sync_timeout_ms =>
|
||||
get_int(Service, <<"hotpatch_startup_sync_timeout_ms">>, 30000),
|
||||
gateway_http_cleanup_interval_ms =>
|
||||
get_int(Service, <<"gateway_http_cleanup_interval_ms">>, 30000),
|
||||
gateway_http_cleanup_max_age_ms =>
|
||||
|
||||
@@ -38,8 +38,7 @@ common_children() ->
|
||||
child_spec(gateway_nats_pool, gateway_nats_pool),
|
||||
child_spec(gateway_event_pause, gateway_event_pause),
|
||||
child_spec(gateway_concurrency, gateway_concurrency),
|
||||
child_spec(gateway_rollout_config, gateway_rollout_config),
|
||||
child_spec(gateway_hotpatch_reconciler, gateway_hotpatch_reconciler)
|
||||
child_spec(gateway_rollout_config, gateway_rollout_config)
|
||||
] ++ cluster_children() ++
|
||||
[
|
||||
child_spec(gateway_dispatch_relay, gateway_dispatch_relay),
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(gateway_hotpatch_bundle).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-export([
|
||||
compress_term/1,
|
||||
decompress_term/1,
|
||||
bundle_hash/1,
|
||||
signing_payload/1,
|
||||
sign/2,
|
||||
verify_signature/4,
|
||||
parse_public_keys/1,
|
||||
decode_signed_event/2
|
||||
]).
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-endif.
|
||||
|
||||
-define(DOMAIN, <<"fluxer-gateway-hotpatch-v1">>).
|
||||
-define(ZSTD_LEVEL, 3).
|
||||
|
||||
-spec compress_term(term()) -> {ok, binary()} | {error, term()}.
|
||||
compress_term(Term) ->
|
||||
compress_binary(term_to_binary(Term, [deterministic])).
|
||||
|
||||
-spec decompress_term(binary()) -> {ok, term()} | {error, term()}.
|
||||
decompress_term(Compressed) when is_binary(Compressed) ->
|
||||
case decompress_binary(Compressed) of
|
||||
{ok, Binary} -> decode_term(Binary);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec decode_term(binary()) -> {ok, term()} | {error, term()}.
|
||||
decode_term(Binary) ->
|
||||
try
|
||||
{ok, binary_to_term(Binary, [safe])}
|
||||
catch
|
||||
Class:Reason -> {error, {invalid_term, Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec bundle_hash(binary()) -> binary().
|
||||
bundle_hash(CompressedBundle) when is_binary(CompressedBundle) ->
|
||||
crypto:hash(sha256, CompressedBundle).
|
||||
|
||||
-spec signing_payload(binary()) -> binary().
|
||||
signing_payload(CompressedBundle) when is_binary(CompressedBundle) ->
|
||||
<<?DOMAIN/binary, 0, CompressedBundle/binary>>.
|
||||
|
||||
-spec sign(binary(), binary()) -> {ok, binary()} | {error, term()}.
|
||||
sign(CompressedBundle, PrivateKey) when is_binary(CompressedBundle), is_binary(PrivateKey) ->
|
||||
try
|
||||
{ok, crypto:sign(eddsa, none, signing_payload(CompressedBundle), [PrivateKey, ed25519])}
|
||||
catch
|
||||
Class:Reason -> {error, {sign_failed, Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec verify_signature(binary(), binary(), binary(), #{binary() => binary()}) ->
|
||||
ok | {error, term()}.
|
||||
verify_signature(CompressedBundle, SignerKeyId, Signature, PublicKeys) when
|
||||
is_binary(CompressedBundle),
|
||||
is_binary(SignerKeyId),
|
||||
is_binary(Signature),
|
||||
is_map(PublicKeys)
|
||||
->
|
||||
case maps:get(SignerKeyId, PublicKeys, undefined) of
|
||||
undefined ->
|
||||
{error, {unknown_signer, SignerKeyId}};
|
||||
PublicKey ->
|
||||
verify_with_key(CompressedBundle, Signature, PublicKey)
|
||||
end.
|
||||
|
||||
-spec parse_public_keys(term()) ->
|
||||
{ok, #{binary() => binary()}} | {error, term()}.
|
||||
parse_public_keys(undefined) ->
|
||||
{ok, #{}};
|
||||
parse_public_keys(Value) when is_list(Value) ->
|
||||
parse_public_keys(type_conv:ensure_binary(Value));
|
||||
parse_public_keys(Value) when is_binary(Value) ->
|
||||
Tokens = [
|
||||
string:trim(Token)
|
||||
|| Token <- binary:split(Value, [<<",">>, <<"\n">>, <<";">>], [global]),
|
||||
string:trim(Token) =/= <<>>
|
||||
],
|
||||
parse_public_key_tokens(Tokens, #{});
|
||||
parse_public_keys(Value) ->
|
||||
{error, {invalid_public_keys_config, Value}}.
|
||||
|
||||
-spec decode_signed_event(map(), #{binary() => binary()}) -> {ok, map()} | {error, term()}.
|
||||
decode_signed_event(Event, PublicKeys) when is_map(Event), is_map(PublicKeys) ->
|
||||
Bundle = event_value(bundle, Event),
|
||||
SignerKeyId = event_value(signer_key_id, Event),
|
||||
Signature = event_value(signature, Event),
|
||||
BundleHash = event_value(bundle_sha256, Event),
|
||||
decode_event_payload(Bundle, SignerKeyId, Signature, BundleHash, PublicKeys).
|
||||
|
||||
-spec decode_event_payload(term(), term(), term(), term(), #{binary() => binary()}) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
decode_event_payload(Bundle, SignerKeyId, Signature, BundleHash, PublicKeys) when
|
||||
is_binary(Bundle), is_binary(SignerKeyId), is_binary(Signature), is_binary(BundleHash)
|
||||
->
|
||||
case validate_event_hash(Bundle, BundleHash) of
|
||||
ok -> decode_verified_event(Bundle, SignerKeyId, Signature, PublicKeys);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end;
|
||||
decode_event_payload(_Bundle, _SignerKeyId, _Signature, _BundleHash, _PublicKeys) ->
|
||||
{error, invalid_event_payload}.
|
||||
|
||||
-spec decode_verified_event(binary(), binary(), binary(), #{binary() => binary()}) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
decode_verified_event(Bundle, SignerKeyId, Signature, PublicKeys) ->
|
||||
case verify_signature(Bundle, SignerKeyId, Signature, PublicKeys) of
|
||||
ok -> decompress_bundle_map(Bundle);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec decompress_bundle_map(binary()) -> {ok, map()} | {error, term()}.
|
||||
decompress_bundle_map(Bundle) ->
|
||||
case decompress_term(Bundle) of
|
||||
{ok, Map} when is_map(Map) -> {ok, Map};
|
||||
{ok, Other} -> {error, {invalid_bundle_term, Other}};
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec compress_binary(binary()) -> {ok, binary()} | {error, term()}.
|
||||
compress_binary(Binary) ->
|
||||
try erlang:apply(ezstd, compress, [Binary, ?ZSTD_LEVEL]) of
|
||||
Compressed when is_binary(Compressed) -> {ok, Compressed};
|
||||
{error, Reason} -> {error, {compress_failed, Reason}};
|
||||
Other -> {error, {compress_failed, Other}}
|
||||
catch
|
||||
Class:Reason -> {error, {compress_failed, Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec decompress_binary(binary()) -> {ok, binary()} | {error, term()}.
|
||||
decompress_binary(Binary) ->
|
||||
try erlang:apply(ezstd, decompress, [Binary]) of
|
||||
Decompressed when is_binary(Decompressed) -> {ok, Decompressed};
|
||||
Decompressed when is_list(Decompressed) -> {ok, iolist_to_binary(Decompressed)};
|
||||
{error, Reason} -> {error, {decompress_failed, Reason}};
|
||||
Other -> {error, {decompress_failed, Other}}
|
||||
catch
|
||||
Class:Reason -> {error, {decompress_failed, Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec verify_with_key(binary(), binary(), binary()) -> ok | {error, term()}.
|
||||
verify_with_key(CompressedBundle, Signature, PublicKey) when byte_size(PublicKey) =:= 32 ->
|
||||
verify_with_valid_key(CompressedBundle, Signature, PublicKey);
|
||||
verify_with_key(_CompressedBundle, _Signature, PublicKey) ->
|
||||
{error, {invalid_public_key_size, byte_size(PublicKey)}}.
|
||||
|
||||
-spec verify_with_valid_key(binary(), binary(), binary()) -> ok | {error, term()}.
|
||||
verify_with_valid_key(CompressedBundle, Signature, PublicKey) ->
|
||||
try
|
||||
crypto:verify(eddsa, none, signing_payload(CompressedBundle), Signature, [
|
||||
PublicKey, ed25519
|
||||
])
|
||||
of
|
||||
true -> ok;
|
||||
false -> {error, invalid_signature}
|
||||
catch
|
||||
Class:Reason -> {error, {verify_failed, Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec parse_public_key_tokens([binary()], #{binary() => binary()}) ->
|
||||
{ok, #{binary() => binary()}} | {error, term()}.
|
||||
parse_public_key_tokens([], Acc) ->
|
||||
{ok, Acc};
|
||||
parse_public_key_tokens([Token | Rest], Acc) ->
|
||||
case parse_public_key_token(Token) of
|
||||
{ok, KeyId, PublicKey} -> parse_public_key_tokens(Rest, Acc#{KeyId => PublicKey});
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec parse_public_key_token(binary()) -> {ok, binary(), binary()} | {error, term()}.
|
||||
parse_public_key_token(Token) ->
|
||||
case split_key_token(Token) of
|
||||
{ok, KeyId, Encoded} -> parse_public_key_material(KeyId, Encoded);
|
||||
error -> {error, {invalid_public_key_token, Token}}
|
||||
end.
|
||||
|
||||
-spec parse_public_key_material(binary(), binary()) ->
|
||||
{ok, binary(), binary()} | {error, term()}.
|
||||
parse_public_key_material(KeyId, Encoded) ->
|
||||
case decode_key_material(Encoded) of
|
||||
{ok, PublicKey} when byte_size(PublicKey) =:= 32 -> {ok, KeyId, PublicKey};
|
||||
{ok, PublicKey} -> {error, {invalid_public_key_size, KeyId, byte_size(PublicKey)}};
|
||||
{error, Reason} -> {error, {invalid_public_key, KeyId, Reason}}
|
||||
end.
|
||||
|
||||
-spec split_key_token(binary()) -> {ok, binary(), binary()} | error.
|
||||
split_key_token(Token) ->
|
||||
case binary:split(Token, <<":">>) of
|
||||
[KeyId, Encoded] -> {ok, string:trim(KeyId), string:trim(Encoded)};
|
||||
_ -> split_key_token_equals(Token)
|
||||
end.
|
||||
|
||||
-spec split_key_token_equals(binary()) -> {ok, binary(), binary()} | error.
|
||||
split_key_token_equals(Token) ->
|
||||
case binary:split(Token, <<"=">>) of
|
||||
[KeyId, Encoded] -> {ok, string:trim(KeyId), string:trim(Encoded)};
|
||||
_ -> error
|
||||
end.
|
||||
|
||||
-spec decode_key_material(binary()) -> {ok, binary()} | {error, term()}.
|
||||
decode_key_material(Encoded) ->
|
||||
case try_base64(Encoded) of
|
||||
{ok, Decoded} -> {ok, Decoded};
|
||||
{error, _} -> try_base64url(Encoded)
|
||||
end.
|
||||
|
||||
-spec try_base64(binary()) -> {ok, binary()} | {error, term()}.
|
||||
try_base64(Encoded) ->
|
||||
try
|
||||
{ok, base64:decode(Encoded)}
|
||||
catch
|
||||
Class:Reason -> {error, {Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec try_base64url(binary()) -> {ok, binary()} | {error, term()}.
|
||||
try_base64url(Encoded) ->
|
||||
try
|
||||
{ok, base64url:decode(Encoded)}
|
||||
catch
|
||||
Class:Reason -> {error, {Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec validate_event_hash(binary(), binary()) -> ok | {error, term()}.
|
||||
validate_event_hash(Bundle, BundleHash) ->
|
||||
case bundle_hash(Bundle) of
|
||||
BundleHash -> ok;
|
||||
Other -> {error, {bundle_hash_mismatch, Other, BundleHash}}
|
||||
end.
|
||||
|
||||
-spec event_value(atom(), map()) -> term().
|
||||
event_value(Key, Event) ->
|
||||
maps:get(Key, Event, maps:get(atom_to_binary(Key, utf8), Event, undefined)).
|
||||
|
||||
-ifdef(TEST).
|
||||
|
||||
compress_decompress_roundtrip_test() ->
|
||||
Term = #{
|
||||
version => 1,
|
||||
build_sha => <<"abc123">>,
|
||||
modules => [#{module => <<"session_lifecycle">>, expected_current_md5 => <<0:128>>}]
|
||||
},
|
||||
{ok, Compressed} = compress_term(Term),
|
||||
?assert(is_binary(Compressed)),
|
||||
?assertEqual({ok, Term}, decompress_term(Compressed)).
|
||||
|
||||
sign_and_verify_roundtrip_test() ->
|
||||
{PublicKey, PrivateKey} = ed25519_keypair(),
|
||||
{ok, Compressed} = compress_term(#{version => 1}),
|
||||
{ok, Signature} = sign(Compressed, PrivateKey),
|
||||
Keys = #{<<"ops">> => PublicKey},
|
||||
?assertEqual(ok, verify_signature(Compressed, <<"ops">>, Signature, Keys)),
|
||||
?assertEqual(
|
||||
{error, invalid_signature},
|
||||
verify_signature(<<Compressed/binary, 0>>, <<"ops">>, Signature, Keys)
|
||||
).
|
||||
|
||||
decode_signed_event_rejects_hash_mismatch_test() ->
|
||||
{PublicKey, PrivateKey} = ed25519_keypair(),
|
||||
{ok, Compressed} = compress_term(#{version => 1}),
|
||||
{ok, Signature} = sign(Compressed, PrivateKey),
|
||||
Event = #{
|
||||
signer_key_id => <<"ops">>,
|
||||
signature => Signature,
|
||||
bundle_sha256 => <<0:256>>,
|
||||
bundle => Compressed
|
||||
},
|
||||
?assertMatch(
|
||||
{error, {bundle_hash_mismatch, _, _}},
|
||||
decode_signed_event(Event, #{<<"ops">> => PublicKey})
|
||||
).
|
||||
|
||||
parse_public_keys_test() ->
|
||||
PublicKey = <<1:256>>,
|
||||
Encoded = base64:encode(PublicKey),
|
||||
?assertEqual(
|
||||
{ok, #{<<"ops">> => PublicKey}}, parse_public_keys(<<"ops:", Encoded/binary>>)
|
||||
).
|
||||
|
||||
ed25519_keypair() ->
|
||||
{PublicKey, PrivateKey} = crypto:generate_key(eddsa, ed25519),
|
||||
{require_binary(PublicKey), require_binary(PrivateKey)}.
|
||||
|
||||
require_binary(Value) when is_binary(Value) ->
|
||||
Value.
|
||||
|
||||
-endif.
|
||||
@@ -1,459 +0,0 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(gateway_hotpatch_cli).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-export([main/1, build_bundle/2, sign_bundle/3]).
|
||||
|
||||
-spec main([string()]) -> no_return().
|
||||
main(Args) ->
|
||||
halt(run_main(Args)).
|
||||
|
||||
-spec run_main([string()]) -> non_neg_integer().
|
||||
run_main(["bundle", BuildSha | ModuleOrder]) when ModuleOrder =/= [] ->
|
||||
write_json_result(build_bundle(type_conv:ensure_binary(BuildSha), ModuleOrder));
|
||||
run_main(["sign", SignerKeyId, PrivateKeyPath, BundlePath]) ->
|
||||
write_json_result(sign_bundle_file(SignerKeyId, PrivateKeyPath, BundlePath));
|
||||
run_main(["append", BuildSha, CreatedBy, EventPath]) ->
|
||||
Result = append_event(
|
||||
type_conv:ensure_binary(BuildSha), type_conv:ensure_binary(CreatedBy), EventPath
|
||||
),
|
||||
write_append_result(Result);
|
||||
run_main(_Args) ->
|
||||
write_usage(),
|
||||
64.
|
||||
|
||||
-spec build_bundle(binary(), [string()]) -> {ok, map()} | {error, term()}.
|
||||
build_bundle(BuildSha, ModuleSpecs) when is_binary(BuildSha), is_list(ModuleSpecs) ->
|
||||
build_bundle_modules(ModuleSpecs, fun(Modules) ->
|
||||
{ok, #{<<"version">> => 1, <<"build_sha">> => BuildSha, <<"modules">> => Modules}}
|
||||
end).
|
||||
|
||||
-spec sign_bundle(binary(), file:filename(), binary()) -> {ok, map()} | {error, term()}.
|
||||
sign_bundle(SignerKeyId, PrivateKeyPath, BundleJson) when
|
||||
is_binary(SignerKeyId), is_binary(BundleJson)
|
||||
->
|
||||
case decode_json(BundleJson) of
|
||||
{ok, Bundle} -> sign_decoded_bundle_with_key(SignerKeyId, PrivateKeyPath, Bundle);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec sign_decoded_bundle_with_key(binary(), file:filename(), term()) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
sign_decoded_bundle_with_key(SignerKeyId, PrivateKeyPath, Bundle) ->
|
||||
case read_private_key(PrivateKeyPath) of
|
||||
{ok, PrivateKey} -> sign_decoded_bundle(SignerKeyId, Bundle, PrivateKey);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec sign_bundle_file(string(), file:filename(), file:filename()) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
sign_bundle_file(SignerKeyId, PrivateKeyPath, BundlePath) ->
|
||||
case file:read_file(BundlePath) of
|
||||
{ok, BundleJson} ->
|
||||
sign_bundle(type_conv:ensure_binary(SignerKeyId), PrivateKeyPath, BundleJson);
|
||||
{error, Reason} ->
|
||||
{error, {read_bundle_failed, Reason}}
|
||||
end.
|
||||
|
||||
-spec sign_decoded_bundle(binary(), term(), binary()) -> {ok, map()} | {error, term()}.
|
||||
sign_decoded_bundle(SignerKeyId, BundleJson, PrivateKey) ->
|
||||
case json_to_bundle(BundleJson) of
|
||||
{ok, Bundle} -> sign_bundle_term(SignerKeyId, Bundle, PrivateKey);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec sign_bundle_term(binary(), map(), binary()) -> {ok, map()} | {error, term()}.
|
||||
sign_bundle_term(SignerKeyId, Bundle, PrivateKey) ->
|
||||
case gateway_hotpatch_bundle:compress_term(Bundle) of
|
||||
{ok, Compressed} -> sign_compressed_bundle(SignerKeyId, Compressed, PrivateKey);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec sign_compressed_bundle(binary(), binary(), binary()) -> {ok, map()} | {error, term()}.
|
||||
sign_compressed_bundle(SignerKeyId, Compressed, PrivateKey) ->
|
||||
case gateway_hotpatch_bundle:sign(Compressed, PrivateKey) of
|
||||
{ok, Signature} -> {ok, signed_event(SignerKeyId, Compressed, Signature)};
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec signed_event(binary(), binary(), binary()) -> map().
|
||||
signed_event(SignerKeyId, Compressed, Signature) ->
|
||||
#{
|
||||
<<"schema_version">> => 1,
|
||||
<<"kind">> => <<"beam_bundle">>,
|
||||
<<"created_by">> => gateway_hotpatch_runtime:node_name(),
|
||||
<<"signer_key_id">> => SignerKeyId,
|
||||
<<"bundle_sha256">> => encode_bytes(gateway_hotpatch_bundle:bundle_hash(Compressed)),
|
||||
<<"signature">> => encode_bytes(Signature),
|
||||
<<"bundle">> => encode_bytes(Compressed)
|
||||
}.
|
||||
|
||||
-spec append_event(binary(), binary(), file:filename()) -> {ok, binary()} | {error, term()}.
|
||||
append_event(BuildSha, CreatedBy, EventPath) ->
|
||||
case file:read_file(EventPath) of
|
||||
{ok, EventJson} -> append_event_json(BuildSha, CreatedBy, EventJson);
|
||||
{error, Reason} -> {error, {read_event_failed, Reason}}
|
||||
end.
|
||||
|
||||
-spec append_event_json(binary(), binary(), binary()) -> {ok, binary()} | {error, term()}.
|
||||
append_event_json(BuildSha, CreatedBy, EventJson) ->
|
||||
case decode_json(EventJson) of
|
||||
{ok, EventJsonTerm} -> append_event_term(BuildSha, CreatedBy, EventJsonTerm);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec append_event_term(binary(), binary(), term()) -> {ok, binary()} | {error, term()}.
|
||||
append_event_term(BuildSha, CreatedBy, EventJsonTerm) ->
|
||||
case json_to_event(EventJsonTerm) of
|
||||
{ok, Event} -> append_event_row(BuildSha, CreatedBy, Event);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec build_bundle_modules([string()], fun(([map()]) -> {ok, map()})) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
build_bundle_modules(ModuleSpecs, Fun) ->
|
||||
case build_bundle_module_list(ModuleSpecs, []) of
|
||||
{ok, Modules} -> Fun(Modules);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec build_bundle_module_list([string()], [map()]) -> {ok, [map()]} | {error, term()}.
|
||||
build_bundle_module_list([], Acc) ->
|
||||
{ok, lists:reverse(Acc)};
|
||||
build_bundle_module_list([ModuleSpec | Rest], Acc) ->
|
||||
case bundle_module(ModuleSpec) of
|
||||
{ok, Module} -> build_bundle_module_list(Rest, [Module | Acc]);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec bundle_module(string()) -> {ok, map()} | {error, term()}.
|
||||
bundle_module(ModuleSpec) ->
|
||||
case split_module_spec(ModuleSpec) of
|
||||
{ok, ModuleName, BeamPath0} -> bundle_named_module(ModuleName, BeamPath0);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec bundle_named_module(string(), file:filename() | undefined) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
bundle_named_module(ModuleName, BeamPath0) ->
|
||||
case existing_module(ModuleName) of
|
||||
{ok, Module} -> bundle_existing_module(Module, BeamPath0);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec bundle_existing_module(atom(), file:filename() | undefined) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
bundle_existing_module(Module, BeamPath0) ->
|
||||
case collect_bundle_parts(Module, BeamPath0) of
|
||||
{ok, CurrentMd5, TargetMd5, CompressedBeam} ->
|
||||
{ok, build_module_entry(Module, CurrentMd5, TargetMd5, CompressedBeam)};
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec collect_bundle_parts(atom(), file:filename() | undefined) ->
|
||||
{ok, binary(), binary(), binary()} | {error, term()}.
|
||||
collect_bundle_parts(Module, BeamPath0) ->
|
||||
case gateway_hotpatch_loader:current_md5(Module) of
|
||||
{ok, CurrentMd5} -> collect_bundle_beam(Module, BeamPath0, CurrentMd5);
|
||||
{error, Reason} -> {error, {current_md5_failed, Module, Reason}}
|
||||
end.
|
||||
|
||||
-spec collect_bundle_beam(atom(), file:filename() | undefined, binary()) ->
|
||||
{ok, binary(), binary(), binary()} | {error, term()}.
|
||||
collect_bundle_beam(Module, BeamPath0, CurrentMd5) ->
|
||||
case resolve_beam_path(Module, BeamPath0) of
|
||||
{ok, BeamPath} -> read_bundle_beam(Module, BeamPath, CurrentMd5);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec read_bundle_beam(atom(), file:filename(), binary()) ->
|
||||
{ok, binary(), binary(), binary()} | {error, term()}.
|
||||
read_bundle_beam(Module, BeamPath, CurrentMd5) ->
|
||||
case file:read_file(BeamPath) of
|
||||
{ok, Beam} -> hash_bundle_beam(Module, CurrentMd5, Beam);
|
||||
{error, Reason} -> {error, {read_beam_failed, Module, BeamPath, Reason}}
|
||||
end.
|
||||
|
||||
-spec hash_bundle_beam(atom(), binary(), binary()) ->
|
||||
{ok, binary(), binary(), binary()} | {error, term()}.
|
||||
hash_bundle_beam(Module, CurrentMd5, Beam) ->
|
||||
case gateway_hotpatch_loader:beam_md5(Beam) of
|
||||
{ok, TargetMd5} -> compress_bundle_beam(Module, CurrentMd5, TargetMd5, Beam);
|
||||
{error, Reason} -> {error, {target_md5_failed, Module, Reason}}
|
||||
end.
|
||||
|
||||
-spec compress_bundle_beam(atom(), binary(), binary(), binary()) ->
|
||||
{ok, binary(), binary(), binary()} | {error, term()}.
|
||||
compress_bundle_beam(Module, CurrentMd5, TargetMd5, Beam) ->
|
||||
case compress_beam(Beam) of
|
||||
{ok, CompressedBeam} -> {ok, CurrentMd5, TargetMd5, CompressedBeam};
|
||||
{error, Reason} -> {error, {beam_compress_failed, Module, Reason}}
|
||||
end.
|
||||
|
||||
-spec build_module_entry(atom(), binary(), binary(), binary()) -> map().
|
||||
build_module_entry(Module, CurrentMd5, TargetMd5, CompressedBeam) ->
|
||||
#{
|
||||
<<"module">> => atom_to_binary(Module, utf8),
|
||||
<<"expected_current_md5">> => encode_bytes(CurrentMd5),
|
||||
<<"target_md5">> => encode_bytes(TargetMd5),
|
||||
<<"beam_zstd">> => encode_bytes(CompressedBeam)
|
||||
}.
|
||||
|
||||
-spec append_event_row(binary(), binary(), map()) -> {ok, binary()} | {error, term()}.
|
||||
append_event_row(BuildSha, CreatedBy, Event) ->
|
||||
_ = fluxer_gateway_env:load(),
|
||||
case gateway_hotpatch_store:connect() of
|
||||
ok ->
|
||||
gateway_hotpatch_store:append_event(
|
||||
BuildSha,
|
||||
CreatedBy,
|
||||
maps:get(signer_key_id, Event),
|
||||
maps:get(signature, Event),
|
||||
maps:get(bundle_sha256, Event),
|
||||
maps:get(bundle, Event)
|
||||
);
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec split_module_spec(string()) ->
|
||||
{ok, string(), file:filename() | undefined} | {error, term()}.
|
||||
split_module_spec(ModuleSpec) ->
|
||||
case string:split(ModuleSpec, "=", leading) of
|
||||
[ModuleName, BeamPath] when ModuleName =/= "", BeamPath =/= "" ->
|
||||
{ok, ModuleName, BeamPath};
|
||||
[ModuleName] when ModuleName =/= "" ->
|
||||
{ok, ModuleName, undefined};
|
||||
_ ->
|
||||
{error, {invalid_module_spec, ModuleSpec}}
|
||||
end.
|
||||
|
||||
-spec existing_module(string()) -> {ok, atom()} | {error, term()}.
|
||||
existing_module(ModuleName) ->
|
||||
try
|
||||
{ok, list_to_existing_atom(ModuleName)}
|
||||
catch
|
||||
error:badarg -> {error, {unknown_module, ModuleName}}
|
||||
end.
|
||||
|
||||
-spec resolve_beam_path(atom(), file:filename() | undefined) ->
|
||||
{ok, file:filename()} | {error, term()}.
|
||||
resolve_beam_path(Module, undefined) ->
|
||||
case code:which(Module) of
|
||||
File when is_list(File) -> {ok, File};
|
||||
Other -> {error, {module_beam_not_found, Module, Other}}
|
||||
end;
|
||||
resolve_beam_path(_Module, BeamPath) ->
|
||||
{ok, BeamPath}.
|
||||
|
||||
-spec compress_beam(binary()) -> {ok, binary()} | {error, term()}.
|
||||
compress_beam(Beam) ->
|
||||
try erlang:apply(ezstd, compress, [Beam, 3]) of
|
||||
Compressed when is_binary(Compressed) -> {ok, Compressed};
|
||||
{error, Reason} -> {error, Reason};
|
||||
Other -> {error, Other}
|
||||
catch
|
||||
Class:Reason -> {error, {Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec decode_json(binary()) -> {ok, term()} | {error, term()}.
|
||||
decode_json(Json) ->
|
||||
try
|
||||
{ok, json:decode(Json)}
|
||||
catch
|
||||
Class:Reason -> {error, {decode_json_failed, Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec read_private_key(file:filename()) -> {ok, binary()} | {error, term()}.
|
||||
read_private_key(Path) ->
|
||||
case file:read_file(Path) of
|
||||
{ok, PrivateKey} when byte_size(PrivateKey) =:= 32 ->
|
||||
{ok, PrivateKey};
|
||||
{ok, PrivateKey} ->
|
||||
{error, {invalid_private_key_size, byte_size(PrivateKey)}};
|
||||
{error, Reason} ->
|
||||
{error, {read_private_key_failed, Reason}}
|
||||
end.
|
||||
|
||||
-spec json_to_bundle(term()) -> {ok, map()} | {error, term()}.
|
||||
json_to_bundle(#{
|
||||
<<"version">> := Version, <<"build_sha">> := BuildSha, <<"modules">> := Modules
|
||||
}) when is_list(Modules) ->
|
||||
case json_to_module_entries(Modules, []) of
|
||||
{ok, Entries} ->
|
||||
{ok, #{
|
||||
<<"version">> => Version,
|
||||
<<"build_sha">> => BuildSha,
|
||||
<<"modules">> => Entries
|
||||
}};
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end;
|
||||
json_to_bundle(Other) ->
|
||||
{error, {invalid_bundle_json, Other}}.
|
||||
|
||||
-spec json_to_module_entries([term()], [map()]) -> {ok, [map()]} | {error, term()}.
|
||||
json_to_module_entries([], Acc) ->
|
||||
{ok, lists:reverse(Acc)};
|
||||
json_to_module_entries([Module | Rest], Acc) ->
|
||||
case json_to_module_entry(Module) of
|
||||
{ok, Entry} -> json_to_module_entries(Rest, [Entry | Acc]);
|
||||
{error, Reason} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec json_to_module_entry(term()) -> {ok, map()} | {error, term()}.
|
||||
json_to_module_entry(#{
|
||||
<<"module">> := Module,
|
||||
<<"expected_current_md5">> := ExpectedMd5,
|
||||
<<"target_md5">> := TargetMd5,
|
||||
<<"beam_zstd">> := BeamZstd
|
||||
}) ->
|
||||
decode_module_entry(Module, ExpectedMd5, TargetMd5, BeamZstd);
|
||||
json_to_module_entry(Other) ->
|
||||
{error, {invalid_module_entry_json, Other}}.
|
||||
|
||||
-spec decode_module_entry(term(), term(), term(), term()) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
decode_module_entry(Module, ExpectedMd5, TargetMd5, BeamZstd) when
|
||||
is_binary(Module), is_binary(ExpectedMd5), is_binary(TargetMd5), is_binary(BeamZstd)
|
||||
->
|
||||
case decode_bytes(ExpectedMd5) of
|
||||
{ok, ExpectedMd5Bytes} ->
|
||||
decode_module_entry_target(Module, ExpectedMd5Bytes, TargetMd5, BeamZstd);
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end;
|
||||
decode_module_entry(Module, ExpectedMd5, TargetMd5, BeamZstd) ->
|
||||
{error, {invalid_module_entry_json, {Module, ExpectedMd5, TargetMd5, BeamZstd}}}.
|
||||
|
||||
-spec decode_module_entry_target(binary(), binary(), binary(), binary()) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
decode_module_entry_target(Module, ExpectedMd5, TargetMd5, BeamZstd) ->
|
||||
case decode_bytes(TargetMd5) of
|
||||
{ok, TargetMd5Bytes} ->
|
||||
decode_module_entry_beam(Module, ExpectedMd5, TargetMd5Bytes, BeamZstd);
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec decode_module_entry_beam(binary(), binary(), binary(), binary()) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
decode_module_entry_beam(Module, ExpectedMd5, TargetMd5, BeamZstd) ->
|
||||
case decode_bytes(BeamZstd) of
|
||||
{ok, BeamZstdBytes} ->
|
||||
{ok, #{
|
||||
<<"module">> => Module,
|
||||
<<"expected_current_md5">> => ExpectedMd5,
|
||||
<<"target_md5">> => TargetMd5,
|
||||
<<"beam_zstd">> => BeamZstdBytes
|
||||
}};
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec json_to_event(term()) -> {ok, map()} | {error, term()}.
|
||||
json_to_event(#{
|
||||
<<"signer_key_id">> := SignerKeyId,
|
||||
<<"bundle_sha256">> := BundleSha256,
|
||||
<<"signature">> := Signature,
|
||||
<<"bundle">> := Bundle
|
||||
}) ->
|
||||
decode_event_bytes(SignerKeyId, BundleSha256, Signature, Bundle);
|
||||
json_to_event(Other) ->
|
||||
{error, {invalid_event_json, Other}}.
|
||||
|
||||
-spec decode_event_bytes(term(), term(), term(), term()) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
decode_event_bytes(SignerKeyId, BundleSha256, Signature, Bundle) when
|
||||
is_binary(SignerKeyId), is_binary(BundleSha256), is_binary(Signature), is_binary(Bundle)
|
||||
->
|
||||
case decode_bytes(BundleSha256) of
|
||||
{ok, BundleSha256Bytes} ->
|
||||
decode_event_signature(SignerKeyId, BundleSha256Bytes, Signature, Bundle);
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end;
|
||||
decode_event_bytes(SignerKeyId, BundleSha256, Signature, Bundle) ->
|
||||
{error, {invalid_event_json, {SignerKeyId, BundleSha256, Signature, Bundle}}}.
|
||||
|
||||
-spec decode_event_signature(binary(), binary(), binary(), binary()) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
decode_event_signature(SignerKeyId, BundleSha256, Signature, Bundle) ->
|
||||
case decode_bytes(Signature) of
|
||||
{ok, SignatureBytes} ->
|
||||
decode_event_bundle(SignerKeyId, BundleSha256, SignatureBytes, Bundle);
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec decode_event_bundle(binary(), binary(), binary(), binary()) ->
|
||||
{ok, map()} | {error, term()}.
|
||||
decode_event_bundle(SignerKeyId, BundleSha256, Signature, Bundle) ->
|
||||
case decode_bytes(Bundle) of
|
||||
{ok, BundleBytes} ->
|
||||
{ok, #{
|
||||
signer_key_id => SignerKeyId,
|
||||
bundle_sha256 => BundleSha256,
|
||||
signature => Signature,
|
||||
bundle => BundleBytes
|
||||
}};
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec encode_bytes(binary()) -> binary().
|
||||
encode_bytes(Binary) ->
|
||||
base64:encode(Binary).
|
||||
|
||||
-spec decode_bytes(binary()) -> {ok, binary()} | {error, term()}.
|
||||
decode_bytes(Encoded) when is_binary(Encoded) ->
|
||||
try
|
||||
{ok, base64:decode(Encoded)}
|
||||
catch
|
||||
Class:Reason -> {error, {invalid_base64_bytes, Encoded, Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec write_json_result({ok, map()} | {error, term()}) -> non_neg_integer().
|
||||
write_json_result({ok, Term}) ->
|
||||
write_stdout("~ts~n", [json:encode(Term)]),
|
||||
0;
|
||||
write_json_result({error, Reason}) ->
|
||||
write_error(Reason).
|
||||
|
||||
-spec write_append_result({ok, binary()} | {error, term()}) -> non_neg_integer().
|
||||
write_append_result({ok, EventId}) ->
|
||||
write_stdout("appended hotpatch event ~ts~n", [gateway_hotpatch_loader:hex(EventId)]),
|
||||
0;
|
||||
write_append_result({error, Reason}) ->
|
||||
write_error(Reason).
|
||||
|
||||
-spec write_error(term()) -> non_neg_integer().
|
||||
write_error(Reason) ->
|
||||
write_stderr("gateway hotpatch failed: ~0tp~n", [Reason]),
|
||||
1.
|
||||
|
||||
-spec write_usage() -> ok.
|
||||
write_usage() ->
|
||||
write_stderr(
|
||||
"usage: gateway_hotpatch_cli bundle BUILD_SHA module_a=/path/to/module_a.beam ...~n"
|
||||
" gateway_hotpatch_cli sign SIGNER_KEY_ID PRIVATE_KEY_RAW_FILE bundle.json~n"
|
||||
" gateway_hotpatch_cli append BUILD_SHA CREATED_BY signed-event.json~n",
|
||||
[]
|
||||
).
|
||||
|
||||
-spec write_stdout(io:format(), [term()]) -> ok.
|
||||
write_stdout(Format, Args) ->
|
||||
write_stream(standard_io, Format, Args).
|
||||
|
||||
-spec write_stderr(io:format(), [term()]) -> ok.
|
||||
write_stderr(Format, Args) ->
|
||||
write_stream(standard_error, Format, Args).
|
||||
|
||||
-spec write_stream(file:io_device() | standard_io | standard_error, io:format(), [term()]) ->
|
||||
ok.
|
||||
write_stream(Device, Format, Args) ->
|
||||
Output = iolist_to_binary(io_lib:format(Format, Args)),
|
||||
_ = file:write(Device, Output),
|
||||
ok.
|
||||
@@ -1,433 +0,0 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(gateway_hotpatch_loader).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-export([
|
||||
apply_bundle/1,
|
||||
current_md5/1,
|
||||
beam_module/1,
|
||||
beam_md5/1,
|
||||
hex/1
|
||||
]).
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
-endif.
|
||||
|
||||
-type module_entry() :: map().
|
||||
-type apply_result() :: {ok, applied | skipped, atom(), binary()} | {error, term()}.
|
||||
|
||||
-spec apply_bundle(map()) -> {ok, map()} | {error, term()}.
|
||||
apply_bundle(Bundle) when is_map(Bundle) ->
|
||||
case
|
||||
{
|
||||
entry_value(version, Bundle),
|
||||
entry_value(build_sha, Bundle),
|
||||
entry_value(modules, Bundle)
|
||||
}
|
||||
of
|
||||
{1, BundleBuildSha, Modules} when is_list(Modules) ->
|
||||
apply_versioned_bundle(BundleBuildSha, Modules);
|
||||
_ ->
|
||||
{error, invalid_bundle}
|
||||
end;
|
||||
apply_bundle(_Bundle) ->
|
||||
{error, invalid_bundle}.
|
||||
|
||||
-spec apply_versioned_bundle(term(), [module_entry()]) -> {ok, map()} | {error, term()}.
|
||||
apply_versioned_bundle(BundleBuildSha, Modules) ->
|
||||
CurrentBuildSha = gateway_hotpatch_runtime:build_sha(),
|
||||
case normalize_binary(BundleBuildSha) of
|
||||
CurrentBuildSha -> apply_modules(Modules, #{applied => [], skipped => []});
|
||||
Other -> {error, {build_sha_mismatch, Other, CurrentBuildSha}}
|
||||
end.
|
||||
|
||||
-spec current_md5(atom()) -> {ok, binary()} | {error, term()}.
|
||||
current_md5(Module) when is_atom(Module) ->
|
||||
case hotpatch_loaded_md5(Module) of
|
||||
{ok, Md5} -> {ok, Md5};
|
||||
error -> current_md5_result(Module, current_md5_from_loaded_or_file(Module))
|
||||
end.
|
||||
|
||||
-spec current_md5_result(atom(), {ok, binary()} | {error, term()}) ->
|
||||
{ok, binary()} | {error, term()}.
|
||||
current_md5_result(_Module, {ok, Md5}) ->
|
||||
{ok, Md5};
|
||||
current_md5_result(Module, {error, Reason}) ->
|
||||
fallback_current_md5(Module, Reason).
|
||||
|
||||
-spec fallback_current_md5(atom(), term()) -> {ok, binary()} | {error, term()}.
|
||||
fallback_current_md5(Module, Reason) ->
|
||||
case hotpatch_loaded_md5(Module) of
|
||||
{ok, Md5} -> {ok, Md5};
|
||||
error -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec current_md5_from_loaded_or_file(atom()) -> {ok, binary()} | {error, term()}.
|
||||
current_md5_from_loaded_or_file(Module) ->
|
||||
case code:get_object_code(Module) of
|
||||
{Module, Beam, _File} when is_binary(Beam) -> beam_md5(Beam);
|
||||
error -> current_md5_from_file(Module)
|
||||
end.
|
||||
|
||||
-spec beam_module(binary()) -> {ok, atom()} | {error, term()}.
|
||||
beam_module(Beam) when is_binary(Beam) ->
|
||||
case beam_lib:info(Beam) of
|
||||
Info when is_list(Info) -> beam_module_from_info(Info);
|
||||
Other -> {error, {beam_info_failed, Other}}
|
||||
end.
|
||||
|
||||
-spec beam_module_from_info(list()) -> {ok, atom()} | {error, term()}.
|
||||
beam_module_from_info(Info) ->
|
||||
case lists:keyfind(module, 1, Info) of
|
||||
{module, Module} when is_atom(Module) -> {ok, Module};
|
||||
_ -> {error, module_not_found}
|
||||
end.
|
||||
|
||||
-spec beam_md5(binary()) -> {ok, binary()} | {error, term()}.
|
||||
beam_md5(Beam) when is_binary(Beam) ->
|
||||
case beam_lib:md5(Beam) of
|
||||
{ok, {_Module, Md5}} when is_binary(Md5) -> {ok, Md5};
|
||||
Error -> {error, {beam_md5_failed, Error}}
|
||||
end.
|
||||
|
||||
-spec hex(binary()) -> binary().
|
||||
hex(Binary) when is_binary(Binary) ->
|
||||
iolist_to_binary([[hex_nibble(High), hex_nibble(Low)] || <<High:4, Low:4>> <= Binary]).
|
||||
|
||||
-spec apply_modules([module_entry()], map()) -> {ok, map()} | {error, term()}.
|
||||
apply_modules([], Acc) ->
|
||||
{ok, Acc#{module_count => length(maps:get(applied, Acc)) + length(maps:get(skipped, Acc))}};
|
||||
apply_modules([Entry | Rest], Acc) ->
|
||||
case apply_module(Entry) of
|
||||
{ok, applied, Module, TargetMd5} ->
|
||||
apply_modules(
|
||||
Rest,
|
||||
Acc#{applied => [{Module, hex(TargetMd5)} | maps:get(applied, Acc)]}
|
||||
);
|
||||
{ok, skipped, Module, TargetMd5} ->
|
||||
apply_modules(
|
||||
Rest,
|
||||
Acc#{skipped => [{Module, hex(TargetMd5)} | maps:get(skipped, Acc)]}
|
||||
);
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec apply_module(module_entry()) -> apply_result().
|
||||
apply_module(Entry) ->
|
||||
with_entry(Entry, fun apply_valid_entry/4).
|
||||
|
||||
-spec apply_valid_entry(atom(), binary(), binary(), binary()) -> apply_result().
|
||||
apply_valid_entry(Module, ExpectedMd5, TargetMd5, Beam) ->
|
||||
case current_md5(Module) of
|
||||
{ok, TargetMd5} ->
|
||||
{ok, skipped, Module, TargetMd5};
|
||||
{ok, ExpectedMd5} ->
|
||||
load_module(Module, Beam, TargetMd5);
|
||||
{ok, CurrentMd5} ->
|
||||
{error, {md5_mismatch, Module, hex(CurrentMd5), hex(ExpectedMd5)}};
|
||||
{error, Reason} ->
|
||||
{error, {current_md5_failed, Module, Reason}}
|
||||
end.
|
||||
|
||||
-spec with_entry(module_entry(), fun((atom(), binary(), binary(), binary()) -> apply_result())) ->
|
||||
apply_result().
|
||||
with_entry(Entry, Fun) when is_map(Entry), is_function(Fun, 4) ->
|
||||
case normalize_module(entry_value(module, Entry)) of
|
||||
{ok, Module} ->
|
||||
ExpectedMd5 = entry_value(expected_current_md5, Entry),
|
||||
TargetMd5 = entry_value(target_md5, Entry),
|
||||
BeamZstd = entry_value(beam_zstd, Entry),
|
||||
with_entry_beam(Module, ExpectedMd5, TargetMd5, BeamZstd, Fun);
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end;
|
||||
with_entry(_Entry, _Fun) ->
|
||||
{error, invalid_module_entry}.
|
||||
|
||||
-spec with_entry_beam(atom(), term(), term(), term(), fun(
|
||||
(atom(), binary(), binary(), binary()) -> apply_result()
|
||||
)) ->
|
||||
apply_result().
|
||||
with_entry_beam(Module, ExpectedMd5, TargetMd5, BeamZstd, Fun) when
|
||||
is_binary(ExpectedMd5),
|
||||
is_binary(TargetMd5),
|
||||
byte_size(ExpectedMd5) =:= 16,
|
||||
byte_size(TargetMd5) =:= 16,
|
||||
is_binary(BeamZstd)
|
||||
->
|
||||
case decompress_beam(BeamZstd) of
|
||||
{ok, Beam} -> validate_beam(Module, ExpectedMd5, TargetMd5, Beam, Fun);
|
||||
{error, Reason} -> {error, {beam_decompress_failed, Module, Reason}}
|
||||
end;
|
||||
with_entry_beam(Module, _ExpectedMd5, _TargetMd5, _BeamZstd, _Fun) ->
|
||||
{error, {invalid_module_entry, Module}}.
|
||||
|
||||
-spec validate_beam(atom(), binary(), binary(), binary(), fun(
|
||||
(atom(), binary(), binary(), binary()) -> apply_result()
|
||||
)) ->
|
||||
apply_result().
|
||||
validate_beam(Module, ExpectedMd5, TargetMd5, Beam, Fun) ->
|
||||
case beam_module(Beam) of
|
||||
{ok, Module} -> validate_beam_md5(Module, ExpectedMd5, TargetMd5, Beam, Fun);
|
||||
{ok, OtherModule} -> {error, {beam_module_mismatch, Module, OtherModule}};
|
||||
{error, Reason} -> {error, {beam_module_failed, Module, Reason}}
|
||||
end.
|
||||
|
||||
-spec validate_beam_md5(atom(), binary(), binary(), binary(), fun(
|
||||
(atom(), binary(), binary(), binary()) -> apply_result()
|
||||
)) ->
|
||||
apply_result().
|
||||
validate_beam_md5(Module, ExpectedMd5, TargetMd5, Beam, Fun) ->
|
||||
case beam_md5(Beam) of
|
||||
{ok, TargetMd5} ->
|
||||
Fun(Module, ExpectedMd5, TargetMd5, Beam);
|
||||
{ok, OtherMd5} ->
|
||||
{error, {target_md5_mismatch, Module, hex(OtherMd5), hex(TargetMd5)}};
|
||||
{error, Reason} ->
|
||||
{error, {target_md5_failed, Module, Reason}}
|
||||
end.
|
||||
|
||||
-spec load_module(atom(), binary(), binary()) -> apply_result().
|
||||
load_module(Module, Beam, TargetMd5) ->
|
||||
case code:soft_purge(Module) of
|
||||
true -> load_purged_module(Module, Beam, TargetMd5);
|
||||
false -> {error, {soft_purge_failed, Module}}
|
||||
end.
|
||||
|
||||
-spec load_purged_module(atom(), binary(), binary()) -> apply_result().
|
||||
load_purged_module(Module, Beam, TargetMd5) ->
|
||||
case code:load_binary(Module, atom_to_list(Module) ++ ".beam", Beam) of
|
||||
{module, Module} ->
|
||||
put_hotpatch_loaded_md5(Module, TargetMd5),
|
||||
verify_loaded_module(Module, TargetMd5);
|
||||
{error, Reason} ->
|
||||
{error, {load_binary_failed, Module, Reason}}
|
||||
end.
|
||||
|
||||
-spec verify_loaded_module(atom(), binary()) -> apply_result().
|
||||
verify_loaded_module(Module, TargetMd5) ->
|
||||
case current_md5(Module) of
|
||||
{ok, TargetMd5} ->
|
||||
{ok, applied, Module, TargetMd5};
|
||||
{ok, OtherMd5} ->
|
||||
{error, {post_load_md5_mismatch, Module, hex(OtherMd5), hex(TargetMd5)}};
|
||||
{error, Reason} ->
|
||||
{error, {post_load_md5_failed, Module, Reason}}
|
||||
end.
|
||||
|
||||
-spec current_md5_from_file(atom()) -> {ok, binary()} | {error, term()}.
|
||||
current_md5_from_file(Module) ->
|
||||
case code:which(Module) of
|
||||
File when is_list(File) -> current_md5_from_path(Module, File);
|
||||
preloaded -> {error, preloaded};
|
||||
non_existing -> {error, not_loaded};
|
||||
Other -> {error, {not_loadable, Other}}
|
||||
end.
|
||||
|
||||
-spec current_md5_from_path(atom(), file:filename()) -> {ok, binary()} | {error, term()}.
|
||||
current_md5_from_path(Module, File) ->
|
||||
case beam_lib:md5(File) of
|
||||
{ok, {Module, Md5}} when is_binary(Md5) -> {ok, Md5};
|
||||
Error -> {error, {beam_file_md5_failed, Error}}
|
||||
end.
|
||||
|
||||
-spec hotpatch_loaded_md5(atom()) -> {ok, binary()} | error.
|
||||
hotpatch_loaded_md5(Module) ->
|
||||
case persistent_term:get({?MODULE, loaded_md5, Module}, undefined) of
|
||||
Md5 when is_binary(Md5), byte_size(Md5) =:= 16 -> {ok, Md5};
|
||||
_ -> error
|
||||
end.
|
||||
|
||||
-spec put_hotpatch_loaded_md5(atom(), binary()) -> ok.
|
||||
put_hotpatch_loaded_md5(Module, Md5) ->
|
||||
persistent_term:put({?MODULE, loaded_md5, Module}, Md5).
|
||||
|
||||
-spec decompress_beam(binary()) -> {ok, binary()} | {error, term()}.
|
||||
decompress_beam(Compressed) ->
|
||||
try erlang:apply(ezstd, decompress, [Compressed]) of
|
||||
Beam when is_binary(Beam) -> {ok, Beam};
|
||||
Beam when is_list(Beam) -> {ok, iolist_to_binary(Beam)};
|
||||
{error, Reason} -> {error, Reason};
|
||||
Other -> {error, Other}
|
||||
catch
|
||||
Class:Reason -> {error, {Class, Reason}}
|
||||
end.
|
||||
|
||||
-spec normalize_module(term()) -> {ok, atom()} | {error, term()}.
|
||||
normalize_module(Module) when is_atom(Module) ->
|
||||
{ok, Module};
|
||||
normalize_module(Module) when is_binary(Module) ->
|
||||
try
|
||||
{ok, binary_to_existing_atom(Module, utf8)}
|
||||
catch
|
||||
error:badarg -> {error, {unknown_module, Module}}
|
||||
end;
|
||||
normalize_module(Module) when is_list(Module) ->
|
||||
normalize_module(type_conv:ensure_binary(Module));
|
||||
normalize_module(Module) ->
|
||||
{error, {invalid_module, Module}}.
|
||||
|
||||
-spec normalize_binary(term()) -> binary().
|
||||
normalize_binary(Bin) when is_binary(Bin) -> Bin;
|
||||
normalize_binary(List) when is_list(List) -> type_conv:ensure_binary(List);
|
||||
normalize_binary(Atom) when is_atom(Atom) -> atom_to_binary(Atom, utf8);
|
||||
normalize_binary(Other) -> term_to_binary(Other).
|
||||
|
||||
-spec entry_value(atom(), map()) -> term().
|
||||
entry_value(Key, Entry) ->
|
||||
maps:get(Key, Entry, maps:get(atom_to_binary(Key, utf8), Entry, undefined)).
|
||||
|
||||
-spec hex_nibble(0..15) -> integer().
|
||||
hex_nibble(N) when N < 10 -> $0 + N;
|
||||
hex_nibble(N) -> $a + (N - 10).
|
||||
|
||||
-ifdef(TEST).
|
||||
|
||||
beam_module_and_md5_test() ->
|
||||
{module, ?MODULE} = code:ensure_loaded(?MODULE),
|
||||
{?MODULE, Binary, _File} = code:get_object_code(?MODULE),
|
||||
?assertEqual({ok, ?MODULE}, beam_module(Binary)),
|
||||
{ok, Md5} = beam_md5(Binary),
|
||||
?assertEqual(16, byte_size(Md5)).
|
||||
|
||||
apply_bundle_rejects_build_mismatch_test() ->
|
||||
Bundle = #{version => 1, build_sha => <<"definitely-not-this-build">>, modules => []},
|
||||
?assertMatch({error, {build_sha_mismatch, _, _}}, apply_bundle(Bundle)).
|
||||
|
||||
apply_bundle_accepts_binary_keys_test() ->
|
||||
Bundle = #{
|
||||
<<"version">> => 1,
|
||||
<<"build_sha">> => gateway_hotpatch_runtime:build_sha(),
|
||||
<<"modules">> => []
|
||||
},
|
||||
?assertEqual(
|
||||
{ok, #{applied => [], skipped => [], module_count => 0}}, apply_bundle(Bundle)
|
||||
).
|
||||
|
||||
apply_bundle_loads_new_beam_test() ->
|
||||
Module = gateway_hotpatch_loader_test_target,
|
||||
cleanup_test_module(Module),
|
||||
try
|
||||
Beam1 = compile_test_module(Module, 1),
|
||||
Beam2 = compile_test_module(Module, 2),
|
||||
{module, Module} = load_test_beam(Module, Beam1),
|
||||
?assertEqual(1, erlang:apply(Module, version, [])),
|
||||
{ok, ExpectedMd5} = current_md5(Module),
|
||||
{Entry, TargetMd5} = beam_entry(Module, ExpectedMd5, Beam2),
|
||||
Bundle = #{
|
||||
version => 1,
|
||||
build_sha => gateway_hotpatch_runtime:build_sha(),
|
||||
modules => [Entry]
|
||||
},
|
||||
?assertEqual(
|
||||
{ok, #{applied => [{Module, hex(TargetMd5)}], skipped => [], module_count => 1}},
|
||||
apply_bundle(Bundle)
|
||||
),
|
||||
?assertEqual(2, erlang:apply(Module, version, []))
|
||||
after
|
||||
cleanup_test_module(Module)
|
||||
end.
|
||||
|
||||
apply_bundle_leaves_prior_module_loaded_when_later_entry_fails_test() ->
|
||||
ModuleA = gateway_hotpatch_loader_test_partial_a,
|
||||
ModuleB = gateway_hotpatch_loader_test_partial_b,
|
||||
cleanup_test_module(ModuleA),
|
||||
cleanup_test_module(ModuleB),
|
||||
try
|
||||
BeamA1 = compile_test_module(ModuleA, 1),
|
||||
BeamA2 = compile_test_module(ModuleA, 2),
|
||||
BeamB1 = compile_test_module(ModuleB, 1),
|
||||
BeamB2 = compile_test_module(ModuleB, 2),
|
||||
{module, ModuleA} = load_test_beam(ModuleA, BeamA1),
|
||||
{module, ModuleB} = load_test_beam(ModuleB, BeamB1),
|
||||
{ok, Md5A1} = current_md5(ModuleA),
|
||||
WrongExpectedMd5 = <<0:128>>,
|
||||
{EntryA, _Md5A2} = beam_entry(ModuleA, Md5A1, BeamA2),
|
||||
{EntryB, _Md5B2} = beam_entry(ModuleB, WrongExpectedMd5, BeamB2),
|
||||
Bundle = #{
|
||||
version => 1,
|
||||
build_sha => gateway_hotpatch_runtime:build_sha(),
|
||||
modules => [EntryA, EntryB]
|
||||
},
|
||||
?assertMatch({error, {md5_mismatch, ModuleB, _, _}}, apply_bundle(Bundle)),
|
||||
?assertEqual(2, erlang:apply(ModuleA, version, [])),
|
||||
?assertEqual(1, erlang:apply(ModuleB, version, []))
|
||||
after
|
||||
cleanup_test_module(ModuleA),
|
||||
cleanup_test_module(ModuleB)
|
||||
end.
|
||||
|
||||
hex_test() ->
|
||||
?assertEqual(<<"0001020f10ff">>, hex(<<0, 1, 2, 15, 16, 255>>)).
|
||||
|
||||
compile_test_module(Module, Version) ->
|
||||
Dir = test_compile_dir(),
|
||||
Source = filename:join(Dir, atom_to_list(Module) ++ ".erl"),
|
||||
SourceText = io_lib:format(
|
||||
"-module(~p).~n-export([version/0]).~nversion() -> ~p.~n",
|
||||
[Module, Version]
|
||||
),
|
||||
ok = file:write_file(Source, SourceText),
|
||||
case compile:file(Source, [binary, return_errors, return_warnings]) of
|
||||
{ok, Module, Beam} -> Beam;
|
||||
{ok, Module, Beam, _Warnings} -> Beam;
|
||||
Error -> erlang:error({test_module_compile_failed, Module, Error})
|
||||
end.
|
||||
|
||||
test_compile_dir() ->
|
||||
Root =
|
||||
case os:getenv("TMPDIR") of
|
||||
false -> "/tmp";
|
||||
Value -> Value
|
||||
end,
|
||||
Dir = filename:join(Root, "fluxer_hotpatch_loader_tests"),
|
||||
ok = filelib:ensure_dir(filename:join(Dir, "placeholder")),
|
||||
Dir.
|
||||
|
||||
load_test_beam(Module, Beam) ->
|
||||
cleanup_test_module(Module),
|
||||
BeamPath = filename:join(test_compile_dir(), atom_to_list(Module) ++ ".beam"),
|
||||
ok = file:write_file(BeamPath, Beam),
|
||||
code:load_abs(filename:rootname(BeamPath)).
|
||||
|
||||
beam_entry(Module, ExpectedMd5, TargetBeam) ->
|
||||
{ok, TargetMd5} = beam_md5(TargetBeam),
|
||||
{ok, BeamZstd} = compress_test_beam(TargetBeam),
|
||||
{
|
||||
#{
|
||||
module => Module,
|
||||
expected_current_md5 => ExpectedMd5,
|
||||
target_md5 => TargetMd5,
|
||||
beam_zstd => BeamZstd
|
||||
},
|
||||
TargetMd5
|
||||
}.
|
||||
|
||||
compress_test_beam(Beam) ->
|
||||
try erlang:apply(ezstd, compress, [Beam, 3]) of
|
||||
Compressed when is_binary(Compressed) -> {ok, Compressed};
|
||||
{error, Reason} -> {error, Reason};
|
||||
Other -> {error, Other}
|
||||
catch
|
||||
Class:Reason -> {error, {Class, Reason}}
|
||||
end.
|
||||
|
||||
cleanup_test_module(Module) ->
|
||||
_ = code:soft_purge(Module),
|
||||
_ = code:purge(Module),
|
||||
_ = code:delete(Module),
|
||||
_ = code:purge(Module),
|
||||
erase_hotpatch_loaded_md5(Module),
|
||||
_ = file:delete(filename:join(test_compile_dir(), atom_to_list(Module) ++ ".erl")),
|
||||
_ = file:delete(filename:join(test_compile_dir(), atom_to_list(Module) ++ ".beam")),
|
||||
ok.
|
||||
|
||||
-spec erase_hotpatch_loaded_md5(atom()) -> ok.
|
||||
erase_hotpatch_loaded_md5(Module) ->
|
||||
_ = persistent_term:erase({?MODULE, loaded_md5, Module}),
|
||||
ok.
|
||||
|
||||
-endif.
|
||||
@@ -1,319 +0,0 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(gateway_hotpatch_reconciler).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-behaviour(gen_server).
|
||||
|
||||
-export([start_link/0, is_ready/0, status/0, reconcile_async/0]).
|
||||
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
|
||||
|
||||
-record(state, {
|
||||
enabled = false :: boolean(),
|
||||
build_sha = <<"dev">> :: binary(),
|
||||
public_keys = #{} :: #{binary() => binary()},
|
||||
applied_event_ids = [] :: [term()],
|
||||
applied_count = 0 :: non_neg_integer(),
|
||||
poll_interval_ms = 5000 :: pos_integer(),
|
||||
last_error = undefined :: term()
|
||||
}).
|
||||
|
||||
-type state() :: #state{}.
|
||||
|
||||
-define(SERVER, ?MODULE).
|
||||
-define(POLL, poll).
|
||||
-define(STARTUP_RECONCILE_RETRY_MS, 1000).
|
||||
|
||||
-spec start_link() -> gen_server:start_ret().
|
||||
start_link() ->
|
||||
gen_server:start_link({local, ?SERVER}, ?MODULE, [], []).
|
||||
|
||||
-spec is_ready() -> boolean().
|
||||
is_ready() ->
|
||||
gateway_hotpatch_runtime:is_ready().
|
||||
|
||||
-spec status() -> map().
|
||||
status() ->
|
||||
gateway_hotpatch_runtime:status().
|
||||
|
||||
-spec reconcile_async() -> ok.
|
||||
reconcile_async() ->
|
||||
gen_server:cast(?SERVER, reconcile).
|
||||
|
||||
-spec init([]) -> {ok, state()} | {stop, term()}.
|
||||
init([]) ->
|
||||
case gateway_hotpatch_runtime:is_enabled() of
|
||||
false -> init_disabled();
|
||||
true -> init_enabled()
|
||||
end.
|
||||
|
||||
-spec handle_call(term(), {pid(), term()}, state()) -> {reply, term(), state()}.
|
||||
handle_call(status, _From, State) ->
|
||||
{reply, state_status(State, gateway_hotpatch_runtime:is_ready()), State};
|
||||
handle_call(_Request, _From, State) ->
|
||||
{reply, {error, unsupported_call}, State}.
|
||||
|
||||
-spec handle_cast(term(), state()) -> {noreply, state()}.
|
||||
handle_cast(reconcile, State) ->
|
||||
{noreply, reconcile_and_publish(State)};
|
||||
handle_cast(_Request, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
-spec handle_info(term(), state()) -> {noreply, state()}.
|
||||
handle_info(?POLL, #state{enabled = true} = State) ->
|
||||
NewState = reconcile_and_publish(State),
|
||||
schedule_poll(NewState),
|
||||
{noreply, NewState};
|
||||
handle_info(_Info, State) ->
|
||||
{noreply, State}.
|
||||
|
||||
-spec terminate(term(), state()) -> ok.
|
||||
terminate(_Reason, _State) ->
|
||||
ok.
|
||||
|
||||
-spec code_change(term(), state(), term()) -> {ok, state()}.
|
||||
code_change(_OldVsn, State, _Extra) ->
|
||||
{ok, State}.
|
||||
|
||||
-spec init_disabled() -> {ok, state()}.
|
||||
init_disabled() ->
|
||||
BuildSha = gateway_hotpatch_runtime:build_sha(),
|
||||
State = #state{enabled = false, build_sha = BuildSha},
|
||||
gateway_hotpatch_runtime:put_ready(true),
|
||||
publish_status(State, true),
|
||||
{ok, State}.
|
||||
|
||||
-spec init_enabled() -> {ok, state()} | {stop, term()}.
|
||||
init_enabled() ->
|
||||
gateway_hotpatch_runtime:put_ready(false),
|
||||
BuildSha = gateway_hotpatch_runtime:build_sha(),
|
||||
PollIntervalMs = runtime_pos_integer(hotpatch_poll_interval_ms, 5000),
|
||||
PublicKeyConfig = gateway_hotpatch_runtime:get(hotpatch_public_keys, undefined),
|
||||
case gateway_hotpatch_bundle:parse_public_keys(PublicKeyConfig) of
|
||||
{ok, PublicKeys} when map_size(PublicKeys) > 0 ->
|
||||
State0 = #state{
|
||||
enabled = true,
|
||||
build_sha = BuildSha,
|
||||
public_keys = PublicKeys,
|
||||
poll_interval_ms = max(1000, PollIntervalMs)
|
||||
},
|
||||
publish_status(State0, false),
|
||||
init_enabled_connected(State0);
|
||||
{ok, _Empty} ->
|
||||
init_enabled_config_error(BuildSha, PollIntervalMs, missing_hotpatch_public_keys);
|
||||
{error, Reason} ->
|
||||
init_enabled_config_error(
|
||||
BuildSha, PollIntervalMs, {invalid_hotpatch_public_keys, Reason}
|
||||
)
|
||||
end.
|
||||
|
||||
-spec init_enabled_config_error(binary(), pos_integer(), term()) -> {ok, state()}.
|
||||
init_enabled_config_error(BuildSha, PollIntervalMs, Reason) ->
|
||||
State = #state{
|
||||
enabled = true,
|
||||
build_sha = BuildSha,
|
||||
poll_interval_ms = max(1000, PollIntervalMs),
|
||||
last_error = Reason
|
||||
},
|
||||
gateway_hotpatch_runtime:put_ready(false),
|
||||
publish_status(State, false),
|
||||
logger:error("Gateway hotpatch configuration invalid: ~0tp", [Reason]),
|
||||
schedule_poll(State),
|
||||
{ok, State}.
|
||||
|
||||
-spec init_enabled_connected(state()) -> {ok, state()}.
|
||||
init_enabled_connected(State0) ->
|
||||
finish_enabled_startup(State0).
|
||||
|
||||
-spec finish_enabled_startup(state()) -> {ok, state()}.
|
||||
finish_enabled_startup(State0) ->
|
||||
TimeoutMs = runtime_pos_integer(hotpatch_startup_sync_timeout_ms, 30000),
|
||||
DeadlineMs = erlang:monotonic_time(millisecond) + TimeoutMs,
|
||||
finish_startup_reconcile(State0, startup_reconcile(State0, DeadlineMs)).
|
||||
|
||||
-spec finish_startup_reconcile(state(), {ok, state()} | {error, term()}) -> {ok, state()}.
|
||||
finish_startup_reconcile(_State0, {ok, State}) ->
|
||||
gateway_hotpatch_runtime:put_ready(true),
|
||||
publish_status(State, true),
|
||||
schedule_poll(State),
|
||||
{ok, State};
|
||||
finish_startup_reconcile(State0, {error, Reason}) ->
|
||||
Error = {hotpatch_startup_sync_failed, Reason},
|
||||
State = State0#state{last_error = Error},
|
||||
gateway_hotpatch_runtime:put_ready(false),
|
||||
publish_status(State, false),
|
||||
logger:error("Gateway hotpatch startup sync failed; keeping node unready: ~0tp", [Error]),
|
||||
schedule_poll(State),
|
||||
{ok, State}.
|
||||
|
||||
-spec startup_reconcile(state(), integer()) -> {ok, state()} | {error, term()}.
|
||||
startup_reconcile(State, DeadlineMs) ->
|
||||
case reconcile_once(State) of
|
||||
{ok, NewState} ->
|
||||
{ok, NewState};
|
||||
{error, Reason} ->
|
||||
maybe_retry_startup_reconcile(State, DeadlineMs, Reason)
|
||||
end.
|
||||
|
||||
-spec maybe_retry_startup_reconcile(state(), integer(), term()) ->
|
||||
{ok, state()} | {error, term()}.
|
||||
maybe_retry_startup_reconcile(State, DeadlineMs, Reason) ->
|
||||
case erlang:monotonic_time(millisecond) >= DeadlineMs of
|
||||
true ->
|
||||
{error, Reason};
|
||||
false ->
|
||||
retry_startup_reconcile(State#state{last_error = Reason}, DeadlineMs, Reason)
|
||||
end.
|
||||
|
||||
-spec retry_startup_reconcile(state(), integer(), term()) -> {ok, state()} | {error, term()}.
|
||||
retry_startup_reconcile(State, DeadlineMs, Reason) ->
|
||||
case gateway_retry_timer:wait_until(?STARTUP_RECONCILE_RETRY_MS, DeadlineMs) of
|
||||
ok -> startup_reconcile(State, DeadlineMs);
|
||||
expired -> {error, Reason};
|
||||
{error, _InvalidDelay} -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec reconcile_and_publish(state()) -> state().
|
||||
reconcile_and_publish(#state{enabled = false} = State) ->
|
||||
gateway_hotpatch_runtime:put_ready(true),
|
||||
publish_status(State, true),
|
||||
State;
|
||||
reconcile_and_publish(State) ->
|
||||
case reconcile_once(State) of
|
||||
{ok, NewState} ->
|
||||
gateway_hotpatch_runtime:put_ready(true),
|
||||
publish_status(NewState, true),
|
||||
NewState;
|
||||
{error, Reason} ->
|
||||
publish_reconcile_error(State, Reason)
|
||||
end.
|
||||
|
||||
-spec reconcile_once(state()) -> {ok, state()} | {error, term()}.
|
||||
reconcile_once(#state{build_sha = BuildSha} = State) ->
|
||||
case gateway_hotpatch_store:connect() of
|
||||
ok -> fetch_and_apply_events(BuildSha, State);
|
||||
{error, Reason} -> {error, {store_connect_failed, Reason}}
|
||||
end.
|
||||
|
||||
-spec fetch_and_apply_events(binary(), state()) -> {ok, state()} | {error, term()}.
|
||||
fetch_and_apply_events(BuildSha, State) ->
|
||||
case gateway_hotpatch_store:fetch_events(BuildSha) of
|
||||
{ok, Events} -> apply_events(Events, State);
|
||||
{error, Reason} -> {error, {fetch_events_failed, Reason}}
|
||||
end.
|
||||
|
||||
-spec publish_reconcile_error(state(), term()) -> state().
|
||||
publish_reconcile_error(State, {fetch_events_failed, _Reason} = Error) ->
|
||||
ErrorState = State#state{last_error = Error},
|
||||
Ready = gateway_hotpatch_runtime:is_ready(),
|
||||
gateway_hotpatch_runtime:put_ready(Ready),
|
||||
publish_status(ErrorState, Ready),
|
||||
logger:warning("Gateway hotpatch fetch failed; keeping current readiness: ~0tp", [Error]),
|
||||
ErrorState;
|
||||
publish_reconcile_error(State, Reason) ->
|
||||
ErrorState = State#state{last_error = Reason},
|
||||
gateway_hotpatch_runtime:put_ready(false),
|
||||
publish_status(ErrorState, false),
|
||||
logger:error("Gateway hotpatch reconciliation failed: ~0tp", [Reason]),
|
||||
ErrorState.
|
||||
|
||||
-spec apply_events([map()], state()) -> {ok, state()} | {error, term()}.
|
||||
apply_events([], State) ->
|
||||
{ok, State#state{last_error = undefined}};
|
||||
apply_events([Event | Rest], State) ->
|
||||
EventId = maps:get(event_id, Event, undefined),
|
||||
case lists:member(EventId, State#state.applied_event_ids) of
|
||||
true ->
|
||||
apply_events(Rest, State);
|
||||
false ->
|
||||
apply_new_event(EventId, Event, Rest, State)
|
||||
end.
|
||||
|
||||
-spec apply_new_event(term(), map(), [map()], state()) -> {ok, state()} | {error, term()}.
|
||||
apply_new_event(EventId, Event, Rest, State) ->
|
||||
case apply_event(Event, State) of
|
||||
{ok, NewState} -> apply_events(Rest, NewState);
|
||||
{error, Reason} -> {error, {event_apply_failed, EventId, Reason}}
|
||||
end.
|
||||
|
||||
-spec apply_event(map(), state()) -> {ok, state()} | {error, term()}.
|
||||
apply_event(Event, #state{public_keys = PublicKeys, build_sha = BuildSha} = State) ->
|
||||
EventId = maps:get(event_id, Event, undefined),
|
||||
BundleHash = maps:get(bundle_sha256, Event, <<>>),
|
||||
_ = code:ensure_loaded(gateway_hotpatch_loader),
|
||||
case gateway_hotpatch_bundle:decode_signed_event(Event, PublicKeys) of
|
||||
{ok, Bundle} ->
|
||||
apply_decoded_event(BuildSha, EventId, BundleHash, Bundle, State);
|
||||
{error, Reason} ->
|
||||
audit_event(BuildSha, EventId, #{bundle_sha256 => BundleHash}, {error, Reason}),
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec apply_decoded_event(binary(), term(), binary(), map(), state()) ->
|
||||
{ok, state()} | {error, term()}.
|
||||
apply_decoded_event(BuildSha, EventId, BundleHash, Bundle, State) ->
|
||||
Summary0 = #{bundle_sha256 => BundleHash},
|
||||
case gateway_hotpatch_loader:apply_bundle(Bundle) of
|
||||
{ok, Summary} ->
|
||||
handle_applied_event(BuildSha, EventId, Summary0, Summary, State);
|
||||
{error, Reason} ->
|
||||
audit_event(BuildSha, EventId, Summary0, {error, Reason}),
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec handle_applied_event(binary(), term(), map(), map(), state()) -> {ok, state()}.
|
||||
handle_applied_event(BuildSha, EventId, Summary0, Summary, State) ->
|
||||
AuditSummary = maps:merge(Summary0, Summary),
|
||||
audit_event(BuildSha, EventId, AuditSummary, ok),
|
||||
logger:notice("Applied gateway hotpatch event ~0tp summary=~0tp", [EventId, AuditSummary]),
|
||||
{ok, State#state{
|
||||
applied_event_ids = [EventId | State#state.applied_event_ids],
|
||||
applied_count = State#state.applied_count + 1,
|
||||
last_error = undefined
|
||||
}}.
|
||||
|
||||
-spec audit_event(binary(), term(), map(), ok | {error, term()}) -> ok.
|
||||
audit_event(BuildSha, EventId, Summary, Result) when is_binary(EventId) ->
|
||||
case
|
||||
gateway_hotpatch_store:audit_applied(
|
||||
BuildSha, gateway_hotpatch_runtime:node_name(), EventId, Summary, Result
|
||||
)
|
||||
of
|
||||
ok ->
|
||||
ok;
|
||||
{error, Reason} ->
|
||||
logger:warning("Gateway hotpatch audit write failed: ~0tp", [Reason]),
|
||||
ok
|
||||
end;
|
||||
audit_event(_BuildSha, _EventId, _Summary, _Result) ->
|
||||
ok.
|
||||
|
||||
-spec publish_status(state(), boolean()) -> ok.
|
||||
publish_status(State, Ready) ->
|
||||
gateway_hotpatch_runtime:put_status(state_status(State, Ready)).
|
||||
|
||||
-spec state_status(state(), boolean()) -> map().
|
||||
state_status(State, Ready) ->
|
||||
#{
|
||||
enabled => State#state.enabled,
|
||||
ready => Ready,
|
||||
build_sha => State#state.build_sha,
|
||||
applied_count => State#state.applied_count,
|
||||
applied_event_count => length(State#state.applied_event_ids),
|
||||
last_error => State#state.last_error
|
||||
}.
|
||||
|
||||
-spec schedule_poll(state()) -> ok.
|
||||
schedule_poll(#state{poll_interval_ms = PollIntervalMs}) ->
|
||||
erlang:send_after(PollIntervalMs, self(), ?POLL),
|
||||
ok.
|
||||
|
||||
-spec runtime_pos_integer(atom(), pos_integer()) -> pos_integer().
|
||||
runtime_pos_integer(Key, Default) ->
|
||||
normalize_pos_integer(gateway_hotpatch_runtime:get(Key, Default), Default).
|
||||
|
||||
-spec normalize_pos_integer(term(), pos_integer()) -> pos_integer().
|
||||
normalize_pos_integer(Value, _Default) when is_integer(Value), Value > 0 ->
|
||||
Value;
|
||||
normalize_pos_integer(_Value, Default) ->
|
||||
Default.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user