Compare commits

..
Author SHA1 Message Date
jude 4c98265657 Performance improvements 2025-12-03 21:55:35 +00:00
92 changed files with 467 additions and 3752 deletions

No files matched your search

-2
View File
@@ -1,7 +1,6 @@
target target
.env .env
/venv /venv
__pycache__/
.cargo .cargo
.idea .idea
static/index.html static/index.html
@@ -29,4 +28,3 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
.junie
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "\n UPDATE channels\n SET\n name = ?,\n nudge = ?,\n blacklisted = ?,\n webhook_id = ?,\n webhook_token = ?,\n paused = ?,\n paused_until = ?\n WHERE id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 8
},
"nullable": []
},
"hash": "1bd6f70d57d5e913f97025cf6fc5be795a1302485ef1c07fe13eb246f184a1d1"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "MySQL", "db_name": "MySQL",
"query": "\n SELECT id, channel, nudge, webhook_id, webhook_token, paused,\n paused_until\n FROM channels\n WHERE channel = ?\n ", "query": "\n SELECT id, channel, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until\n FROM channels\n WHERE channel = ?\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -23,6 +23,15 @@
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 400
}
},
{
"ordinal": 3,
"name": "nudge", "name": "nudge",
"type_info": { "type_info": {
"type": "Short", "type": "Short",
@@ -31,7 +40,16 @@
} }
}, },
{ {
"ordinal": 3, "ordinal": 4,
"name": "blacklisted",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 5,
"name": "webhook_id", "name": "webhook_id",
"type_info": { "type_info": {
"type": "LongLong", "type": "LongLong",
@@ -40,7 +58,7 @@
} }
}, },
{ {
"ordinal": 4, "ordinal": 6,
"name": "webhook_token", "name": "webhook_token",
"type_info": { "type_info": {
"type": "Blob", "type": "Blob",
@@ -49,7 +67,7 @@
} }
}, },
{ {
"ordinal": 5, "ordinal": 7,
"name": "paused", "name": "paused",
"type_info": { "type_info": {
"type": "Tiny", "type": "Tiny",
@@ -58,7 +76,7 @@
} }
}, },
{ {
"ordinal": 6, "ordinal": 8,
"name": "paused_until", "name": "paused_until",
"type_info": { "type_info": {
"type": "Timestamp", "type": "Timestamp",
@@ -73,6 +91,8 @@
"nullable": [ "nullable": [
false, false,
false, false,
true,
false,
false, false,
true, true,
true, true,
@@ -80,5 +100,5 @@
true true
] ]
}, },
"hash": "017b3f73101926a3fe029ed75c412ec902aa74fee334c23fd75fe0cde1e324ca" "hash": "4d26e92088234552f9159b876c7fdd3bb557a7f918820e289bd39742de553883"
} }
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "INSERT IGNORE INTO channels (channel, guild_id) VALUES (?, (SELECT id FROM guilds WHERE guild = ?))",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "5d7a787e3a6f487c3fe00bd39d308c9350c82e58b0ad1aae0b92824375786f02"
}
@@ -1,12 +0,0 @@
{
"db_name": "MySQL",
"query": "\n UPDATE channels\n SET\n nudge = ?,\n webhook_id = ?,\n webhook_token = ?,\n paused = ?,\n paused_until = ?\n WHERE id = ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "af08f3524c316f438feb62a24c67b96197e3655a87efe128914ed386be20d900"
}
@@ -1,6 +1,6 @@
{ {
"db_name": "MySQL", "db_name": "MySQL",
"query": "\n SELECT id, channel, nudge, webhook_id, webhook_token, paused, paused_until\n FROM channels\n WHERE channel = ?\n ", "query": "\n SELECT id, channel, name, nudge, blacklisted, webhook_id, webhook_token, paused,\n paused_until\n FROM channels\n WHERE channel = ?\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -23,6 +23,15 @@
}, },
{ {
"ordinal": 2, "ordinal": 2,
"name": "name",
"type_info": {
"type": "VarString",
"flags": "",
"max_size": 400
}
},
{
"ordinal": 3,
"name": "nudge", "name": "nudge",
"type_info": { "type_info": {
"type": "Short", "type": "Short",
@@ -31,7 +40,16 @@
} }
}, },
{ {
"ordinal": 3, "ordinal": 4,
"name": "blacklisted",
"type_info": {
"type": "Tiny",
"flags": "NOT_NULL",
"max_size": 1
}
},
{
"ordinal": 5,
"name": "webhook_id", "name": "webhook_id",
"type_info": { "type_info": {
"type": "LongLong", "type": "LongLong",
@@ -40,7 +58,7 @@
} }
}, },
{ {
"ordinal": 4, "ordinal": 6,
"name": "webhook_token", "name": "webhook_token",
"type_info": { "type_info": {
"type": "Blob", "type": "Blob",
@@ -49,7 +67,7 @@
} }
}, },
{ {
"ordinal": 5, "ordinal": 7,
"name": "paused", "name": "paused",
"type_info": { "type_info": {
"type": "Tiny", "type": "Tiny",
@@ -58,7 +76,7 @@
} }
}, },
{ {
"ordinal": 6, "ordinal": 8,
"name": "paused_until", "name": "paused_until",
"type_info": { "type_info": {
"type": "Timestamp", "type": "Timestamp",
@@ -73,6 +91,8 @@
"nullable": [ "nullable": [
false, false,
false, false,
true,
false,
false, false,
true, true,
true, true,
@@ -80,5 +100,5 @@
true true
] ]
}, },
"hash": "21b660ecbeffcac31ef283f59e420360a28b344759407024a11dc4a04da9341d" "hash": "d3e41b7c9df5823c418181f46aa78999159afbd458dcd05ffb9e032182ec493b"
} }
@@ -0,0 +1,12 @@
{
"db_name": "MySQL",
"query": "INSERT IGNORE INTO channels (channel, name, guild_id) VALUES (?, ?, (SELECT id FROM guilds WHERE guild = ?))",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "f8e99a39fb87d47bcdf70f38e00c4dbe4d6832b1b073efddfa79a8cd756d8dc6"
}
Generated
+80 -1319
View File
File diff suppressed because it is too large. Load diff
+2 -11
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "reminder-rs" name = "reminder-rs"
version = "1.7.45" version = "1.7.41"
authors = ["Jude Southworth <judesouthworth@pm.me>"] authors = ["Jude Southworth <judesouthworth@pm.me>"]
edition = "2021" edition = "2021"
license = "AGPL-3.0 only" license = "AGPL-3.0 only"
@@ -36,9 +36,6 @@ oauth2 = "4"
csv = "1.2" csv = "1.2"
sd-notify = "0.4.1" sd-notify = "0.4.1"
cron-parser = "0.10" cron-parser = "0.10"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
nsfw = { version = "0.2", default-features = false }
rust-s3 = { version = "0.38", default-features = false, features = ["tokio-rustls-tls"] }
[dependencies.extract_derive] [dependencies.extract_derive]
path = "extract_derive" path = "extract_derive"
@@ -71,12 +68,6 @@ conf-files = [
"/etc/reminder-rs/Rocket.toml", "/etc/reminder-rs/Rocket.toml",
] ]
[[package.metadata.deb.systemd-units]] [package.metadata.deb.systemd-units]
unit-scripts = "systemd" unit-scripts = "systemd"
unit-name = "reminder-rs"
start = false
[[package.metadata.deb.systemd-units]]
unit-scripts = "systemd"
unit-name = "reminder-rs-clean-images"
start = false start = false
+3
View File
@@ -25,6 +25,9 @@ COPY ./Cargo.lock ./
COPY ./Cargo.toml ./ COPY ./Cargo.toml ./
COPY ./dp.py ./ COPY ./dp.py ./
# Build dashboard assets explicitly to ensure dist exists
RUN npm ci --prefix reminder-dashboard && npm run build --prefix reminder-dashboard
# Build and install the Rust binary # Build and install the Rust binary
RUN cargo install --path . RUN cargo install --path .
-22
View File
@@ -1,22 +0,0 @@
.DEFAULT_GOAL := build
CARGO ?= cargo
PODMAN ?= podman
IMAGE ?= reminder-rs
.PHONY: build prepare test image
# Keep the stages ordered even when make is run with -j.
build: image
$(PODMAN) run --rm -v "$(CURDIR):/mnt" -w /mnt \
-e SQLX_OFFLINE=true "$(IMAGE)" cargo deb
image: test
$(PODMAN) build -t "$(IMAGE)" .
test: prepare
SQLX_OFFLINE=true $(CARGO) test --locked
# Include test-only queries in the metadata used by offline compilation.
prepare:
SQLX_OFFLINE=false $(CARGO) sqlx prepare -- --all-targets --locked
+25 -102
View File
@@ -18,26 +18,14 @@ Recommended method.
By default, this builds targeting Ubuntu 20.04. Modify the Containerfile if you wish to target a By default, this builds targeting Ubuntu 20.04. Modify the Containerfile if you wish to target a
different platform. These instructions are written using `podman`, but `docker` should work too. different platform. These instructions are written using `podman`, but `docker` should work too.
1. Install container software and Make: `sudo apt install podman make`. 1. Install container software: `sudo apt install podman`.
2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called 2. Install database server: `sudo apt install mysql-server-8.0`. Create a database called
`reminders` `reminders`
3. Install SQLx CLI matching the project's SQLx version: `cargo install sqlx-cli --version 0.8.2 --locked`. 3. Install SQLx CLI: `cargo install sqlx-cli`
4. Install the local build requirements (Rust, Node.js/npm, GCC, CMake, pkg-config, and OpenSSL 4. From the source code directory, execute `sqlx migrate run`
development headers), then install dashboard dependencies: `npm ci --prefix reminder-dashboard`. 5. Build container image: `podman build -t reminder-rs .`
5. Export the URL of your build/test database: 6. Build with podman:
`export DATABASE_URL="mysql://user[:password]@localhost/reminders"`. `podman run --rm --network=host -v "$PWD":/mnt -w /mnt -e "DATABASE_URL=mysql://user@localhost/reminders" reminder-rs cargo deb`
6. From the source code directory, execute `sqlx migrate run`.
7. Run `make` (or `make build`). This prepares SQLx query metadata in `.sqlx`, runs the Rust tests,
builds the `reminder-rs` container image, and runs `cargo deb` inside it. The resulting package is
written to `target/debian/`.
Preparation and database-dependent tests use the local database. The container packaging step uses
the prepared `.sqlx` metadata with `SQLX_OFFLINE=true`, so it does not need database access, including
when a `.env` file is present in the mounted source directory.
The stages can also be run separately: `make prepare`, `make test` (includes preparation), and
`make image` (includes preparation and tests). Override `IMAGE` to change the container image tag,
or `PODMAN` to use another container command, for example `make PODMAN=docker`.
### Compiling for other target ### Compiling for other target
@@ -50,7 +38,7 @@ or `PODMAN` to use another container command, for example `make PODMAN=docker`.
5. Install `sqlx-cli`: `cargo install sqlx-cli`. 5. Install `sqlx-cli`: `cargo install sqlx-cli`.
6. Run migrations: `sqlx migrate run`. 6. Run migrations: `sqlx migrate run`.
7. Set environment variables: 7. Set environment variables:
- `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`) * `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
8. Build: `cargo build --release` 8. Build: `cargo build --release`
### Configuring ### Configuring
@@ -58,97 +46,32 @@ or `PODMAN` to use another container command, for example `make PODMAN=docker`.
Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded Reminder Bot reads a number of environment variables. Some are essential, and others have hardcoded
fallbacks. Environment variables can be loaded from a .env file in the working directory. fallbacks. Environment variables can be loaded from a .env file in the working directory.
**Required Variables** __Required Variables__
- `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`) * `DATABASE_URL` - the URL of your MySQL database (`mysql://user[:password]@domain/database`)
- `DISCORD_TOKEN` - your application's bot user's authorization token * `DISCORD_TOKEN` - your application's bot user's authorization token
**Other Variables** __Other Variables__
- `MIN_INTERVAL` - default `600`, defines the shortest interval the bot should accept * `MIN_INTERVAL` - default `600`, defines the shortest interval the bot should accept
- `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor * `LOCAL_TIMEZONE` - default `UTC`, necessary for calculations in the natural language processor
- `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to * `SUBSCRIPTION_ROLES` - default `None`, accepts a list of Discord role IDs that are given to
subscribed users subscribed users
- `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the * `CNC_GUILD` - default `None`, accepts a single Discord guild ID for the server that the
subscription roles belong to subscription roles belong to
- `PYTHON_LOCATION` - default `/usr/bin/python3`. Can be changed if your Python executable is * `PYTHON_LOCATION` - default `/usr/bin/python3`. Can be changed if your Python executable is
located somewhere else located somewhere else
- `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message * `THEME_COLOR` - default `8fb677`. Specifies the hex value of the color to use on info message
embeds embeds
### Dashboard image uploads
Image URL inputs offer an **Upload image** button when all of these settings are supplied:
- `S3_BUCKET` — destination bucket name.
- `S3_REGION` — bucket region, for example `eu-west-2`.
- `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY` — credentials with `s3:PutObject` and
`s3:DeleteObject` permissions for the bucket's `uploads/` prefix.
- `S3_PUBLIC_URL` — public base URL for the bucket or CDN, for example
`https://my-bucket.s3.eu-west-2.amazonaws.com`. Objects must be publicly readable through
this URL; the application does not change bucket policies or set object ACLs.
`NSFW_CHECK_ENABLED` defaults to `true`, and enables the use of a small model to block uploading
of NSFW images. Set it to `false` to skip NSFW moderation and model initialization. Empty or
unrecognized values keep checks enabled.
The Debian package includes `reminder-rs-clean-images.timer`, an hourly cleanup job. Enable it
after configuring S3 and starting the bot (which applies database migrations):
```sh
sudo systemctl enable --now reminder-rs-clean-images.timer
```
Each run records the first observed orphan time in `image_uploads.orphaned_at` (UTC). An upload
is orphaned when neither a reminder (including archived reminders) nor a template uses its URL
as an avatar, image, thumbnail, author icon or footer icon. Referenced uploads have this timestamp
cleared. `IMAGE_UPLOAD_ORPHAN_RETENTION_SECONDS` controls the additional retention time after
detection; it defaults to `604800` (seven days) and must be a positive integer. Unsaved uploads
are also covered. Cleanup rechecks references before deleting and removes the tracking row only
after S3 confirms deletion; failed deletions are retried on later runs. Each run attempts up to
1,000 expired uploads from the configured public URL's `uploads/` prefix.
For other deployments, schedule `/path/to/reminder-rs clean-images` hourly with cron, using the
same working directory and configuration as the bot. The command requires no Discord token
and skips NSFW model initialization. With no S3 settings it exits successfully without work;
partial S3 settings or invalid retention values fail the job.
## Running with Docker ## Running with Docker
### RustFS for local development
Start the local object store and initialize the image upload bucket:
```sh
docker compose --env-file conf/rustfs.env -f compose.rustfs.yaml up -d
```
Add the settings from `conf/rustfs.env` to your local `.env`, then restart the bot running on
your host. RustFS serves the S3 API at `http://127.0.0.1:9000` and its console at
`http://127.0.0.1:9001`. Log into the console with the access key and secret key from
`conf/rustfs.env`. Both ports bind to localhost. The named data volume persists across restarts.
The initializer creates `reminder-images` if needed and permits anonymous reads of its
`uploads/` objects. Uploads, deletes and bucket listing require credentials. These settings
enable dashboard image uploads locally; `NSFW_CHECK_ENABLED` keeps its configured value.
`S3_ENDPOINT` uses path-style addressing for RustFS, and `S3_PUBLIC_URL` includes the bucket
name because the browser fetches objects through that public URL.
```sh
docker compose --env-file conf/rustfs.env -f compose.rustfs.yaml logs rustfs-init
docker compose --env-file conf/rustfs.env -f compose.rustfs.yaml down
```
`down` keeps the data volume. This development stack is separate from the bot/database stack
below. The [RustFS Docker documentation](https://docs.rustfs.com/en/installation/container/docker)
describes the upstream container configuration.
A `compose.yml` file is provided to aid in running the bot agnostically using docker. A `compose.yml` file is provided to aid in running the bot agnostically using docker.
- Populate a `.env` file as in `conf/default.env` * Populate a `.env` file as in `conf/default.env`
- Add the additional variable `ROCKET_SECRET_KEY` with a key generated from * Add the additional variable `ROCKET_SECRET_KEY` with a key generated from
`head -c64 /dev/urandom | base64` `head -c64 /dev/urandom | base64`
- Run `docker compose up` * Run `docker compose up`
Please note that this is _not_ production-ready when run via compose. We do not offer a way for Please note that this is _not_ production-ready when run via compose. We do not offer a way for
backing up of your data, or a way to run the dashboard securely via HTTPS, which is required for backing up of your data, or a way to run the dashboard securely via HTTPS, which is required for
-7
View File
@@ -1,7 +0,0 @@
# Bundled NSFW model
`model.onnx` is the pretrained model distributed with
[Fyko/nsfw v0.2.0](https://github.com/fyko/nsfw/releases/tag/v0.2.0), based on
[GantMan/nsfw_model](https://github.com/GantMan/nsfw_model).
Source: https://github.com/fyko/nsfw/releases/download/v0.2.0/model.onnx
Binary file not shown.
+3 -10
View File
@@ -8,26 +8,19 @@ fn main() {
.arg("run") .arg("run")
.arg("build") .arg("build")
.current_dir(Path::new("reminder-dashboard")) .current_dir(Path::new("reminder-dashboard"))
.env("VITE_VERSION", env!("CARGO_PKG_VERSION"))
.spawn() .spawn()
.expect("Failed to build NPM") .expect("Failed to build NPM");
.wait()
.expect("Failed to wait for NPM build");
Command::new("cp") Command::new("cp")
.arg("reminder-dashboard/dist/index.html") .arg("reminder-dashboard/dist/index.html")
.arg("static/index.html") .arg("static/index.html")
.spawn() .spawn()
.expect("Failed to copy index.html") .expect("Failed to copy index.html");
.wait()
.expect("Failed to wait for index.html copy");
Command::new("cp") Command::new("cp")
.arg("-r") .arg("-r")
.arg("reminder-dashboard/dist/static/assets") .arg("reminder-dashboard/dist/static/assets")
.arg("static/") .arg("static/")
.spawn() .spawn()
.expect("Failed to copy assets") .expect("Failed to copy assets");
.wait()
.expect("Failed to wait for assets copy");
} }
-63
View File
@@ -1,63 +0,0 @@
name: reminder-rustfs-dev
services:
rustfs-permissions:
image: busybox:1.37
user: "0:0"
volumes:
- rustfs-data:/data
- rustfs-logs:/logs
command: ["sh", "-c", "chown 10001:10001 /data /logs"]
restart: "no"
rustfs:
image: rustfs/rustfs:latest
restart: unless-stopped
depends_on:
rustfs-permissions:
condition: service_completed_successfully
environment:
RUSTFS_ACCESS_KEY: ${S3_ACCESS_KEY_ID:-REMINDERDEV}
RUSTFS_SECRET_KEY: ${S3_SECRET_ACCESS_KEY:-reminder-rustfs-local-secret}
RUSTFS_REGION: ${S3_REGION:-us-east-1}
RUSTFS_VOLUMES: /data
RUSTFS_ADDRESS: "0.0.0.0:9000"
RUSTFS_CONSOLE_ADDRESS: "0.0.0.0:9001"
RUSTFS_CONSOLE_ENABLE: "true"
RUSTFS_OBS_LOGGER_LEVEL: warn
RUSTFS_OBS_LOG_DIRECTORY: /logs
# Local single-volume development storage.
RUSTFS_UNSAFE_BYPASS_DISK_CHECK: "true"
volumes:
- rustfs-data:/data
- rustfs-logs:/logs
ports:
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9000/health"]
interval: 3s
timeout: 5s
retries: 20
start_period: 10s
rustfs-init:
image: amazon/aws-cli:latest
depends_on:
rustfs:
condition: service_healthy
environment:
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-REMINDERDEV}
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-reminder-rustfs-local-secret}
AWS_DEFAULT_REGION: ${S3_REGION:-us-east-1}
AWS_EC2_METADATA_DISABLED: "true"
AWS_PAGER: ""
S3_BUCKET: ${S3_BUCKET:-reminder-images}
volumes:
- ./scripts/init-rustfs.sh:/init-rustfs.sh:ro
entrypoint: ["/bin/sh", "/init-rustfs.sh"]
restart: "no"
volumes:
rustfs-data:
rustfs-logs:
-15
View File
@@ -20,18 +20,3 @@ REPORT_EMAIL=
SENT_CLEAN_AGE= SENT_CLEAN_AGE=
# Delete all reminders after this many months # Delete all reminders after this many months
TOTAL_CLEAN_AGE= TOTAL_CLEAN_AGE=
# Optional dashboard image uploads. Leave these blank to use image URLs only.
# All five S3 settings are required. The bucket/CDN must serve objects publicly.
S3_BUCKET=
S3_REGION=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_PUBLIC_URL=
# Optional S3-compatible endpoint (uses path-style addressing).
S3_ENDPOINT=
# NSFW moderation is enabled by default. Set false (or 0/off) to disable it.
NSFW_CHECK_ENABLED=true
# Seconds to retain an upload after cleanup first detects that it is orphaned.
# Must be positive. Defaults to seven days when absent or blank.
IMAGE_UPLOAD_ORPHAN_RETENTION_SECONDS=604800
-7
View File
@@ -1,7 +0,0 @@
# Local development only. These credentials match compose.rustfs.yaml defaults.
S3_BUCKET=reminder-images
S3_REGION=us-east-1
S3_ACCESS_KEY_ID=REMINDERDEV
S3_SECRET_ACCESS_KEY=reminder-rustfs-local-secret
S3_ENDPOINT=http://127.0.0.1:9000
S3_PUBLIC_URL=http://127.0.0.1:9000/reminder-images
@@ -1,13 +0,0 @@
SET foreign_key_checks = 0;
-- Drop all old tables
DROP TABLE IF EXISTS users_old;
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS embeds;
DROP TABLE IF EXISTS embed_fields;
DROP TABLE IF EXISTS command_aliases;
DROP TABLE IF EXISTS macro;
DROP TABLE IF EXISTS roles;
DROP TABLE IF EXISTS command_restrictions;
SET foreign_key_checks = 1;
@@ -1,10 +0,0 @@
CREATE TABLE image_uploads (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
-- Discord user ID from the authenticated session, including guild-only users.
uploader BIGINT UNSIGNED NOT NULL,
url VARCHAR(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
orphaned_at DATETIME DEFAULT NULL,
INDEX image_uploads_uploader (uploader),
INDEX image_uploads_orphaned_at (orphaned_at)
);
+1 -2
View File
@@ -5,8 +5,7 @@
"scripts": { "scripts": {
"dev": "vite build --watch --mode development", "dev": "vite build --watch --mode development",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview"
"prettier": "prettier -w src/"
}, },
"dependencies": { "dependencies": {
"axios": "^1.5.1", "axios": "^1.5.1",
+2 -10
View File
@@ -6,10 +6,9 @@ enum ColorScheme {
Light = "light", Light = "light",
} }
type UserInfo = { export type UserInfo = {
name: string; name: string;
patreon: boolean; patreon: boolean;
image_uploads: boolean;
preferences: { preferences: {
timezone: string | null; timezone: string | null;
reset_inputs_on_create: boolean; reset_inputs_on_create: boolean;
@@ -117,7 +116,7 @@ type Template = {
embed_fields: EmbedField[] | null; embed_fields: EmbedField[] | null;
}; };
const USER_INFO_STALE_TIME = 86_400_000; const USER_INFO_STALE_TIME = 120_000;
const GUILD_INFO_STALE_TIME = 300_000; const GUILD_INFO_STALE_TIME = 300_000;
const OTHER_STALE_TIME = 120_000; const OTHER_STALE_TIME = 120_000;
@@ -127,13 +126,6 @@ export const fetchUserInfo = () => ({
staleTime: USER_INFO_STALE_TIME, staleTime: USER_INFO_STALE_TIME,
}); });
export const uploadImage = (file: File) =>
axios
.post<{ url: string }>("/dashboard/api/images", file, {
headers: { "Content-Type": "application/octet-stream" },
})
.then((response) => response.data.url);
export const patchUserInfo = () => ({ export const patchUserInfo = () => ({
mutationFn: (userInfo: UpdateUserInfo) => axios.patch(`/dashboard/api/user`, userInfo), mutationFn: (userInfo: UpdateUserInfo) => axios.patch(`/dashboard/api/user`, userInfo),
}); });
@@ -1,43 +0,0 @@
import { createContext } from "preact";
import { useContext } from "preact/compat";
import { useEffect, useState } from "preact/hooks";
import { fetchUserInfo } from "../../api";
import { useQuery } from "react-query";
type TColorScheme = "light" | "dark";
type TColorSchemeContext = {
colorScheme: TColorScheme;
};
const ColorSchemeContext = createContext({ colorScheme: "light" } as TColorSchemeContext);
export const ColorSchemeProvider = ({ children }) => {
const { data } = useQuery({ ...fetchUserInfo() });
const [activeScheme, setActiveScheme] = useState<TColorScheme>("light");
useEffect(() => {
const preference = data?.preferences?.dashboard_color_scheme || "system";
if (preference === "system") {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
setActiveScheme(mediaQuery.matches ? "dark" : "light");
};
handleChange();
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
} else {
setActiveScheme(preference as TColorScheme);
}
}, [data]);
return (
<ColorSchemeContext.Provider value={{ colorScheme: activeScheme }}>
{children}
</ColorSchemeContext.Provider>
);
};
export const useColorScheme = () => useContext(ColorSchemeContext);
+52 -59
View File
@@ -1,66 +1,59 @@
import {Sidebar} from "../Sidebar"; import { Sidebar } from "../Sidebar";
import {QueryClient, QueryClientProvider} from "react-query"; import { QueryClient, QueryClientProvider } from "react-query";
import {Route, Router, Switch} from "wouter"; import { Route, Router, Switch } from "wouter";
import {Welcome} from "../Welcome"; import { Welcome } from "../Welcome";
import {Guild} from "../Guild"; import { Guild } from "../Guild";
import {FlashProvider} from "./FlashProvider"; import { FlashProvider } from "./FlashProvider";
import {TimezoneProvider} from "./TimezoneProvider"; import { TimezoneProvider } from "./TimezoneProvider";
import {User} from "../User"; import { User } from "../User";
import {GuildReminders} from "../Guild/GuildReminders"; import { GuildReminders } from "../Guild/GuildReminders";
import {GuildTodos} from "../Guild/GuildTodos"; import { GuildTodos } from "../Guild/GuildTodos";
import {ColorSchemeProvider, useColorScheme} from "./ColorSchemeProvider";
import {useEffect} from "preact/hooks";
const InnerApp = () => {
const {colorScheme} = useColorScheme();
useEffect(() => {
const body = document.querySelector("body");
body.className = body.className.replace(/scheme-\w+/g, "");
body.classList.add(`scheme-${colorScheme}`);
}, [colorScheme]);
return (
<Router base={"/dashboard"}>
<div class={`columns is-gapless dashboard-frame scheme-${colorScheme}`}>
<Sidebar/>
<div class="column is-main-content">
<div style={{margin: "0 12px 12px 12px"}}>
<Switch>
<Route path={"/@me/reminders"} component={User}></Route>
<Route path={"/:guild/reminders"}>
<Guild>
<GuildReminders/>
</Guild>
</Route>
<Route path={"/:guild/todos"}>
<Guild>
<GuildTodos/>
</Guild>
</Route>
<Route>
<Welcome/>
</Route>
</Switch>
</div>
</div>
</div>
</Router>
);
};
const queryClient = new QueryClient();
export function App() { export function App() {
const queryClient = new QueryClient();
let scheme = "light";
// if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
// scheme = "dark";
// }
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ColorSchemeProvider> <TimezoneProvider>
<TimezoneProvider> <FlashProvider>
<FlashProvider> <Router base={"/dashboard"}>
<InnerApp/> <div class={`columns is-gapless dashboard-frame scheme-${scheme}`}>
</FlashProvider> <Sidebar />
</TimezoneProvider> <div class="column is-main-content">
</ColorSchemeProvider> <div style={{ margin: "0 12px 12px 12px" }}>
<Switch>
<Route path={"/@me/reminders"} component={User}></Route>
<Route
path={"/:guild/reminders"}
component={() => (
<Guild>
<GuildReminders />
</Guild>
)}
></Route>
<Route
path={"/:guild/todos"}
component={() => (
<Guild>
<GuildTodos />
</Guild>
)}
></Route>
<Route>
<Welcome />
</Route>
</Switch>
</div>
</div>
</div>
</Router>
</FlashProvider>
</TimezoneProvider>
</QueryClientProvider> </QueryClientProvider>
); );
} }
@@ -1,8 +1,8 @@
import { useQuery, useQueryClient } from "react-query"; import { useQuery, useQueryClient } from "react-query";
import { fetchGuildChannels, fetchGuildReminders } from "../../api"; import { fetchGuildChannels, fetchGuildInfo, fetchGuildReminders, fetchUserInfo } from "../../api";
import { EditReminder } from "../Reminder/EditReminder"; import { EditReminder } from "../Reminder/EditReminder";
import { CreateReminder } from "../Reminder/CreateReminder"; import { CreateReminder } from "../Reminder/CreateReminder";
import { useCallback, useState } from "preact/hooks"; import { useCallback, useMemo, useState } from "preact/hooks";
import { Loader } from "../Loader"; import { Loader } from "../Loader";
import { useGuild } from "../App/useGuild"; import { useGuild } from "../App/useGuild";
@@ -22,6 +22,11 @@ export const GuildReminders = () => {
data: guildReminders, data: guildReminders,
} = useQuery(fetchGuildReminders(guild)); } = useQuery(fetchGuildReminders(guild));
const { data: channels } = useQuery(fetchGuildChannels(guild)); const { data: channels } = useQuery(fetchGuildChannels(guild));
const channelNames = useMemo(() => {
return new Map(channels.map((ch) => [ch.id, ch.name]));
}, [channels]);
const { isSuccess: guildFetched, data: guildInfo } = useQuery({ ...fetchGuildInfo(guild) });
const { isSuccess: userFetched, data: userInfo } = useQuery({ ...fetchUserInfo() });
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [sort, _setSort] = useState(Sort.Time); const [sort, _setSort] = useState(Sort.Time);
@@ -34,9 +39,49 @@ export const GuildReminders = () => {
_setSort(sort); _setSort(sort);
}, []); }, []);
const sorted = useMemo(
() =>
[...guildReminders]
.sort((r1, r2) => {
if (sort === Sort.Time) {
return r1.utc_time > r2.utc_time ? 1 : -1;
} else if (sort === Sort.Name) {
return r1.name > r2.name ? 1 : -1;
} else {
return r1.channel > r2.channel ? 1 : -1;
}
})
.map((reminder) => {
let breaker = <></>;
if (sort === Sort.Channel && channels) {
if (prevReminder === null || prevReminder.channel !== reminder.channel) {
breaker = (
<div class={"channel-tag"}>#{channelNames[reminder.channel]}</div>
);
}
}
prevReminder = reminder;
return (
<>
{breaker}
<EditReminder
key={reminder.uid}
reminder={reminder}
guildInfo={guildInfo}
userInfo={userInfo}
globalCollapse={collapsed}
/>
</>
);
}),
[guildReminders, sort, channelNames, collapsed],
);
return ( return (
<> <>
{!isFetched && <Loader />} {!(userFetched && guildFetched && isFetched) && <Loader />}
<strong>Create Reminder</strong> <strong>Create Reminder</strong>
<div id={"reminderCreator"}> <div id={"reminderCreator"}>
@@ -100,44 +145,7 @@ export const GuildReminders = () => {
</div> </div>
<div id={"guildReminders"} className={isFetching ? "loading" : ""}> <div id={"guildReminders"} className={isFetching ? "loading" : ""}>
{isSuccess && {isSuccess && sorted}
guildReminders
.sort((r1, r2) => {
if (sort === Sort.Time) {
return r1.utc_time > r2.utc_time ? 1 : -1;
} else if (sort === Sort.Name) {
return r1.name > r2.name ? 1 : -1;
} else {
return r1.channel > r2.channel ? 1 : -1;
}
})
.map((reminder) => {
let breaker = <></>;
if (sort === Sort.Channel && channels) {
if (
prevReminder === null ||
prevReminder.channel !== reminder.channel
) {
const channel = channels.find(
(ch) => ch.id === reminder.channel,
);
breaker = <div class={"channel-tag"}>#{channel.name}</div>;
}
}
prevReminder = reminder;
return (
<>
{breaker}
<EditReminder
key={reminder.uid}
reminder={reminder}
globalCollapse={collapsed}
/>
</>
);
})}
</div> </div>
</> </>
); );
@@ -1,23 +1,15 @@
import { JSX } from "preact"; import {JSX} from "preact";
import { createPortal } from "preact/compat"; import {createPortal} from "preact/compat";
type Props = { type Props = {
setModalOpen: (open: boolean) => void; setModalOpen: (open: boolean) => never;
title: string | JSX.Element; title: string | JSX.Element;
onSubmitText?: string; onSubmitText?: string;
onSubmit?: () => void; onSubmit?: () => void;
onSubmitDisabled?: boolean;
children: string | JSX.Element | JSX.Element[] | (() => JSX.Element); children: string | JSX.Element | JSX.Element[] | (() => JSX.Element);
}; };
export const Modal = ({ export const Modal = ({setModalOpen, title, onSubmit, onSubmitText, children}: Props) => {
setModalOpen,
title,
onSubmit,
onSubmitText,
onSubmitDisabled,
children,
}: Props) => {
const body = document.querySelector("body"); const body = document.querySelector("body");
return createPortal( return createPortal(
@@ -42,11 +34,7 @@ export const Modal = ({
<section class="modal-card-body">{children}</section> <section class="modal-card-body">{children}</section>
{onSubmit && ( {onSubmit && (
<footer class="modal-card-foot"> <footer class="modal-card-foot">
<button <button class="button is-success" onClick={onSubmit}>
class="button is-success"
onClick={onSubmit}
disabled={onSubmitDisabled}
>
{onSubmitText || "Save"} {onSubmitText || "Save"}
</button> </button>
<button <button
@@ -1,5 +1,5 @@
import { useRef, useState } from "preact/hooks"; import { useRef, useState } from "preact/hooks";
import { useMutation, useQueryClient } from "react-query"; import { useMutation } from "react-query";
import { patchGuildReminder, patchUserReminder } from "../../../api"; import { patchGuildReminder, patchUserReminder } from "../../../api";
import { ICON_FLASH_TIME } from "../../../consts"; import { ICON_FLASH_TIME } from "../../../consts";
import { DeleteButton } from "./DeleteButton"; import { DeleteButton } from "./DeleteButton";
@@ -1,4 +1,4 @@
import { Reminder } from "../../api"; import { Reminder, UserInfo, GuildInfo } from "../../api";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { EditButtonRow } from "./ButtonRow/EditButtonRow"; import { EditButtonRow } from "./ButtonRow/EditButtonRow";
import { Message } from "./Message"; import { Message } from "./Message";
@@ -10,9 +10,16 @@ import "./styles.scss";
type Props = { type Props = {
reminder: Reminder; reminder: Reminder;
globalCollapse: boolean; globalCollapse: boolean;
userInfo: UserInfo;
guildInfo: GuildInfo;
}; };
export const EditReminder = ({ reminder: initialReminder, globalCollapse }: Props) => { export const EditReminder = ({
reminder: initialReminder,
globalCollapse,
userInfo,
guildInfo,
}: Props) => {
const [propReminder, setPropReminder] = useState(initialReminder); const [propReminder, setPropReminder] = useState(initialReminder);
const [reminder, setReminder] = useState(initialReminder); const [reminder, setReminder] = useState(initialReminder);
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
@@ -39,11 +46,15 @@ export const EditReminder = ({ reminder: initialReminder, globalCollapse }: Prop
setCollapsed(!collapsed); setCollapsed(!collapsed);
}} }}
/> />
<div class="columns reminder-settings"> {!collapsed && (
<Message /> <>
<Settings /> <div class="columns reminder-settings">
</div> <Message />
<EditButtonRow /> <Settings userInfo={userInfo} guildInfo={guildInfo} />
</div>
<EditButtonRow />
</>
)}
</div> </div>
</ReminderContext.Provider> </ReminderContext.Provider>
); );
@@ -1,65 +1,13 @@
import { Modal } from "../Modal"; import { Modal } from "../Modal";
import { useEffect, useRef, useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { useQuery } from "react-query";
import axios from "axios";
import { fetchUserInfo, uploadImage } from "../../api";
const useImageDrop = (enabled: boolean, onFiles: (files: File[]) => void, busy = false) => {
const [dragging, setDragging] = useState(false);
const dragDepth = useRef(0);
const acceptsDrag = (event: DragEvent) =>
enabled && event.dataTransfer?.types.includes("Files");
return {
dragging,
handlers: {
onDragEnter: (event: DragEvent) => {
if (!acceptsDrag(event)) return;
event.preventDefault();
event.stopPropagation();
dragDepth.current += 1;
if (!busy) setDragging(true);
},
onDragOver: (event: DragEvent) => {
if (!acceptsDrag(event)) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = busy ? "none" : "copy";
},
onDragLeave: () => {
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDragging(false);
},
onDrop: (event: DragEvent) => {
if (!acceptsDrag(event)) return;
event.preventDefault();
event.stopPropagation();
dragDepth.current = 0;
setDragging(false);
if (!busy) onFiles(Array.from(event.dataTransfer.files));
},
},
};
};
export const ImagePicker = ({ alt, url, setImage, ...props }) => { export const ImagePicker = ({ alt, url, setImage, ...props }) => {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [droppedFiles, setDroppedFiles] = useState<File[] | null>(null);
const { data: userInfo } = useQuery(fetchUserInfo());
const uploadsEnabled = userInfo?.image_uploads === true;
const { dragging, handlers } = useImageDrop(uploadsEnabled, (files) => {
setDroppedFiles(files);
setModalOpen(true);
});
return ( return (
<> <>
<a <a
{...handlers}
class={dragging ? "image-picker-dragging" : ""}
title={uploadsEnabled ? "Click to edit or drop an image to upload" : undefined}
onClick={() => { onClick={() => {
setDroppedFiles(null);
setModalOpen(true); setModalOpen(true);
}} }}
role={"button"} role={"button"}
@@ -70,64 +18,14 @@ export const ImagePicker = ({ alt, url, setImage, ...props }) => {
<ImagePickerModal <ImagePickerModal
setModalOpen={setModalOpen} setModalOpen={setModalOpen}
setImage={setImage} setImage={setImage}
url={url}
uploadsEnabled={uploadsEnabled}
droppedFiles={droppedFiles}
></ImagePickerModal> ></ImagePickerModal>
)} )}
</> </>
); );
}; };
const ImagePickerModal = ({ setModalOpen, setImage, url, uploadsEnabled, droppedFiles }) => { const ImagePickerModal = ({ setModalOpen, setImage }) => {
const [value, setValue] = useState(url || ""); const [value, setValue] = useState("");
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
const [blocked, setBlocked] = useState(false);
const fileInput = useRef<HTMLInputElement>(null);
const uploadInProgress = useRef(false);
const upload = async (files: File[]) => {
setBlocked(false);
if (!uploadsEnabled || uploadInProgress.current) return;
setError("");
if (files.length !== 1) {
setError("Please upload only one image.");
return;
}
const file = files[0];
if (file.size > 8 * 1024 * 1024) {
setError("Images must be no larger than 8 MiB.");
return;
}
uploadInProgress.current = true;
setUploading(true);
try {
setValue(await uploadImage(file));
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 451) {
setBlocked(true);
} else {
setError(
axios.isAxiosError(error) && typeof error.response?.data?.error === "string"
? error.response.data.error
: "Could not upload image. Please try again.",
);
}
} finally {
uploadInProgress.current = false;
setUploading(false);
}
};
const { dragging, handlers } = useImageDrop(
uploadsEnabled,
(files) => void upload(files),
uploading,
);
useEffect(() => {
if (droppedFiles) void upload(droppedFiles);
}, [droppedFiles]);
return ( return (
<Modal <Modal
@@ -138,7 +36,6 @@ const ImagePickerModal = ({ setModalOpen, setImage, url, uploadsEnabled, dropped
setModalOpen(false); setModalOpen(false);
}} }}
onSubmitText={"Save"} onSubmitText={"Save"}
onSubmitDisabled={uploading}
> >
<p> <p>
Please note: if you attach an image directly from Discord, it will not be visible in Please note: if you attach an image directly from Discord, it will not be visible in
@@ -149,65 +46,10 @@ const ImagePickerModal = ({ setModalOpen, setImage, url, uploadsEnabled, dropped
class="input" class="input"
id="urlInput" id="urlInput"
placeholder="Image URL..." placeholder="Image URL..."
value={value}
disabled={uploading}
onInput={(ev) => { onInput={(ev) => {
setValue(ev.currentTarget.value); setValue(ev.currentTarget.value);
}} }}
></input> ></input>
{uploadsEnabled && (
<div
{...handlers}
class={`mt-4 image-upload-dropzone ${dragging ? "image-picker-dragging" : ""}`}
>
<p>or…</p>
<input
ref={fileInput}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
hidden
disabled={uploading}
aria-label="Choose an image to upload"
onChange={(event) => {
const file = event.currentTarget.files?.[0];
event.currentTarget.value = "";
if (file) void upload([file]);
}}
/>
<button
type="button"
class={`button is-info ${uploading ? "is-loading" : ""}`}
disabled={uploading}
onClick={() => fileInput.current?.click()}
>
Upload image
</button>
<p class="help">Or drag and drop an image here.</p>
<p class="help">
Static PNG, JPEG, GIF or WebP, up to 8 MiB. All uploads are converted to
PNGs.
</p>
{blocked && (
<>
<p class="help" style="color: red;">
This image was flagged as potentially NSFW. Unfortunately, due to
the UK's Online Safety Act, we cannot handle explicit content.
Please defer to using Imgur or another image storage platform who
have the resources to deal with idiotic laws.
</p>
<p class="help" style="color: red;">
The image you attempted to upload has not been stored.
</p>
</>
)}
{uploading && <p role="status">Uploading image…</p>}
</div>
)}
{error && (
<p class="help is-danger" role="alert">
{error}
</p>
)}
</Modal> </Modal>
); );
}; };
@@ -1,24 +1,21 @@
import { ChannelSelector } from "./ChannelSelector"; import { ChannelSelector } from "./ChannelSelector";
import { IntervalSelector } from "./IntervalSelector"; import { IntervalSelector } from "./IntervalSelector";
import { useQuery } from "react-query";
import { fetchGuildInfo, fetchUserInfo } from "../../api";
import { useReminder } from "./ReminderContext"; import { useReminder } from "./ReminderContext";
import { Attachment } from "./Attachment"; import { Attachment } from "./Attachment";
import { TTS } from "./TTS"; import { TTS } from "./TTS";
import { TimeInput } from "./TimeInput"; import { TimeInput } from "./TimeInput";
import { useGuild } from "../App/useGuild"; import { useGuild } from "../App/useGuild";
import { GuildInfo, UserInfo } from "../../api";
export const Settings = () => { type Props = {
userInfo: UserInfo;
guildInfo: GuildInfo;
};
export const Settings = ({ guildInfo, userInfo }: Props) => {
const guild = useGuild(); const guild = useGuild();
const { isSuccess: userFetched, data: userInfo } = useQuery({ ...fetchUserInfo() });
const { isSuccess: guildFetched, data: guildInfo } = useQuery({ ...fetchGuildInfo(guild) });
const [reminder, setReminder] = useReminder(); const [reminder, setReminder] = useReminder();
if (!userFetched || !guildFetched) {
return <></>;
}
return ( return (
<div class="column settings"> <div class="column settings">
{guild && ( {guild && (
@@ -11,7 +11,7 @@
border-radius: 8px; border-radius: 8px;
margin: 4px; margin: 4px;
padding: 4px; padding: 4px;
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.75); box-shadow: 0 0 5px 0 rgba(0,0,0,0.75);
.highlight { .highlight {
background-color: #35373c; background-color: #35373c;
@@ -34,24 +34,3 @@ div.reminderContent {
background-color: #f5f5f5; background-color: #f5f5f5;
border-radius: 8px; border-radius: 8px;
} }
.image-upload-dropzone {
border: 2px dashed #b5b5b5;
border-radius: 4px;
padding: 1rem;
text-align: center;
}
.image-picker-dragging {
outline: 3px solid #3e8ed0;
outline-offset: 2px;
img {
opacity: 0.6;
}
&.image-upload-dropzone {
background-color: #eff5fb;
border-color: #3e8ed0;
}
}
@@ -66,7 +66,7 @@ aside.menu {
padding-left: 4px; padding-left: 4px;
} }
@media screen and (max-width: 1023px), print { @media screen and (max-width: 1023px),print {
.columns:not(.is-desktop) { .columns:not(.is-desktop) {
flex-direction: column; flex-direction: column;
} }
@@ -5,12 +5,6 @@ import { useRef, useState } from "preact/hooks";
import { ICON_FLASH_TIME } from "../../consts"; import { ICON_FLASH_TIME } from "../../consts";
import { useFlash } from "../App/FlashContext"; import { useFlash } from "../App/FlashContext";
enum ColorScheme {
System = "system",
Dark = "dark",
Light = "light",
}
export const UserPreferences = () => { export const UserPreferences = () => {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
@@ -102,40 +96,6 @@ const PreferencesModal = ({ setModalOpen }) => {
</label> </label>
</div> </div>
<br></br> <br></br>
<div style={{ display: "flex", flexDirection: "row", alignContent: "center" }}>
<label>
<div class={"is-inline-block"} style={{ marginRight: "6px" }}>
Dashboard Color Scheme:
</div>
<div class={"control"}>
<div class={"is-inline-block select"}>
{isLoading && <i class={"fa fa-spinner"} />}
{isSuccess && (
<select
class={"channel-selector"}
value={
updatedSettings.dashboard_color_scheme === undefined
? data.preferences.dashboard_color_scheme
: updatedSettings.dashboard_color_scheme
}
onChange={(e) =>
setUpdatedSettings((s) => ({
...s,
dashboard_color_scheme: (e.target as HTMLSelectElement)
.value as ColorScheme,
}))
}
>
<option value={ColorScheme.System}>System default</option>
<option value={ColorScheme.Light}>Light</option>
<option value={ColorScheme.Dark}>Dark</option>
</select>
)}
</div>
</div>
</label>
</div>
<br></br>
<div class="has-text-centered"> <div class="has-text-centered">
<button <button
class="button is-success is-outlined" class="button is-success is-outlined"
@@ -14,9 +14,6 @@ export const Welcome = () => (
<p> <p>
<strong>Please report bugs!</strong> I can't fix issues if I am unaware of them. <strong>Please report bugs!</strong> I can't fix issues if I am unaware of them.
</p> </p>
<p>
Client version: {import.meta.env.VITE_VERSION}
</p>
</div> </div>
</section> </section>
); );
+18 -240
View File
@@ -2,280 +2,58 @@
/* override styles for when the div is collapsed */ /* override styles for when the div is collapsed */
div.reminderContent.is-collapsed .column.discord-frame { div.reminderContent.is-collapsed .column.discord-frame {
display: none; display: none;
} }
div.reminderContent.is-collapsed .column.settings { div.reminderContent.is-collapsed .column.settings {
display: none; display: none;
} }
div.reminderContent.is-collapsed .reminder-settings { div.reminderContent.is-collapsed .reminder-settings {
margin-bottom: 0; margin-bottom: 0;
} }
div.reminderContent.is-collapsed .button-row { div.reminderContent.is-collapsed .button-row {
display: none; display: none;
} }
div.reminderContent.is-collapsed .button-row-edit { div.reminderContent.is-collapsed .button-row-edit {
display: none; display: none;
} }
div.reminderContent.is-collapsed .reminder-topbar { div.reminderContent.is-collapsed .reminder-topbar {
padding-bottom: 0; padding-bottom: 0;
} }
div.reminderContent.is-collapsed .invert-collapses { div.reminderContent.is-collapsed .invert-collapses {
display: inline-flex; display: inline-flex;
} }
div.reminderContent .invert-collapses { div.reminderContent .invert-collapses {
display: none; display: none;
} }
div.reminderContent.is-collapsed input[name="name"] { div.reminderContent.is-collapsed input[name="name"] {
display: inline-flex; display: inline-flex;
flex-grow: 1; flex-grow: 1;
border: none; border: none;
background: none; background: none;
box-shadow: none; box-shadow: none;
opacity: 1; opacity: 1;
} }
div.reminderContent.is-collapsed .hide-box { div.reminderContent.is-collapsed .hide-box {
display: inline-flex; display: inline-flex;
} }
div.reminderContent.is-collapsed .hide-box i { div.reminderContent.is-collapsed .hide-box i {
transform: rotate(90deg); transform: rotate(90deg);
} }
.button.is-success:not(.is-outlined) { .button.is-success:not(.is-outlined) {
color: white; color: white;
} }
.button.is-outlined.is-success:not(.is-focused, :hover, :focus) { .button.is-outlined.is-success:not(.is-focused, :hover, :focus) {
background-color: white; background-color: white;
}
@import "vars";
.scheme-light {
background-color: $primary-background-light;
color: $primary-text-light;
.column.is-main-content {
background-color: $primary-background-light;
}
.reminderContent {
background-color: $secondary-background-light;
}
}
.scheme-dark {
background-color: $secondary-background-dark;
color: $primary-text-dark;
.column.is-main-content {
background-color: $secondary-background-dark;
}
.reminderContent {
background-color: $primary-background-dark;
color: $primary-text-dark;
}
.title,
.subtitle,
.label,
.help,
strong {
color: $primary-text-dark;
}
input,
textarea,
select,
.input,
.textarea {
background-color: $secondary-background-dark;
color: $primary-text-dark;
border-color: $contrast-background-dark;
&::placeholder {
color: #aaa;
}
}
// Buttons
.button.is-outlined.is-success:not(.is-focused, :hover, :focus) {
background-color: $primary-background-dark;
color: #48c78e;
border-color: #48c78e;
}
.button.is-outlined.is-danger:not(.is-focused, :hover, :focus) {
background-color: $primary-background-dark;
}
.button.is-outlined.is-warning:not(.is-focused, :hover, :focus) {
background-color: $primary-background-dark;
}
.button:not(.is-success, .is-danger, .is-warning, .is-light) {
background-color: $secondary-background-dark;
color: $primary-text-dark;
border-color: $contrast-background-dark;
}
.button.is-light {
background-color: $secondary-background-dark;
color: $primary-text-dark;
}
// Modal
.modal-card-head,
.modal-card-foot {
background-color: $contrast-background-dark;
border-color: $contrast-background-dark;
}
.modal-card-head .modal-card-title {
color: $primary-text-dark;
}
.modal-card-body {
background-color: $primary-background-dark;
color: $primary-text-dark;
}
// Navbar
.navbar {
background-color: $contrast-background-dark;
}
.navbar-item,
.navbar-burger {
color: $primary-text-dark;
}
// Select dropdown arrow
.select:not(.is-multiple):not(.is-loading)::after {
border-color: $primary-text-dark;
}
.select select {
background-color: $secondary-background-dark;
color: $primary-text-dark;
border-color: $contrast-background-dark;
}
// Interval selector inputs
.interval-group input {
background-color: $secondary-background-dark !important;
color: $primary-text-dark;
}
// Notification
.notification {
background-color: $secondary-background-dark;
color: $primary-text-dark;
}
// Links
a:not(.menu a):not(.button) {
color: #7eaef1;
}
// Content area
.content {
color: $primary-text-dark;
}
// Checkbox
.checkbox {
color: $primary-text-dark;
}
// Message input
.message-input {
background-color: $secondary-background-dark;
color: $primary-text-dark;
}
// Box
.box {
background-color: $secondary-background-dark;
color: $primary-text-dark;
}
// Panel
.panel {
background-color: $secondary-background-dark;
.panel-heading {
background-color: $contrast-background-dark;
color: $primary-text-dark;
}
.panel-block {
color: $primary-text-dark;
border-color: $contrast-background-dark;
}
}
// Horizontal rule
hr {
background-color: $contrast-background-dark;
}
// Tag
.tag:not(.is-success, .is-danger, .is-warning, .is-info) {
background-color: $contrast-background-dark;
color: $primary-text-dark;
}
// Todo
.todo {
color: $primary-text-dark;
}
// Page links
.page-links .button {
background-color: $secondary-background-dark;
color: $primary-text-dark;
border-color: $contrast-background-dark;
}
// Loading overlay
.load-screen {
background-color: rgba(36, 36, 36, 0.8);
color: $primary-text-dark;
}
// File/attachment input
.file-cta {
background-color: $secondary-background-dark;
color: $primary-text-dark;
border-color: $contrast-background-dark;
}
.file-name {
background-color: $secondary-background-dark;
color: $primary-text-dark;
border-color: $contrast-background-dark;
}
// Date/time inputs color scheme
input[type="date"],
input[type="time"],
input[type="datetime-local"] {
color-scheme: dark;
}
// Modal background overlay
.modal-background {
background-color: rgba(10, 10, 10, 0.86);
}
} }
-29
View File
@@ -1,29 +0,0 @@
#!/bin/sh
set -eu
endpoint=http://rustfs:9000
if ! aws --endpoint-url "$endpoint" s3api head-bucket --bucket "$S3_BUCKET" >/dev/null 2>&1; then
if [ "$AWS_DEFAULT_REGION" = us-east-1 ]; then
aws --endpoint-url "$endpoint" s3api create-bucket --bucket "$S3_BUCKET"
else
aws --endpoint-url "$endpoint" s3api create-bucket --bucket "$S3_BUCKET" \
--create-bucket-configuration "LocationConstraint=$AWS_DEFAULT_REGION"
fi
fi
# Dashboard images need public object reads. Listing and writes remain authenticated.
cat > /tmp/public-images-policy.json <<EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::$S3_BUCKET/uploads/*"
}]
}
EOF
aws --endpoint-url "$endpoint" s3api put-bucket-policy --bucket "$S3_BUCKET" \
--policy file:///tmp/public-images-policy.json
echo "RustFS development bucket ready: $S3_BUCKET"
-63
View File
@@ -1,63 +0,0 @@
#!/usr/bin/env python3
"""Split an image into two lossless, random-looking PNG fixture shares."""
from __future__ import annotations
import argparse
from io import BytesIO
from pathlib import Path
import secrets
from PIL import Image
def split_image(input_path: Path, output_dir: Path, index: int | None = None) -> tuple[Path, Path]:
if index is None:
index = 1
while any((output_dir / f"image_{index}{part}.png").exists() for part in ("a", "b")):
index += 1
if index < 1:
raise ValueError("Image index must be a positive integer")
paths = (
output_dir / f"image_{index}a.png",
output_dir / f"image_{index}b.png",
)
for path in paths:
if path.exists():
raise FileExistsError(f"Refusing to overwrite {path}; choose another --index")
# RGBA shares preserve every decoded pixel, including transparency. For animated
# inputs, use the first frame, matching the dashboard's static image upload behavior.
with Image.open(input_path) as source:
image = source.convert("RGBA")
pixels = image.tobytes()
first = secrets.token_bytes(len(pixels))
second = bytes(pixel ^ mask for pixel, mask in zip(pixels, first))
output_dir.mkdir(parents=True, exist_ok=True)
for path, share in zip(paths, (first, second)):
# Exclusive creation also prevents overwriting a file created during splitting.
encoded = BytesIO()
Image.frombytes("RGBA", image.size, share).save(encoded, format="PNG")
with path.open("xb") as output:
output.write(encoded.getvalue())
return paths
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input_image", type=Path)
parser.add_argument("--output-dir", type=Path, default=Path("."))
parser.add_argument("--index", type=int, help="Fixture number (default: first unused positive index)")
args = parser.parse_args()
try:
paths = split_image(args.input_image, args.output_dir, args.index)
except (OSError, ValueError) as error:
parser.exit(1, f"Could not split image: {error}\n")
for path in paths:
print(path)
if __name__ == "__main__":
main()
+3 -2
View File
@@ -56,7 +56,7 @@ impl Recordable for Options {
}), }),
}; };
let channel_id = if let Some(channel) = ctx.cache().channel(ctx.channel_id()) { let channel_id = if let Some(channel) = ctx.channel_id().to_channel_cached(&ctx.cache()) {
if Some(channel.guild_id) == ctx.guild_id() { if Some(channel.guild_id) == ctx.guild_id() {
flags.channel_id.unwrap_or_else(|| ctx.channel_id()) flags.channel_id.unwrap_or_else(|| ctx.channel_id())
} else { } else {
@@ -66,7 +66,8 @@ impl Recordable for Options {
ctx.channel_id() ctx.channel_id()
}; };
let channel_name = ctx.cache().channel(channel_id).map(|channel| channel.name.clone()); let channel_name =
channel_id.to_channel_cached(&ctx.cache()).map(|channel| channel.name.clone());
let reminders = Reminder::from_channel(&ctx.data().database, channel_id, &flags).await; let reminders = Reminder::from_channel(&ctx.data().database, channel_id, &flags).await;
+2 -2
View File
@@ -63,7 +63,7 @@ impl ComponentDataModel {
let flags = pager.flags; let flags = pager.flags;
let channel_id = { let channel_id = {
let channel_opt = ctx.cache.channel(component.channel_id); let channel_opt = component.channel_id.to_channel_cached(&ctx.cache);
if let Some(channel) = channel_opt { if let Some(channel) = channel_opt {
if Some(channel.guild_id) == component.guild_id { if Some(channel.guild_id) == component.guild_id {
@@ -85,7 +85,7 @@ impl ComponentDataModel {
.div_ceil(EMBED_DESCRIPTION_MAX_LENGTH); .div_ceil(EMBED_DESCRIPTION_MAX_LENGTH);
let channel_name = let channel_name =
ctx.cache.channel(channel_id).map(|channel| channel.name.clone()); channel_id.to_channel_cached(&ctx.cache).map(|channel| channel.name.clone());
let next_page = pager.next_page(pages); let next_page = pager.next_page(pages);
-165
View File
@@ -1,165 +0,0 @@
use std::env;
use log::{info, warn};
use sqlx::{Acquire, MySql, Pool};
use crate::{web::image_uploads::ImageUploadService, Error};
const DEFAULT_RETENTION_SECONDS: u32 = 7 * 24 * 60 * 60;
const BATCH_SIZE: u32 = 1000;
const IMAGE_COLUMNS: &str =
"avatar, embed_image_url, embed_thumbnail_url, embed_author_url, embed_footer_url";
fn binary_image_columns() -> String {
// Existing URL columns and image_uploads use different collations. S3 URLs must
// also match case-sensitively, so compare their bytes rather than text collation.
IMAGE_COLUMNS
.split(", ")
.map(|column| format!("BINARY {column}"))
.collect::<Vec<_>>()
.join(", ")
}
fn retention_seconds(value: Option<&str>) -> Result<u32, Error> {
match value.map(str::trim).filter(|value| !value.is_empty()) {
None => Ok(DEFAULT_RETENTION_SECONDS),
Some(value) => value.parse::<u32>().ok().filter(|value| *value > 0).ok_or_else(|| {
"IMAGE_UPLOAD_ORPHAN_RETENTION_SECONDS must be a positive integer of seconds".into()
}),
}
}
// Only keys generated by the upload route in the configured bucket may be deleted.
fn object_key<'a>(public_url: &str, url: &'a str) -> Option<&'a str> {
let key = url.strip_prefix(public_url)?.strip_prefix('/')?;
let name = key.strip_prefix("uploads/")?.strip_suffix(".png")?;
(name.len() == 64
&& name.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)))
.then_some(key)
}
async fn mark_orphans(pool: &Pool<MySql>) -> Result<(), Error> {
let columns = binary_image_columns();
sqlx::query(&format!(
"UPDATE image_uploads AS upload SET orphaned_at = CASE
WHEN NOT EXISTS (SELECT 1 FROM reminders
WHERE BINARY upload.url IN ({columns}))
AND NOT EXISTS (SELECT 1 FROM reminder_template
WHERE BINARY upload.url IN ({columns}))
THEN COALESCE(upload.orphaned_at, UTC_TIMESTAMP(6)) ELSE NULL END"
))
.execute(pool)
.await?;
Ok(())
}
pub(crate) async fn run(pool: &Pool<MySql>) -> Result<(), Error> {
let retention =
retention_seconds(env::var("IMAGE_UPLOAD_ORPHAN_RETENTION_SECONDS").ok().as_deref())?;
let Some(storage) = ImageUploadService::for_cleanup()? else {
info!("Image cleanup skipped: S3 uploads are not configured");
return Ok(());
};
cleanup(pool, &storage, retention).await
}
async fn cleanup(
pool: &Pool<MySql>,
storage: &ImageUploadService,
retention: u32,
) -> Result<(), Error> {
mark_orphans(pool).await?;
let candidates: Vec<u64> = sqlx::query_scalar(
"SELECT id FROM image_uploads
WHERE orphaned_at <= UTC_TIMESTAMP(6) - INTERVAL ? SECOND
ORDER BY orphaned_at, id LIMIT ?",
)
.bind(retention)
.bind(BATCH_SIZE)
.fetch_all(pool)
.await?;
let mut deleted = 0;
let mut failed = 0;
for id in candidates {
match delete_orphan(pool, storage, retention, id).await {
Ok(true) => deleted += 1,
Ok(false) => {}
Err(error) => {
failed += 1;
warn!("Could not clean up image upload {}: {}", id, error);
}
}
}
info!("Image cleanup deleted {} uploads; {} failed", deleted, failed);
if failed > 0 {
return Err(format!("Image cleanup failed for {failed} uploads; retained for retry").into());
}
Ok(())
}
async fn delete_orphan(
pool: &Pool<MySql>,
storage: &ImageUploadService,
retention: u32,
id: u64,
) -> Result<bool, Error> {
let mut connection = pool.acquire().await?;
sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")
.execute(&mut *connection)
.await?;
let mut transaction = connection.begin().await?;
// Serialize overlapping cleanup jobs and recheck the retention time under the row lock.
let url: Option<String> = sqlx::query_scalar(
"SELECT url FROM image_uploads
WHERE id = ? AND orphaned_at <= UTC_TIMESTAMP(6) - INTERVAL ? SECOND
FOR UPDATE",
)
.bind(id)
.bind(retention)
.fetch_optional(&mut *transaction)
.await?;
let Some(url) = url else {
transaction.rollback().await?;
return Ok(false);
};
let key = object_key(&storage.public_url, &url)
.ok_or("Upload URL does not match a generated key in the configured S3 public URL")?;
for table in ["reminders", "reminder_template"] {
// Locking reads see current committed references, rather than a stale snapshot.
// InnoDB's REPEATABLE READ next-key locks also prevent a reference from
// being inserted while the object is being removed. Keep this transaction short:
// it covers one S3 request with the upload service's 30-second timeout.
let columns = binary_image_columns();
let referenced: Option<u32> = sqlx::query_scalar(&format!(
"SELECT id FROM {table} WHERE BINARY ? IN ({columns}) LIMIT 1 FOR UPDATE"
))
.bind(&url)
.fetch_optional(&mut *transaction)
.await?;
if referenced.is_some() {
sqlx::query("UPDATE image_uploads SET orphaned_at = NULL WHERE id = ?")
.bind(id)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
return Ok(false);
}
}
let response = storage.bucket.delete_object(key).await?;
// A missing object is already cleaned up, including a retry after a DB commit failure.
if !(200..300).contains(&response.status_code()) && response.status_code() != 404 {
return Err(format!("S3 deletion returned HTTP {}", response.status_code()).into());
}
sqlx::query("DELETE FROM image_uploads WHERE id = ?")
.bind(id)
.execute(&mut *transaction)
.await?;
transaction.commit().await?;
Ok(true)
}
#[cfg(test)]
mod tests;
-258
View File
@@ -1,258 +0,0 @@
use std::{sync::Arc, time::Duration};
use chrono::NaiveDateTime;
use s3::{creds::Credentials, Bucket, Region};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
sync::Semaphore,
task::JoinHandle,
};
use super::*;
const BASE_URL: &str = "https://images.example.com/bucket";
fn upload_url(number: u64) -> String {
format!("{BASE_URL}/uploads/{number:064x}.png")
}
async fn upload(pool: &Pool<MySql>, number: u64) -> u64 {
sqlx::query("INSERT INTO image_uploads (uploader, url) VALUES (123, ?)")
.bind(upload_url(number))
.execute(pool)
.await
.unwrap()
.last_insert_id()
}
async fn orphaned_at(pool: &Pool<MySql>, id: u64) -> Option<NaiveDateTime> {
sqlx::query_scalar("SELECT orphaned_at FROM image_uploads WHERE id = ?")
.bind(id)
.fetch_one(pool)
.await
.unwrap()
}
async fn expire(pool: &Pool<MySql>, id: u64) {
sqlx::query(
"UPDATE image_uploads SET orphaned_at = UTC_TIMESTAMP(6) - INTERVAL 2 DAY WHERE id = ?",
)
.bind(id)
.execute(pool)
.await
.unwrap();
}
async fn reference_tables(pool: &Pool<MySql>) -> (u64, u64) {
let guild = sqlx::query("INSERT INTO guilds (guild) VALUES (123)")
.execute(pool)
.await
.unwrap()
.last_insert_id();
let channel = sqlx::query("INSERT INTO channels (channel, guild_id) VALUES (123, ?)")
.bind(guild)
.execute(pool)
.await
.unwrap()
.last_insert_id();
(guild, channel)
}
async fn storage(status: u16) -> (ImageUploadService, JoinHandle<String>) {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
let task = tokio::spawn(async move {
let (mut socket, _) =
tokio::time::timeout(Duration::from_secs(5), listener.accept()).await.unwrap().unwrap();
let mut received = Vec::new();
let mut buffer = [0; 4096];
while !received.windows(4).any(|window| window == b"\r\n\r\n") {
let count = socket.read(&mut buffer).await.unwrap();
assert!(count > 0);
received.extend_from_slice(&buffer[..count]);
}
socket
.write_all(
format!("HTTP/1.1 {status} Test\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
.as_bytes(),
)
.await
.unwrap();
String::from_utf8(received).unwrap()
});
let credentials = Credentials::new(Some("test"), Some("test"), None, None, None).unwrap();
let mut bucket =
Bucket::new("images", Region::Custom { region: "local".into(), endpoint }, credentials)
.unwrap()
.with_path_style();
bucket.set_request_timeout(Some(Duration::from_secs(2)));
(
ImageUploadService {
bucket,
public_url: BASE_URL.into(),
model: None,
slots: Arc::new(Semaphore::new(2)),
},
task,
)
}
#[test]
fn retention_defaults_and_rejects_invalid_settings() {
for value in [None, Some(""), Some(" ")] {
assert_eq!(retention_seconds(value).unwrap(), 604800);
}
assert_eq!(retention_seconds(Some(" 3600 ")).unwrap(), 3600);
for value in ["0", "-1", "1.5", "forever", "4294967296"] {
assert!(retention_seconds(Some(value)).is_err());
}
}
#[test]
fn restricts_deletion_to_generated_keys_in_the_configured_url() {
assert_eq!(
object_key(BASE_URL, &upload_url(1)),
Some(format!("uploads/{:064x}.png", 1).as_str())
);
for url in [
upload_url(1).replace("images.example.com", "other.example.com"),
upload_url(1).replace("/bucket/", "/bucket-other/"),
upload_url(1).replace("/uploads/", "/private/"),
format!("{BASE_URL}/uploads/../private.png"),
format!("{BASE_URL}/uploads/{}.png", "g".repeat(64)),
format!("{}?download=true", upload_url(1)),
format!("{BASE_URL}/uploads/short.png"),
] {
assert!(object_key(BASE_URL, &url).is_none(), "{url}");
}
}
#[sqlx::test]
async fn orphan_timestamp_survives_repeated_runs_and_resets_on_reuse(pool: Pool<MySql>) {
let id = upload(&pool, 1).await;
assert!(orphaned_at(&pool, id).await.is_none());
mark_orphans(&pool).await.unwrap();
let first = orphaned_at(&pool, id).await.unwrap();
mark_orphans(&pool).await.unwrap();
assert_eq!(orphaned_at(&pool, id).await, Some(first));
let (guild, _) = reference_tables(&pool).await;
let template =
sqlx::query("INSERT INTO reminder_template (guild_id, embed_image_url) VALUES (?, ?)")
.bind(guild)
.bind(upload_url(1))
.execute(&pool)
.await
.unwrap()
.last_insert_id();
mark_orphans(&pool).await.unwrap();
assert!(orphaned_at(&pool, id).await.is_none());
sqlx::query("DELETE FROM reminder_template WHERE id = ?")
.bind(template)
.execute(&pool)
.await
.unwrap();
mark_orphans(&pool).await.unwrap();
assert!(orphaned_at(&pool, id).await.unwrap() > first);
}
#[sqlx::test]
async fn protects_all_image_fields_in_reminders_and_templates(pool: Pool<MySql>) {
let (guild, channel) = reference_tables(&pool).await;
let mut ids = Vec::new();
for (index, column) in IMAGE_COLUMNS.split(", ").enumerate() {
for table in ["reminders", "reminder_template"] {
let number = ids.len() as u64 + 1;
ids.push(upload(&pool, number).await);
let query = if table == "reminders" {
// Archived/disabled reminders still retain their image URLs.
format!(
"INSERT INTO reminders (uid, channel_id, `utc_time`, status, enabled, {column})
VALUES ('field-{index}', {channel}, UTC_TIMESTAMP(), 'sent', 0, ?)"
)
} else {
format!("INSERT INTO reminder_template (guild_id, {column}) VALUES ({guild}, ?)")
};
sqlx::query(&query).bind(upload_url(number)).execute(&pool).await.unwrap();
}
}
let unused = upload(&pool, 100).await;
// Previously orphaned files are unmarked as soon as cleanup observes a reference.
for id in &ids {
expire(&pool, *id).await;
}
mark_orphans(&pool).await.unwrap();
for id in ids {
assert!(orphaned_at(&pool, id).await.is_none());
}
assert!(orphaned_at(&pool, unused).await.is_some());
}
#[sqlx::test]
async fn only_expired_orphans_are_deleted_and_rows_removed_after_success(pool: Pool<MySql>) {
let old = upload(&pool, 1).await;
let fresh = upload(&pool, 2).await;
expire(&pool, old).await;
let (storage, request) = storage(204).await;
cleanup(&pool, &storage, 86400).await.unwrap();
assert!(request.await.unwrap().starts_with(&format!("DELETE /images/uploads/{:064x}.png ", 1)));
let ids: Vec<u64> = sqlx::query_scalar("SELECT id FROM image_uploads ORDER BY id")
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(ids, vec![fresh]);
assert!(orphaned_at(&pool, fresh).await.is_some());
}
#[sqlx::test]
async fn failed_deletions_keep_the_original_timestamp_and_retry_missing_objects(pool: Pool<MySql>) {
let id = upload(&pool, 1).await;
expire(&pool, id).await;
let original = orphaned_at(&pool, id).await;
let (failed_storage, failed_request) = storage(403).await;
assert!(cleanup(&pool, &failed_storage, 86400).await.is_err());
failed_request.await.unwrap();
assert_eq!(orphaned_at(&pool, id).await, original);
let (retry_storage, retry_request) = storage(404).await;
cleanup(&pool, &retry_storage, 86400).await.unwrap();
retry_request.await.unwrap();
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM image_uploads").fetch_one(&pool).await.unwrap();
assert_eq!(count, 0);
}
#[sqlx::test]
async fn rechecks_references_before_deleting_a_candidate(pool: Pool<MySql>) {
let id = upload(&pool, 1).await;
expire(&pool, id).await;
let (guild, _) = reference_tables(&pool).await;
sqlx::query("INSERT INTO reminder_template (guild_id, avatar) VALUES (?, ?)")
.bind(guild)
.bind(upload_url(1))
.execute(&pool)
.await
.unwrap();
let (storage, request) = storage(204).await;
assert!(!delete_orphan(&pool, &storage, 86400, id).await.unwrap());
assert!(orphaned_at(&pool, id).await.is_none());
request.abort();
}
#[sqlx::test]
async fn foreign_urls_are_retained_without_contacting_s3(pool: Pool<MySql>) {
let id = upload(&pool, 1).await;
sqlx::query(
"UPDATE image_uploads SET url = 'https://elsewhere.example.com/file.png' WHERE id = ?",
)
.bind(id)
.execute(&pool)
.await
.unwrap();
expire(&pool, id).await;
let (storage, request) = storage(204).await;
assert!(cleanup(&pool, &storage, 86400).await.is_err());
assert!(orphaned_at(&pool, id).await.is_some());
request.abort();
}
-2
View File
@@ -9,7 +9,6 @@ mod consts;
mod event_handlers; mod event_handlers;
#[cfg(not(test))] #[cfg(not(test))]
mod hooks; mod hooks;
mod image_cleanup;
mod interval_parser; mod interval_parser;
mod metrics; mod metrics;
#[cfg(not(test))] #[cfg(not(test))]
@@ -122,7 +121,6 @@ async fn _main(tx: Sender<()>) -> Result<(), Box<dyn StdError + Send + Sync>> {
Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap(); Pool::connect(&env::var("DATABASE_URL").expect("No database URL provided")).await.unwrap();
match cmd_word { match cmd_word {
Some("clean-images") => image_cleanup::run(&database).await,
Some("clean") => { Some("clean") => {
let sent_clean_age = env::var("SENT_CLEAN_AGE").expect("No SENT_CLEAN_AGE provided"); let sent_clean_age = env::var("SENT_CLEAN_AGE").expect("No SENT_CLEAN_AGE provided");
if sent_clean_age.is_empty() { if sent_clean_age.is_empty() {
+10 -3
View File
@@ -8,7 +8,9 @@ use crate::{consts::DEFAULT_AVATAR, Error};
pub struct ChannelData { pub struct ChannelData {
pub id: u32, pub id: u32,
pub channel: u64, pub channel: u64,
pub name: Option<String>,
pub nudge: i16, pub nudge: i16,
pub blacklisted: bool,
pub webhook_id: Option<u64>, pub webhook_id: Option<u64>,
pub webhook_token: Option<String>, pub webhook_token: Option<String>,
pub paused: bool, pub paused: bool,
@@ -25,7 +27,7 @@ impl ChannelData {
if let Ok(c) = sqlx::query_as_unchecked!( if let Ok(c) = sqlx::query_as_unchecked!(
Self, Self,
" "
SELECT id, channel, nudge, webhook_id, webhook_token, paused, SELECT id, channel, name, nudge, blacklisted, webhook_id, webhook_token, paused,
paused_until paused_until
FROM channels FROM channels
WHERE channel = ? WHERE channel = ?
@@ -43,8 +45,9 @@ impl ChannelData {
if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) }; if let Some((a, b)) = props { (Some(a), Some(b)) } else { (None, None) };
sqlx::query!( sqlx::query!(
"INSERT IGNORE INTO channels (channel, guild_id) VALUES (?, (SELECT id FROM guilds WHERE guild = ?))", "INSERT IGNORE INTO channels (channel, name, guild_id) VALUES (?, ?, (SELECT id FROM guilds WHERE guild = ?))",
channel_id, channel_id,
channel_name,
guild_id guild_id
) )
.execute(&pool.clone()) .execute(&pool.clone())
@@ -53,7 +56,7 @@ impl ChannelData {
Ok(sqlx::query_as_unchecked!( Ok(sqlx::query_as_unchecked!(
Self, Self,
" "
SELECT id, channel, nudge, webhook_id, webhook_token, paused, paused_until SELECT id, channel, name, nudge, blacklisted, webhook_id, webhook_token, paused, paused_until
FROM channels FROM channels
WHERE channel = ? WHERE channel = ?
", ",
@@ -69,14 +72,18 @@ impl ChannelData {
" "
UPDATE channels UPDATE channels
SET SET
name = ?,
nudge = ?, nudge = ?,
blacklisted = ?,
webhook_id = ?, webhook_id = ?,
webhook_token = ?, webhook_token = ?,
paused = ?, paused = ?,
paused_until = ? paused_until = ?
WHERE id = ? WHERE id = ?
", ",
self.name,
self.nudge, self.nudge,
self.blacklisted,
self.webhook_id, self.webhook_id,
self.webhook_token, self.webhook_token,
self.paused, self.paused,
+20 -20
View File
@@ -215,30 +215,30 @@ impl<'a> MultiReminderBuilder<'a> {
for scope in self.scopes { for scope in self.scopes {
let db_channel_id = match scope { let db_channel_id = match scope {
ReminderScope::User(user_id) => { ReminderScope::User(user_id) => {
let user_id = UserId::new(user_id); if let Ok(user) = UserId::new(user_id).to_user(&self.ctx).await {
match UserData::from_user( let user_data = UserData::from_user(
&user_id, &user,
&self.ctx.serenity_context(), &self.ctx.serenity_context(),
&self.ctx.data().database, &self.ctx.data().database,
) )
.await .await
{ .unwrap();
Ok(user_data) => {
if let Some(guild_id) = self.guild_id { if let Some(guild_id) = self.guild_id {
if guild_id.member(&self.ctx, user_id).await.is_err() { if guild_id.member(&self.ctx, user).await.is_err() {
Err(ReminderError::InvalidTag) Err(ReminderError::InvalidTag)
} else if self.set_by.map_or(true, |i| i != user_data.id) } else if self.set_by.map_or(true, |i| i != user_data.id)
&& !user_data.allowed_dm && !user_data.allowed_dm
{ {
Err(ReminderError::UserBlockedDm) Err(ReminderError::UserBlockedDm)
} else {
Ok((user_data.dm_channel, None))
}
} else { } else {
Ok((user_data.dm_channel, None)) Ok((user_data.dm_channel, None))
} }
} else {
Ok((user_data.dm_channel, None))
} }
Err(_) => Err(ReminderError::InvalidTag), } else {
Err(ReminderError::InvalidTag)
} }
} }
ReminderScope::Channel(channel_with_thread) => { ReminderScope::Channel(channel_with_thread) => {
+13 -92
View File
@@ -195,100 +195,21 @@ impl Embed {
} }
pub fn has_content(&self) -> bool { pub fn has_content(&self) -> bool {
// Discord reports "description required" for empty embeds, but descriptions are optional. if self.title.is_empty()
// An author icon alone does not count as content; a footer icon does. && self.description.is_empty()
!self.title.trim().is_empty() && self.image_url.is_none()
|| !self.description.trim().is_empty() && self.thumbnail_url.is_none()
|| self.image_url.as_ref().is_some_and(|url| !url.trim().is_empty()) && self.footer.is_empty()
|| self.thumbnail_url.as_ref().is_some_and(|url| !url.trim().is_empty()) && self.footer_url.is_none()
|| !self.footer.trim().is_empty() && self.author.is_empty()
|| self.footer_url.as_ref().is_some_and(|url| !url.trim().is_empty()) && self.author_url.is_none()
|| !self.author.trim().is_empty() && self.fields.0.is_empty()
|| !self.fields.0.is_empty() {
} false
} } else {
true
#[cfg(test)]
mod tests {
use super::*;
fn embed(description: &str) -> Embed {
Embed {
title: String::new(),
description: description.to_string(),
image_url: None,
thumbnail_url: None,
footer: String::new(),
footer_url: None,
author: String::new(),
author_url: None,
color: 0,
fields: Json(vec![]),
} }
} }
#[test]
fn embed_without_description_can_have_content() {
let url = Some("https://example.com/image.png".to_string());
let embeds = [
Embed { title: "Reminder".to_string(), ..embed("") },
Embed { image_url: url.clone(), ..embed("") },
Embed { thumbnail_url: url.clone(), ..embed("") },
Embed { footer: "Footer".to_string(), ..embed("") },
Embed { footer_url: url, ..embed("") },
Embed { author: "Author".to_string(), ..embed("") },
Embed {
fields: Json(vec![EmbedField {
title: String::new(),
value: String::new(),
inline: false,
}]),
..embed("")
},
];
for embed in embeds {
assert!(embed.has_content());
}
}
#[test]
fn empty_embed_after_substitution_has_no_content() {
let mut embed = embed("<<timenow:Invalid/Timezone:%H>>");
embed.author_url = Some("https://example.com/image.png".to_string());
embed.description = substitute(&embed.description);
assert!(embed.description.is_empty());
assert!(!embed.has_content());
}
#[test]
fn embed_with_only_description_has_content() {
assert!(embed("Remember to do the thing").has_content());
}
#[test]
fn empty_or_whitespace_embed_has_no_content() {
assert!(!embed("").has_content());
let embed = Embed {
title: " \n\t ".to_string(),
image_url: Some(String::new()),
thumbnail_url: Some(" ".to_string()),
footer: " \n\t ".to_string(),
footer_url: Some(String::new()),
author: " \n\t ".to_string(),
color: 12345,
..embed(" \n\t ")
};
assert!(!embed.has_content());
}
#[test]
fn author_icon_alone_has_no_content() {
let embed =
Embed { author_url: Some("https://example.com/image.png".to_string()), ..embed("") };
assert!(!embed.has_content());
}
} }
impl Into<CreateEmbed> for Embed { impl Into<CreateEmbed> for Embed {
-298
View File
@@ -1,298 +0,0 @@
use std::{env, io::Cursor, sync::Arc};
use image::{ImageFormat, ImageReader, Limits};
use log::{info, warn};
use nsfw::model::{Classification, Metric};
use s3::{creds::Credentials, Bucket, Region};
use tokio::sync::Semaphore;
pub const MAX_UPLOAD_BYTES: u64 = 8 * 1024 * 1024;
const MAX_IMAGE_PIXELS: u64 = 16_000_000;
const NSFW_THRESHOLD: f32 = 0.5;
const MODEL_BYTES: &[u8] =
include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/nsfw/model.onnx"));
#[cfg(test)]
mod fixture_tests;
// An empty configuration keeps the dashboard's existing URL-only behavior.
pub struct ImageUploads(pub Option<Arc<ImageUploadService>>);
pub struct ImageUploadService {
pub bucket: Box<Bucket>,
pub public_url: String,
pub model: Option<nsfw::Model>,
pub slots: Arc<Semaphore>,
}
impl ImageUploads {
pub fn from_env() -> Self {
let names =
["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY", "S3_PUBLIC_URL"];
let values = names.map(|name| env::var(name).unwrap_or_default());
Self::from_settings(&values)
}
fn from_settings(values: &[String; 5]) -> Self {
if values.iter().all(|value| value.trim().is_empty()) {
return Self(None);
}
if values.iter().any(|value| value.trim().is_empty()) {
warn!("Image uploads disabled: all S3 upload settings must be supplied");
return Self(None);
}
let check_nsfw = nsfw_check_enabled(env::var("NSFW_CHECK_ENABLED").ok().as_deref());
match ImageUploadService::new(values, check_nsfw) {
Ok(service) => {
info!("Dashboard image uploads enabled");
Self(Some(Arc::new(service)))
}
Err(error) => {
warn!("Image uploads disabled: {}", error);
Self(None)
}
}
}
}
impl ImageUploadService {
// Cleanup uses the same storage configuration without loading the moderation model.
pub(crate) fn for_cleanup() -> Result<Option<Self>, Box<dyn std::error::Error + Send + Sync>> {
let values =
["S3_BUCKET", "S3_REGION", "S3_ACCESS_KEY_ID", "S3_SECRET_ACCESS_KEY", "S3_PUBLIC_URL"]
.map(|name| env::var(name).unwrap_or_default());
if values.iter().all(|value| value.trim().is_empty()) {
return Ok(None);
}
if values.iter().any(|value| value.trim().is_empty()) {
return Err("Image cleanup requires all S3 upload settings".into());
}
Self::new(&values, false).map(Some).map_err(|error| error.to_string().into())
}
fn new(values: &[String; 5], check_nsfw: bool) -> Result<Self, Box<dyn std::error::Error>> {
let url = reqwest::Url::parse(&values[4])?;
let public_url = url.as_str().trim_end_matches('/').to_string();
if !matches!(url.scheme(), "https" | "http")
|| url.host_str().is_none()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.query().is_some()
|| url.fragment().is_some()
|| public_url.len() > 400
{
return Err("S3_PUBLIC_URL must be an HTTP(S) base URL of at most 400 bytes".into());
}
let endpoint = env::var("S3_ENDPOINT").ok().filter(|value| !value.trim().is_empty());
let region = match &endpoint {
Some(endpoint) => {
Region::Custom { region: values[1].clone(), endpoint: endpoint.clone() }
}
None => values[1].parse()?,
};
let credentials = Credentials::new(Some(&values[2]), Some(&values[3]), None, None, None)?;
let mut bucket = Bucket::new(&values[0], region, credentials)?;
if endpoint.is_some() {
bucket = bucket.with_path_style();
}
bucket.set_request_timeout(Some(std::time::Duration::from_secs(30)));
let model = if check_nsfw {
Some(nsfw::create_model(MODEL_BYTES)?)
} else {
info!("NSFW checks disabled for dashboard image uploads");
None
};
Ok(Self { bucket, public_url, model, slots: Arc::new(Semaphore::new(2)) })
}
}
fn nsfw_check_enabled(value: Option<&str>) -> bool {
match value.map(str::trim).map(str::to_ascii_lowercase).as_deref() {
None | Some("" | "true" | "1" | "on") => true,
Some("false" | "0" | "off") => false,
Some(_) => {
warn!("Unrecognized NSFW_CHECK_ENABLED value; keeping NSFW checks enabled");
true
}
}
}
#[derive(Debug, PartialEq)]
pub enum ImageError {
InvalidImage,
Nsfw,
ModerationFailed,
}
fn probable_nsfw(predictions: &[Classification]) -> Result<bool, ImageError> {
if predictions.len() != 5
|| predictions.iter().any(|prediction| {
!prediction.score.is_finite() || !(0.0..=1.0).contains(&prediction.score)
})
{
return Err(ImageError::ModerationFailed);
}
let score: f32 = predictions
.iter()
.filter(|prediction| {
matches!(prediction.metric, Metric::Porn | Metric::Hentai | Metric::Sexy)
})
.map(|prediction| prediction.score)
.sum();
Ok(score >= NSFW_THRESHOLD)
}
// Publish only the decoded pixels, which are moderated when NSFW checks are enabled.
// Static PNGs strip metadata and prevent unchecked animation frames from being published.
fn decode_image(bytes: &[u8]) -> Result<image::RgbaImage, ImageError> {
let mut reader = ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.map_err(|_| ImageError::InvalidImage)?;
if !matches!(
reader.format(),
Some(ImageFormat::Jpeg | ImageFormat::Png | ImageFormat::Gif | ImageFormat::WebP)
) {
return Err(ImageError::InvalidImage);
}
let mut limits = Limits::default();
limits.max_image_width = Some(8192);
limits.max_image_height = Some(8192);
limits.max_alloc = Some(128 * 1024 * 1024);
reader.limits(limits);
let decoder = reader.into_decoder().map_err(|_| ImageError::InvalidImage)?;
use image::ImageDecoder;
let (width, height) = decoder.dimensions();
if width == 0 || height == 0 || u64::from(width) * u64::from(height) > MAX_IMAGE_PIXELS {
return Err(ImageError::InvalidImage);
}
image::DynamicImage::from_decoder(decoder)
.map(|image| image.to_rgba8())
.map_err(|_| ImageError::InvalidImage)
}
pub fn prepare_image(model: Option<&nsfw::Model>, bytes: &[u8]) -> Result<Vec<u8>, ImageError> {
let image = decode_image(bytes)?;
// Flatten transparency so the classifier sees exactly the published image.
let mut visible = image::RgbImage::new(image.width(), image.height());
for (target, source) in visible.pixels_mut().zip(image.pixels()) {
for channel in 0..3 {
let alpha = u32::from(source[3]);
target[channel] =
((u32::from(source[channel]) * alpha + 255 * (255 - alpha)) / 255) as u8;
}
}
let visible = image::DynamicImage::ImageRgb8(visible);
if let Some(model) = model {
let predictions = nsfw::examine(model, &visible.to_rgba8()).map_err(|error| {
warn!("Image moderation failed: {}", error);
ImageError::ModerationFailed
})?;
if probable_nsfw(&predictions)? {
return Err(ImageError::Nsfw);
}
}
let mut output = Cursor::new(Vec::new());
visible.write_to(&mut output, ImageFormat::Png).map_err(|_| ImageError::InvalidImage)?;
Ok(output.into_inner())
}
#[cfg(test)]
mod tests {
use super::*;
fn predictions(scores: [f32; 5]) -> Vec<Classification> {
[Metric::Drawings, Metric::Hentai, Metric::Neutral, Metric::Porn, Metric::Sexy]
.into_iter()
.zip(scores)
.map(|(metric, score)| Classification { metric, score })
.collect()
}
#[test]
fn combines_unsafe_categories_and_rejects_invalid_scores() {
assert_eq!(probable_nsfw(&predictions([0.1, 0.2, 0.4, 0.2, 0.1])), Ok(true));
assert_eq!(probable_nsfw(&predictions([0.1, 0.1, 0.7, 0.05, 0.05])), Ok(false));
assert_eq!(
probable_nsfw(&predictions([0.0, 0.0, f32::NAN, 0.0, 0.0])),
Err(ImageError::ModerationFailed)
);
assert_eq!(probable_nsfw(&[]), Err(ImageError::ModerationFailed));
assert_eq!(
probable_nsfw(&predictions([0.0, -0.5, 1.0, 0.0, 0.0])),
Err(ImageError::ModerationFailed)
);
}
#[test]
fn absent_or_partial_configuration_disables_uploads() {
let mut settings = std::array::from_fn(|_| String::new());
assert!(ImageUploads::from_settings(&settings).0.is_none());
settings[0] = "images".into();
assert!(ImageUploads::from_settings(&settings).0.is_none());
}
#[test]
fn disabling_nsfw_checks_skips_model_initialization() {
let settings = ["images", "eu-west-2", "test", "test", "https://images.example.com"]
.map(str::to_string);
assert!(ImageUploadService::new(&settings, false).unwrap().model.is_none());
}
#[test]
fn disabled_moderation_allows_flagged_images_but_still_validates_files() {
let model = nsfw::create_model(
&include_bytes!("../../tests/fixtures/image-upload-unsafe.onnx")[..],
)
.unwrap();
let mut bytes = Cursor::new(Vec::new());
image::DynamicImage::new_rgb8(2, 2).write_to(&mut bytes, ImageFormat::Png).unwrap();
assert_eq!(prepare_image(Some(&model), bytes.get_ref()), Err(ImageError::Nsfw));
let published = prepare_image(None, bytes.get_ref()).unwrap();
assert_eq!(image::load_from_memory(&published).unwrap().width(), 2);
assert_eq!(prepare_image(None, b"not an image"), Err(ImageError::InvalidImage));
}
#[test]
fn bundled_model_loads_and_classifies_an_image() {
let model = nsfw::create_model(MODEL_BYTES).unwrap();
let image = image::RgbaImage::from_pixel(224, 224, image::Rgba([255, 255, 255, 255]));
let predictions = nsfw::examine(&model, &image).unwrap();
assert!(probable_nsfw(&predictions).is_ok());
}
#[test]
fn rejects_non_images_and_truncated_images() {
assert_eq!(decode_image(b"<svg></svg>"), Err(ImageError::InvalidImage));
assert_eq!(decode_image(b"\x89PNG\r\n\x1a\n"), Err(ImageError::InvalidImage));
}
#[test]
fn decodes_real_images_without_trusting_the_filename() {
let image = image::DynamicImage::new_rgb8(2, 2);
for format in [ImageFormat::Png, ImageFormat::Jpeg, ImageFormat::Gif, ImageFormat::WebP] {
let mut bytes = Cursor::new(Vec::new());
image.write_to(&mut bytes, format).unwrap();
assert_eq!(decode_image(bytes.get_ref()).unwrap().dimensions(), (2, 2));
}
}
#[test]
fn rejects_excessive_image_dimensions() {
let image = image::DynamicImage::new_rgb8(8193, 1);
let mut bytes = Cursor::new(Vec::new());
image.write_to(&mut bytes, ImageFormat::Png).unwrap();
assert_eq!(decode_image(bytes.get_ref()), Err(ImageError::InvalidImage));
}
}
-131
View File
@@ -1,131 +0,0 @@
use std::{
collections::BTreeSet,
fs,
io::Cursor,
path::{Path, PathBuf},
};
use image::{ImageFormat, RgbaImage};
use super::{prepare_image, ImageError, MODEL_BYTES};
const NSFW_IMAGES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/nsfw");
const NON_NSFW_IMAGES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/non_nsfw");
fn fixture_pairs(directory: &Path) -> Vec<(PathBuf, PathBuf)> {
let mut indices = BTreeSet::new();
for entry in fs::read_dir(directory).expect("Could not read image fixture directory") {
let entry = entry.expect("Could not read image fixture entry");
let path = entry.path();
if path.extension().and_then(|extension| extension.to_str()) != Some("png") {
continue;
}
let name = path.file_name().unwrap().to_str().expect("Fixture name is not valid UTF-8");
let index = name
.strip_prefix("image_")
.and_then(|name| name.strip_suffix("a.png").or_else(|| name.strip_suffix("b.png")))
.filter(|index| index.parse::<u32>().is_ok_and(|index| index > 0))
.unwrap_or_else(|| {
panic!("Invalid fixture name {name}; expected image_1a.png/image_1b.png")
});
indices.insert(index.to_string());
}
assert!(
!indices.is_empty(),
"No paired fixtures in {}. Add images with scripts/split_nsfw_test_image.py.",
directory.display()
);
indices
.into_iter()
.map(|index| {
let first = directory.join(format!("image_{index}a.png"));
let second = directory.join(format!("image_{index}b.png"));
assert!(
first.is_file() && second.is_file(),
"Both fixture parts must exist: {} and {}",
first.display(),
second.display()
);
(first, second)
})
.collect()
}
fn recombine_parts(first: &RgbaImage, second: &RgbaImage) -> Result<RgbaImage, &'static str> {
if first.dimensions() != second.dimensions() {
return Err("Fixture parts must have identical dimensions");
}
let pixels = first.as_raw().iter().zip(second.as_raw()).map(|(a, b)| a ^ b).collect();
RgbaImage::from_raw(first.width(), first.height(), pixels).ok_or("Invalid fixture pixel data")
}
fn check_directory(directory: &str, expected_nsfw: bool) {
let pairs = fixture_pairs(Path::new(directory));
let model = nsfw::create_model(MODEL_BYTES).expect("Could not load bundled NSFW model");
for (first, second) in pairs {
let a = image::open(&first).expect("Could not decode first fixture part").to_rgba8();
let b = image::open(&second).expect("Could not decode second fixture part").to_rgba8();
let reconstructed = recombine_parts(&a, &b).expect("Could not recombine fixture parts");
let mut bytes = Cursor::new(Vec::new());
reconstructed
.write_to(&mut bytes, ImageFormat::Png)
.expect("Could not encode reconstructed fixture");
// Use the production path, including flattening transparency and the same
// combined category threshold. Never write the reconstructed image to disk.
let result = prepare_image(Some(&model), bytes.get_ref());
if expected_nsfw {
assert_eq!(
result.as_ref().err(),
Some(&ImageError::Nsfw),
"Expected {} to be blocked as NSFW, got {:?}",
first.display(),
result.as_ref().err()
);
} else {
assert!(
result.is_ok(),
"Expected {} to be accepted, got {:?}",
first.display(),
result.as_ref().err()
);
}
// The toggle must allow either category, while retaining image validation.
assert!(
prepare_image(None, bytes.get_ref()).is_ok(),
"Expected {} to be accepted with moderation disabled",
first.display()
);
}
}
#[test]
#[ignore = "Add local split fixtures to tests/fixtures/nsfw, then run with --ignored"]
fn nsfw_directory_images_are_blocked() {
check_directory(NSFW_IMAGES, true);
}
#[test]
#[ignore = "Add local split fixtures to tests/fixtures/non_nsfw, then run with --ignored"]
fn non_nsfw_directory_images_are_allowed() {
check_directory(NON_NSFW_IMAGES, false);
}
#[test]
fn recombining_parts_restores_every_pixel_and_alpha_channel() {
let original = [0, 17, 254, 255, 45, 99, 128, 0];
let random = vec![18, 201, 50, 34, 166, 76, 11, 220];
let masked = original.iter().zip(&random).map(|(pixel, mask)| pixel ^ mask).collect();
let first = RgbaImage::from_raw(2, 1, random).unwrap();
let second = RgbaImage::from_raw(2, 1, masked).unwrap();
assert_eq!(recombine_parts(&first, &second).unwrap().as_raw(), &original);
}
#[test]
fn rejects_parts_with_different_dimensions() {
assert_eq!(
recombine_parts(&RgbaImage::new(1, 1), &RgbaImage::new(2, 1)),
Err("Fixture parts must have identical dimensions")
);
}
+1 -1
View File
@@ -42,7 +42,7 @@ impl IpBlocking {
fn contains<I: Into<u32>>(&self, ip: I) -> bool { fn contains<I: Into<u32>>(&self, ip: I) -> bool {
let ip: u32 = ip.into(); let ip: u32 = ip.into();
let _prev_index = self.upper_ips.len() - 1; let mut prev_index = self.upper_ips.len() - 1;
let mut index = self.upper_ips.len() / 2; let mut index = self.upper_ips.len() / 2;
loop { loop {
if self.upper_ips[index] <= ip && self.lower_ips[index] >= ip { if self.upper_ips[index] <= ip && self.lower_ips[index] >= ip {
+10 -9
View File
@@ -4,7 +4,6 @@ mod macros;
mod catchers; mod catchers;
mod fairings; mod fairings;
mod guards; mod guards;
pub(crate) mod image_uploads;
mod ip_blocking; mod ip_blocking;
mod routes; mod routes;
@@ -64,6 +63,7 @@ use log::{error, info, warn};
use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl}; use oauth2::{basic::BasicClient, AuthUrl, ClientId, ClientSecret, RedirectUrl, TokenUrl};
use poise::serenity_prelude::{ use poise::serenity_prelude::{
client::Context, client::Context,
http::CacheHttp,
model::id::{GuildId, UserId}, model::id::{GuildId, UserId},
}; };
use rocket::{ use rocket::{
@@ -76,10 +76,13 @@ use rocket::{
}; };
use rocket_dyn_templates::Template; use rocket_dyn_templates::Template;
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
use std::net::Ipv4Addr;
use std::{env, path::Path}; use std::{env, path::Path};
use crate::web::consts::{DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN}; use crate::web::{
use crate::web::fairings::metrics::MetricProducer; consts::{CNC_GUILD, DISCORD_OAUTH_AUTHORIZE, DISCORD_OAUTH_TOKEN, SUBSCRIPTION_ROLES},
fairings::metrics::MetricProducer,
};
type Database = MySql; type Database = MySql;
@@ -125,8 +128,7 @@ pub async fn initialize(
if Path::new("static").exists() { "static" } else { "/lib/reminder-rs/static" }; if Path::new("static").exists() { "static" } else { "/lib/reminder-rs/static" };
info!("Using static path: {}", static_path); info!("Using static path: {}", static_path);
let image_uploads = image_uploads::ImageUploads::from_env(); rocket::build()
let rocket = rocket::build()
.attach(MetricProducer) .attach(MetricProducer)
.attach(Template::fairing()) .attach(Template::fairing())
.register( .register(
@@ -216,10 +218,9 @@ pub async fn initialize(
routes::dashboard::export::todos::export, routes::dashboard::export::todos::export,
routes::dashboard::export::todos::import, routes::dashboard::export::todos::import,
], ],
); )
.launch()
let rocket = routes::dashboard::api::images::mount(rocket, image_uploads); .await?;
rocket.launch().await?;
warn!("Exiting rocket runtime"); warn!("Exiting rocket runtime");
// distribute kill signal // distribute kill signal
+6 -1
View File
@@ -5,6 +5,8 @@ mod roles;
mod templates; mod templates;
pub mod todos; pub mod todos;
use std::env;
use crate::utils::check_subscription; use crate::utils::check_subscription;
use crate::web::guards::transaction::Transaction; use crate::web::guards::transaction::Transaction;
use crate::web::{check_authorization, routes::JsonResult}; use crate::web::{check_authorization, routes::JsonResult};
@@ -14,7 +16,10 @@ pub use reminders::*;
use rocket::{get, http::CookieJar, serde::json::json, State}; use rocket::{get, http::CookieJar, serde::json::json, State};
pub use roles::get_guild_roles; pub use roles::get_guild_roles;
use serenity::all::UserId; use serenity::all::UserId;
use serenity::{client::Context, model::id::GuildId}; use serenity::{
client::Context,
model::id::{GuildId, RoleId},
};
pub use templates::*; pub use templates::*;
#[get("/api/guild/<id>")] #[get("/api/guild/<id>")]
@@ -267,8 +267,9 @@ pub async fn edit_reminder(
} }
if reminder.channel > 0 { if reminder.channel > 0 {
let channel_guild = let channel_guild = ChannelId::new(reminder.channel)
ctx.cache.channel(ChannelId::new(reminder.channel)).map(|channel| channel.guild_id); .to_channel_cached(&ctx.cache)
.map(|channel| channel.guild_id);
match channel_guild { match channel_guild {
Some(channel_guild) => { Some(channel_guild) => {
let channel_matches_guild = channel_guild.get() == id; let channel_matches_guild = channel_guild.get() == id;
-319
View File
@@ -1,319 +0,0 @@
use log::warn;
use rand::{rngs::OsRng, RngCore};
use rocket::{
data::{Data, ToByteUnit},
http::{CookieJar, Status},
post,
request::{FromRequest, Outcome},
serde::json::{json, Json, Value},
Request, State,
};
use sqlx::{MySql, Pool};
use crate::web::image_uploads::{prepare_image, ImageError, ImageUploads, MAX_UPLOAD_BYTES};
pub fn mount(
rocket: rocket::Rocket<rocket::Build>,
uploads: ImageUploads,
) -> rocket::Rocket<rocket::Build> {
let enabled = uploads.0.is_some();
let rocket = rocket.manage(uploads);
if enabled {
rocket.mount("/dashboard", rocket::routes![upload_image])
} else {
rocket
}
}
pub struct Uploader(u64);
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Uploader {
type Error = ();
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
match uploader_id(request.cookies()) {
Some(id) => Outcome::Success(Self(id)),
None => Outcome::Error((Status::Unauthorized, ())),
}
}
}
fn uploader_id(cookies: &CookieJar<'_>) -> Option<u64> {
cookies.get_private("userid")?.value().parse().ok().filter(|id| *id != 0)
}
type UploadResult = Result<Json<Value>, (Status, Json<Value>)>;
fn error(status: Status, message: &str) -> (Status, Json<Value>) {
(status, Json(json!({ "error": message })))
}
// Requiring a non-form content type prevents cross-site HTML form submissions.
// Authorization is a request guard, so it runs before the body is read.
#[post("/api/images", format = "application/octet-stream", data = "<data>")]
pub async fn upload_image(
uploader: Uploader,
uploads: &State<ImageUploads>,
pool: &State<Pool<MySql>>,
data: Data<'_>,
) -> UploadResult {
let service =
uploads.0.as_ref().ok_or_else(|| error(Status::NotFound, "Image uploads disabled"))?;
let _permit = service
.slots
.clone()
.try_acquire_owned()
.map_err(|_| error(Status::TooManyRequests, "Image uploads busy. Please try again."))?;
let bytes = data
.open(MAX_UPLOAD_BYTES.bytes())
.into_bytes()
.await
.map_err(|_| error(Status::BadRequest, "Could not read image"))?;
if !bytes.is_complete() {
return Err(error(Status::PayloadTooLarge, "Images must be no larger than 8 MiB"));
}
let service_for_moderation = service.clone();
let moderated = tokio::task::spawn_blocking(move || {
prepare_image(service_for_moderation.model.as_ref(), &bytes.into_inner())
})
.await
.map_err(|err| {
warn!("Image moderation task failed: {}", err);
error(Status::ServiceUnavailable, "Image moderation unavailable. Please try again.")
})?;
let image = moderated.map_err(|err| match err {
ImageError::InvalidImage => error(
Status::UnprocessableEntity,
"Please upload a valid PNG, JPEG, GIF or WebP image (at most 16 million pixels)",
),
ImageError::Nsfw => error(
Status::UnavailableForLegalReasons,
"This image was flagged as probably NSFW and cannot be uploaded.",
),
ImageError::ModerationFailed => {
error(Status::ServiceUnavailable, "Image moderation unavailable. Please try again.")
}
})?;
let mut random = [0u8; 32];
OsRng.fill_bytes(&mut random);
let key = format!(
"uploads/{}.png",
random.iter().map(|byte| format!("{:02x}", byte)).collect::<String>()
);
let url = format!("{}/{}", service.public_url, key);
let response =
service.bucket.put_object_with_content_type(&key, &image, "image/png").await.map_err(
|err| {
warn!("Could not upload image to S3: {}", err);
error(Status::BadGateway, "Could not store image. Please try again.")
},
)?;
if !(200..300).contains(&response.status_code()) {
warn!("S3 image upload returned {}", response.status_code());
return Err(error(Status::BadGateway, "Could not store image. Please try again."));
}
if let Err(err) = sqlx::query("INSERT INTO image_uploads (uploader, url) VALUES (?, ?)")
.bind(uploader.0)
.bind(&url)
.execute(pool.inner())
.await
{
warn!("Could not record image upload: {}", err);
match service.bucket.delete_object(&key).await {
Ok(response) if (200..300).contains(&response.status_code()) => {}
_ => warn!("Could not clean up untracked image object {}", key),
}
return Err(error(
Status::InternalServerError,
"Could not record image upload. Please try again.",
));
}
Ok(Json(json!({ "url": url })))
}
#[cfg(test)]
mod tests {
use std::{io::Cursor, sync::Arc};
use rocket::{http::ContentType, local::asynchronous::Client};
use s3::{creds::Credentials, Bucket, Region};
use tokio::sync::Semaphore;
use super::*;
use crate::web::image_uploads::ImageUploadService;
fn service(unsafe_image: bool, endpoint: &str) -> ImageUploads {
let model: &[u8] = if unsafe_image {
include_bytes!("../../../../../tests/fixtures/image-upload-unsafe.onnx")
} else {
include_bytes!("../../../../../tests/fixtures/image-upload-safe.onnx")
};
let bucket = Bucket::new(
"images",
Region::Custom { region: "eu-west-2".into(), endpoint: endpoint.into() },
Credentials::new(Some("test"), Some("test"), None, None, None).unwrap(),
)
.unwrap()
.with_path_style();
ImageUploads(Some(Arc::new(ImageUploadService {
bucket,
public_url: "https://images.example.com".into(),
model: Some(nsfw::create_model(model).unwrap()),
slots: Arc::new(Semaphore::new(2)),
})))
}
fn image_bytes() -> Vec<u8> {
let mut bytes = Cursor::new(Vec::new());
image::DynamicImage::new_rgb8(2, 2).write_to(&mut bytes, image::ImageFormat::Png).unwrap();
bytes.into_inner()
}
async fn client(uploads: ImageUploads) -> Client {
let pool = sqlx::mysql::MySqlPoolOptions::new()
.connect_lazy("mysql://unused:unused@127.0.0.1:1/unused")
.unwrap();
Client::tracked(mount(rocket::build().manage(pool), uploads)).await.unwrap()
}
#[rocket::async_test]
async fn disabled_upload_route_is_not_available() {
let client = client(ImageUploads(None)).await;
assert_eq!(
client
.post("/dashboard/api/images")
.header(ContentType::Binary)
.body(image_bytes())
.dispatch()
.await
.status(),
Status::NotFound
);
}
#[rocket::async_test]
async fn upload_requires_an_authenticated_private_cookie() {
let client = client(service(false, "http://127.0.0.1:1")).await;
for cookie in [None, Some("not-an-id"), Some("0")] {
let mut request = client
.post("/dashboard/api/images")
.header(ContentType::Binary)
.body(image_bytes());
if let Some(cookie) = cookie {
request = request.private_cookie(("userid", cookie));
}
assert_eq!(request.dispatch().await.status(), Status::Unauthorized);
}
}
#[rocket::async_test]
async fn unsafe_images_return_451_without_contacting_storage_or_database() {
let client = client(service(true, "http://127.0.0.1:1")).await;
let response = client
.post("/dashboard/api/images")
.header(ContentType::Binary)
.private_cookie(("userid", "123456789012345678"))
.body(image_bytes())
.dispatch()
.await;
assert_eq!(response.status(), Status::UnavailableForLegalReasons);
assert!(response.into_json::<Value>().await.unwrap()["error"]
.as_str()
.unwrap()
.contains("NSFW"));
}
#[rocket::async_test]
async fn rejects_invalid_oversized_and_form_uploads() {
let client = client(service(false, "http://127.0.0.1:1")).await;
for (body, status) in [
(b"this is not an image".to_vec(), Status::UnprocessableEntity),
(vec![0; MAX_UPLOAD_BYTES as usize + 1], Status::PayloadTooLarge),
] {
let response = client
.post("/dashboard/api/images")
.header(ContentType::Binary)
.private_cookie(("userid", "123456789012345678"))
.body(body)
.dispatch()
.await;
assert_eq!(response.status(), status);
}
let response = client
.post("/dashboard/api/images")
.header(ContentType::Form)
.private_cookie(("userid", "123456789012345678"))
.body("file=anything")
.dispatch()
.await;
assert_eq!(response.status(), Status::NotFound);
}
#[sqlx::test]
async fn stores_moderated_image_and_tracks_uploader(pool: Pool<MySql>) {
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let endpoint = format!("http://{}", listener.local_addr().unwrap());
let storage = tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let mut received = Vec::new();
let mut buffer = [0; 4096];
let (headers, body) = loop {
let count = socket.read(&mut buffer).await.unwrap();
assert!(count > 0);
received.extend_from_slice(&buffer[..count]);
if let Some(end) = received.windows(4).position(|window| window == b"\r\n\r\n") {
let headers = String::from_utf8(received[..end].to_vec()).unwrap();
let length: usize = headers
.lines()
.find_map(|line| {
line.to_lowercase()
.strip_prefix("content-length:")
.map(|length| length.trim().parse().unwrap())
})
.unwrap();
if received.len() >= end + 4 + length {
break (headers, received[end + 4..end + 4 + length].to_vec());
}
}
};
socket
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
.await
.unwrap();
(headers, body)
});
let client =
Client::tracked(mount(rocket::build().manage(pool.clone()), service(false, &endpoint)))
.await
.unwrap();
let response = client
.post("/dashboard/api/images")
.header(ContentType::Binary)
.private_cookie(("userid", "123456789012345678"))
.body(image_bytes())
.dispatch()
.await;
assert_eq!(response.status(), Status::Ok);
let response = response.into_json::<Value>().await.unwrap();
let url = response["url"].as_str().unwrap();
let row: (u64, String) = sqlx::query_as("SELECT uploader, url FROM image_uploads")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(row, (123456789012345678, url.to_string()));
let (headers, body) = storage.await.unwrap();
assert!(headers.starts_with("PUT /images/uploads/"));
assert!(headers.to_lowercase().contains("content-type: image/png"));
assert_eq!(image::load_from_memory(&body).unwrap().width(), 2);
assert!(url.starts_with("https://images.example.com/uploads/"));
assert!(url.ends_with(".png"));
}
}
-1
View File
@@ -1,5 +1,4 @@
pub mod guild; pub mod guild;
pub mod images;
pub mod user; pub mod user;
use log::warn; use log::warn;
-5
View File
@@ -23,7 +23,6 @@ use serenity::{
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
use crate::web::guards::transaction::Transaction; use crate::web::guards::transaction::Transaction;
use crate::web::image_uploads::ImageUploads;
#[derive(Serialize)] #[derive(Serialize)]
struct UserPreferences { struct UserPreferences {
@@ -37,7 +36,6 @@ struct UserPreferences {
struct UserInfo { struct UserInfo {
name: String, name: String,
patreon: bool, patreon: bool,
image_uploads: bool,
preferences: UserPreferences, preferences: UserPreferences,
} }
@@ -54,12 +52,10 @@ pub async fn get_user_info(
cookies: &CookieJar<'_>, cookies: &CookieJar<'_>,
ctx: &State<Context>, ctx: &State<Context>,
pool: &State<Pool<MySql>>, pool: &State<Pool<MySql>>,
uploads: &State<ImageUploads>,
) -> JsonValue { ) -> JsonValue {
offline!(json!(UserInfo { offline!(json!(UserInfo {
name: "Discord".to_string(), name: "Discord".to_string(),
patreon: true, patreon: true,
image_uploads: uploads.0.is_some(),
preferences: UserPreferences { preferences: UserPreferences {
timezone: "UTC".to_string(), timezone: "UTC".to_string(),
use_browser_timezone: true, use_browser_timezone: true,
@@ -105,7 +101,6 @@ pub async fn get_user_info(
); );
let user_info = UserInfo { let user_info = UserInfo {
image_uploads: uploads.0.is_some(),
name: cookies name: cookies
.get_private("username") .get_private("username")
.map_or("Discord User".to_string(), |c| c.value().to_string()), .map_or("Discord User".to_string(), |c| c.value().to_string()),
@@ -3,10 +3,13 @@ use crate::web::{
check_authorization, check_authorization,
guards::transaction::Transaction, guards::transaction::Transaction,
routes::{ routes::{
dashboard::{ImportBody, ReminderTemplateCsv}, dashboard::{
create_reminder, CreateReminder, ImportBody, ReminderCsv, ReminderTemplateCsv, TodoCsv,
},
JsonResult, JsonResult,
}, },
}; };
use crate::Database;
use base64::{prelude::BASE64_STANDARD, Engine}; use base64::{prelude::BASE64_STANDARD, Engine};
use csv::{QuoteStyle, WriterBuilder}; use csv::{QuoteStyle, WriterBuilder};
use log::warn; use log::warn;
@@ -17,7 +20,10 @@ use rocket::{
serde::json::{json, Json}, serde::json::{json, Json},
State, State,
}; };
use serenity::{client::Context, model::id::GuildId}; use serenity::{
client::Context,
model::id::{ChannelId, GuildId, UserId},
};
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
#[get("/api/guild/<id>/export/reminder_templates")] #[get("/api/guild/<id>/export/reminder_templates")]
+4 -2
View File
@@ -2,7 +2,9 @@ use crate::web::{
check_authorization, check_authorization,
guards::transaction::Transaction, guards::transaction::Transaction,
routes::{ routes::{
dashboard::{create_reminder, CreateReminder, ImportBody, ReminderCsv}, dashboard::{
create_reminder, CreateReminder, ImportBody, ReminderCsv, ReminderTemplateCsv, TodoCsv,
},
JsonResult, JsonResult,
}, },
}; };
@@ -19,7 +21,7 @@ use rocket::{
}; };
use serenity::{ use serenity::{
client::Context, client::Context,
model::id::{GuildId, UserId}, model::id::{ChannelId, GuildId, UserId},
}; };
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
+6 -2
View File
@@ -1,10 +1,14 @@
use crate::web::{ use crate::web::{
check_authorization, check_authorization,
guards::transaction::Transaction,
routes::{ routes::{
dashboard::{ImportBody, TodoCsv}, dashboard::{
create_reminder, CreateReminder, ImportBody, ReminderCsv, ReminderTemplateCsv, TodoCsv,
},
JsonResult, JsonResult,
}, },
}; };
use crate::Database;
use base64::{prelude::BASE64_STANDARD, Engine}; use base64::{prelude::BASE64_STANDARD, Engine};
use csv::{QuoteStyle, WriterBuilder}; use csv::{QuoteStyle, WriterBuilder};
use log::warn; use log::warn;
@@ -17,7 +21,7 @@ use rocket::{
}; };
use serenity::{ use serenity::{
client::Context, client::Context,
model::id::{ChannelId, GuildId}, model::id::{ChannelId, GuildId, UserId},
}; };
use sqlx::{MySql, Pool}; use sqlx::{MySql, Pool};
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="EN">
<head>
<meta name="description" content="The most powerful Discord Reminders Bot">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8">
<meta name="yandex-verification" content="bb77b8681eb64a90"/>
<meta name="google-site-verification" content="7h7UVTeEe0AOzHiH3cFtsqMULYGN-zCZdMT_YCkW1Ho"/>
<!-- <meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src *; font-src fonts.gstatic.com 'self'"> -->
<!-- favicon -->
<link rel="apple-touch-icon" sizes="180x180"
href="/static/favicon/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32"
href="/static/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16"
href="/static/favicon/favicon-16x16.png">
<link rel="manifest" href="/static/site.webmanifest">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">
<title>Reminder Bot | Dashboard</title>
<!-- styles -->
<link rel="stylesheet" href="/static/css/fa.css">
<link rel="stylesheet" href="/static/css/font.css">
<link rel="stylesheet" href="/static/css/style.css">
<script type="module" crossorigin src="/static/assets/index-B8f0viPI.js"></script>
<link rel="stylesheet" crossorigin href="/static/assets/index-BZ8NJuKt.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
-11
View File
@@ -1,11 +0,0 @@
[Unit]
Description=Clean orphaned image uploads from Reminder Bot
Wants=network-online.target
After=network-online.target
[Service]
User=reminder
Type=oneshot
ExecStart=/usr/bin/reminder-rs clean-images
WorkingDirectory=/etc/reminder-rs
Environment="RUST_LOG=reminder_rs=info"
-10
View File
@@ -1,10 +0,0 @@
[Unit]
Description=Clean orphaned Reminder Bot image uploads hourly
[Timer]
OnCalendar=hourly
Persistent=true
Unit=reminder-rs-clean-images.service
[Install]
WantedBy=timers.target
-48
View File
@@ -1,48 +0,0 @@
The two tiny ONNX models are deterministic classifiers used by the image upload API tests.
They accept the same `[1, 224, 224, 3]` float input as the production nsfw model and return
`[Drawings, Hentai, Neutral, Porn, Sexy]` scores. One always returns Neutral = 1 and the
other always returns Porn = 1. They exercise the real nsfw inference path without requiring
image fixtures.
## Image fixtures for the bundled model
Add image pairs to these project directories:
* `tests/fixtures/nsfw/` — images expected to be rejected by moderation.
* `tests/fixtures/non_nsfw/` — images expected to be accepted by moderation.
Install the splitter's dependency with `python3 -m pip install Pillow`, then run from the
project root:
```sh
python3 scripts/split_nsfw_test_image.py /path/to/input.jpg --output-dir tests/fixtures/nsfw
python3 scripts/split_nsfw_test_image.py /path/to/safe.jpg --output-dir tests/fixtures/non_nsfw
```
To run the tests:
```sh
SQLX_OFFLINE=true cargo test --locked directory_images -- --ignored
```
## Deterministic model fixtures
To regenerate them with Python's `onnx` package:
```python
from pathlib import Path
from onnx import TensorProto, checker, helper
for name, scores in [("safe", [0., 0., 1., 0., 0.]), ("unsafe", [0., 0., 0., 1., 0.])]:
tensor = helper.make_tensor("probabilities", TensorProto.FLOAT, [1, 5], scores)
graph = helper.make_graph(
[helper.make_node("Identity", ["probabilities"], ["output"])],
"constant classifier for upload endpoint tests",
[helper.make_tensor_value_info("input", TensorProto.FLOAT, [1, 224, 224, 3])],
[helper.make_tensor_value_info("output", TensorProto.FLOAT, [1, 5])],
[tensor],
)
model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 13)], ir_version=8)
checker.check_model(model)
Path(f"tests/fixtures/image-upload-{name}.onnx").write_bytes(model.SerializeToString())
```
Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 MiB