Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ca84f4fea | ||
|
|
5f237aa2e3 | ||
|
|
90ba2ecff7 | ||
|
|
73bf4f9e38 | ||
|
|
7c2569522f | ||
|
|
a62195ffce | ||
|
|
c73a81a5f4 | ||
|
|
af378e7d71 | ||
|
|
59f397a96c | ||
|
|
9ababb8b48 | ||
|
|
37085c5dac | ||
|
|
ecac4cb8b4 | ||
|
|
2eb7af0d41 | ||
|
|
7cecfeabc6 | ||
|
|
ed6178d12e | ||
|
|
8f9656ac0e | ||
|
|
72fc42217f | ||
|
|
d9c1e9e521 | ||
|
|
e2697c0a78 | ||
|
|
474403ffe2 | ||
|
|
98025e9e6d | ||
|
|
3183a3bece | ||
|
|
041a9d4471 | ||
|
|
d84fdd0e98 | ||
|
|
c992a63b8f | ||
|
|
bd2417aff8 | ||
|
|
70d7a4f606 | ||
|
|
f4b534aa09 | ||
|
|
1c6735cde8 | ||
|
|
7f64c6b635 | ||
|
|
69f324ead7 | ||
|
|
e839c84bf0 | ||
|
|
7c39383655 | ||
|
|
84ec431441 | ||
|
|
cf129714be | ||
|
|
3a07481dfc | ||
|
|
e2bf1c457f | ||
|
|
e62d7af42f | ||
|
|
9641862515 | ||
|
|
b96c311235 | ||
|
|
2f613b7027 | ||
|
|
bc7494e7be | ||
|
|
b076903ecd | ||
|
|
a761def65e | ||
|
|
39307cb141 | ||
|
|
1adb761c8d | ||
|
|
d16a77907a | ||
|
|
a17dd5a4ef | ||
|
|
1170e6e9c1 | ||
|
|
b3ceac52ed | ||
|
|
bcff184a64 | ||
|
|
770b1e5ee6 | ||
|
|
2d9493d9fe | ||
|
|
aee29de34a | ||
|
|
f8667c1037 | ||
|
|
f25526782c | ||
|
|
93cce6b9fd | ||
|
|
30b1a1a4b0 |
@@ -0,0 +1,35 @@
|
||||
# Build context is the repository root (see deploy/J621-Backend and
|
||||
# deploy/J621-Frontend). Keep the context small and, more importantly, keep
|
||||
# secrets and runtime data out of the images.
|
||||
|
||||
# Version control / tooling
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
|
||||
# Python: the dev virtualenv, caches, and anything generated at runtime
|
||||
backend/venv/
|
||||
**/__pycache__/
|
||||
**/*.py[cod]
|
||||
backend/db.sqlite3
|
||||
backend/logs/
|
||||
backend/staticfiles/
|
||||
|
||||
# Secrets and media: the .env holds SECRET_KEY and DB passwords, media/ is
|
||||
# the actual library. The deploy composes mount these at runtime instead.
|
||||
backend/.env
|
||||
backend/.env.*
|
||||
backend/media/
|
||||
|
||||
# Frontend build artefacts (npm ci installs fresh from the lockfile)
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Deploy runtime state and env
|
||||
deploy/.env
|
||||
deploy/.env.*
|
||||
deploy/data/
|
||||
deploy/tailscale-state/
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
@@ -0,0 +1,197 @@
|
||||
# J621 CD — manual release workflow (Actions tab -> "Run workflow").
|
||||
#
|
||||
# One dispatch does everything; each half can be skipped with the `images`
|
||||
# and `desktop` inputs:
|
||||
# * builds and pushes the backend + frontend images (multi-arch, :latest
|
||||
# and :<short-sha>, GIT_HASH baked in for the version pill),
|
||||
# * builds the desktop packages and attaches them (plus the update
|
||||
# metadata) to the Gitea release tagged `desktop-v<package.json version>`.
|
||||
#
|
||||
# The desktop build also attaches the update metadata (latest*.yml) to the
|
||||
# release; that is the desktop updater's feed, resolved through the Gitea API
|
||||
# at check time (see desktop/README.md). The older website feed
|
||||
# (deploy/data/desktop, served at /desktop/) is runtime state on the deploy
|
||||
# host and only needed for installs before 0.1.2; it is refreshed with
|
||||
# `deploy/push_desktop.sh` from a machine that can reach the deploy host.
|
||||
#
|
||||
# Registry login uses a repo PAT with the minimal write:package scope (the
|
||||
# Gitea registry rejects the automatic job token, go-gitea/gitea#23642);
|
||||
# release creation uses the automatic job token. Jobs run on the user-scoped
|
||||
# nitro-ci runner (ubuntu-latest).
|
||||
|
||||
name: CD
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
images:
|
||||
description: Build and push the Docker images
|
||||
required: false
|
||||
default: "true"
|
||||
desktop:
|
||||
description: Build the desktop release
|
||||
required: false
|
||||
default: "true"
|
||||
platforms:
|
||||
description: Image platforms (comma separated)
|
||||
required: false
|
||||
default: linux/amd64,linux/arm64
|
||||
windows:
|
||||
description: Also cross-build the Windows installer (needs wine, slow)
|
||||
required: false
|
||||
default: "false"
|
||||
|
||||
concurrency:
|
||||
group: cd
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
images:
|
||||
name: Build & push images
|
||||
if: ${{ inputs.images != 'false' }}
|
||||
runs-on: ubuntu-latest
|
||||
# The Gitea container registry does not accept the automatic job token
|
||||
# (go-gitea/gitea#23642 is still open), so the push uses a repo PAT with
|
||||
# the minimal write:package scope. Releases use the job token instead.
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
PLATFORMS: ${{ inputs.platforms || 'linux/amd64,linux/arm64' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check the registry credentials
|
||||
run: |
|
||||
if [ -z "$REGISTRY_USER" ] || [ -z "$REGISTRY_TOKEN" ]; then
|
||||
echo "Set the REGISTRY_USER and REGISTRY_TOKEN repo secrets" >&2
|
||||
echo "(a PAT with the write:package scope)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Register binfmt (multi-arch builds)
|
||||
run: docker run --privileged --rm tonistiigi/binfmt --install all
|
||||
|
||||
- name: Build & push both images
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SHA="$(git rev-parse --short HEAD)"
|
||||
echo "Publishing $SHA for $PLATFORMS"
|
||||
PLATFORMS="$PLATFORMS" ./deploy/push_frontend.sh "$SHA"
|
||||
PLATFORMS="$PLATFORMS" ./deploy/push_backend.sh "$SHA"
|
||||
|
||||
desktop:
|
||||
name: Desktop release
|
||||
if: ${{ inputs.desktop != 'false' }}
|
||||
runs-on: ubuntu-latest
|
||||
# Creating the release and uploading its assets uses the automatic job
|
||||
# token, so it needs write access to the repository's releases.
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends fakeroot libarchive-tools
|
||||
if [ "${{ inputs.windows }}" = "true" ]; then
|
||||
# electron-builder runs the 32-bit NSIS installer under wine to
|
||||
# build the uninstaller: that needs a virtual display (Xvfb) and
|
||||
# 32-bit wine libraries.
|
||||
sudo dpkg --add-architecture i386
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends xvfb wine wine32:i386
|
||||
fi
|
||||
|
||||
- name: Install frontend + desktop dependencies
|
||||
run: |
|
||||
npm --prefix frontend ci --no-audit --no-fund
|
||||
npm --prefix desktop ci --no-audit --no-fund
|
||||
|
||||
- name: Build desktop packages
|
||||
env:
|
||||
# Keep wine from trying to fetch Gecko/Mono on first run.
|
||||
WINEDLLOVERRIDES: mscoree,mshtml=
|
||||
run: |
|
||||
if [ "${{ inputs.windows }}" = "true" ]; then
|
||||
xvfb-run -a ./deploy/build_desktop.sh --all
|
||||
else
|
||||
./deploy/build_desktop.sh --linux
|
||||
fi
|
||||
|
||||
- name: Add the Gitea release
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="$(node -p "require('./desktop/package.json').version")"
|
||||
TAG="desktop-v$VERSION"
|
||||
API="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
|
||||
AUTH="Authorization: token $GITEA_TOKEN"
|
||||
|
||||
NOTES="$(printf 'J621 desktop %s\n\n' "$VERSION"
|
||||
cd desktop/release
|
||||
sha256sum ./*.deb ./*.pkg.tar.zst ./*.exe 2>/dev/null || true)"
|
||||
|
||||
RELEASE_ID="$(curl -sf -H "$AUTH" "$API/releases/tags/$TAG" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin).get("id",""))' \
|
||||
2>/dev/null || true)"
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
echo "Creating release $TAG"
|
||||
PAYLOAD="$(python3 - "$TAG" "${{ github.sha }}" "$NOTES" <<'PY'
|
||||
import json, sys
|
||||
print(json.dumps({
|
||||
"tag_name": sys.argv[1],
|
||||
"name": sys.argv[1],
|
||||
"body": sys.argv[3],
|
||||
"target_commitish": sys.argv[2],
|
||||
}))
|
||||
PY
|
||||
)"
|
||||
RELEASE_ID="$(curl -sf -X POST -H "$AUTH" \
|
||||
-H "Content-Type: application/json" -d "$PAYLOAD" "$API/releases" \
|
||||
| python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')"
|
||||
else
|
||||
echo "Release $TAG already exists (id $RELEASE_ID); attaching missing files."
|
||||
fi
|
||||
|
||||
EXISTING="$(curl -sf -H "$AUTH" "$API/releases/$RELEASE_ID/assets" \
|
||||
|| echo '[]')"
|
||||
for FILE in desktop/release/*"$VERSION"*.deb \
|
||||
desktop/release/*"$VERSION"*.pkg.tar.zst \
|
||||
desktop/release/latest-linux.yml \
|
||||
desktop/release/latest.yml \
|
||||
desktop/release/*"$VERSION"*.exe \
|
||||
desktop/release/*"$VERSION"*.exe.blockmap; do
|
||||
[ -e "$FILE" ] || continue
|
||||
NAME="$(basename "$FILE")"
|
||||
# Replace the asset when it is already there: latest*.yml must
|
||||
# reference the installers built by *this* run (NSIS builds are
|
||||
# not bit-reproducible), so old copies are deleted first.
|
||||
ASSET_ID="$(printf '%s' "$EXISTING" | python3 -c '
|
||||
import json, sys
|
||||
name = sys.argv[1]
|
||||
print(next((str(a["id"]) for a in json.load(sys.stdin) if a["name"] == name), ""))
|
||||
' "$NAME")"
|
||||
if [ -n "$ASSET_ID" ]; then
|
||||
echo " replacing $NAME"
|
||||
curl -sf -X DELETE -H "$AUTH" \
|
||||
"$API/releases/$RELEASE_ID/assets/$ASSET_ID" >/dev/null
|
||||
else
|
||||
echo " attaching $NAME"
|
||||
fi
|
||||
# Names like "J621 Setup 0.1.1.exe" contain spaces: encode them
|
||||
# or curl refuses the URL (exit 3).
|
||||
ENCODED="$(python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1]))' "$NAME")"
|
||||
curl -sf -X POST -H "$AUTH" -H "Content-Type: application/octet-stream" \
|
||||
--data-binary @"$FILE" "$API/releases/$RELEASE_ID/assets?name=$ENCODED" >/dev/null
|
||||
done
|
||||
echo "Release: ${{ github.server_url }}/${{ github.repository }}/releases/tag/$TAG"
|
||||
@@ -0,0 +1,101 @@
|
||||
# J621 CI — runs on every push (and pull request): Django checks + the full
|
||||
# backend test suite against MariaDB/Redis, and the frontend type-check,
|
||||
# lint and production build.
|
||||
#
|
||||
# Runner: the "nitro-ci" act_runner with the custom `ubuntu-latest` label.
|
||||
|
||||
name: CI
|
||||
|
||||
# Tests and builds only need to read the repository; the automatic job token
|
||||
# stays read-only.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
tags-ignore: ["**"]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
name: Backend tests
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11.4
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: root
|
||||
MARIADB_DATABASE: j621
|
||||
MARIADB_USER: j621
|
||||
MARIADB_PASSWORD: j621
|
||||
options: >-
|
||||
--health-cmd="healthcheck.sh --connect --innodb_initialized"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=12
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
options: >-
|
||||
--health-cmd="redis-cli ping"
|
||||
--health-interval=5s
|
||||
--health-timeout=5s
|
||||
--health-retries=12
|
||||
env:
|
||||
# Connect as root so Django can create the test database itself;
|
||||
# everything else mirrors the development defaults.
|
||||
DB_HOST: mariadb
|
||||
DB_PORT: "3306"
|
||||
DB_NAME: j621
|
||||
DB_USER: root
|
||||
DB_PASSWORD: root
|
||||
REDIS_URL: redis://redis:6379/1
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: pip install -r backend/requirements.txt
|
||||
|
||||
- name: Django system checks
|
||||
working-directory: backend
|
||||
run: python manage.py check
|
||||
|
||||
- name: Backend tests
|
||||
working-directory: backend
|
||||
run: >-
|
||||
python manage.py test
|
||||
apps.core.tests
|
||||
apps.library.tests
|
||||
apps.follows.tests
|
||||
apps.accounts.tests
|
||||
|
||||
frontend:
|
||||
name: Frontend build & lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
working-directory: frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Type-check & build
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
@@ -25,6 +25,7 @@ frontend/dist/
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
.directory
|
||||
|
||||
# Tooling
|
||||
*.log
|
||||
|
||||
@@ -2,6 +2,11 @@ Use the venv on backend/venv/
|
||||
|
||||
DO NOT mess with the system's python
|
||||
|
||||
The original J621 (Django) app is kept for reference at
|
||||
/mnt/Disco/Proyects/Python3.13/J621-Django/
|
||||
Consult it when matching original behavior (batch sizes for e621 lookups,
|
||||
per-page sets, upload flow) instead of guessing.
|
||||
|
||||
For Frontend work:
|
||||
|
||||
read both Frontend Design related Markdown files on the frontend/ folder
|
||||
@@ -34,10 +39,27 @@ Project constraints (do not regress):
|
||||
- deploy/ is the deployment source of truth: J621-Frontend (SPA on static
|
||||
nginx) and J621-Backend (gunicorn + whitenoise, ffmpeg, migrations on
|
||||
start), three compose variants (both / frontend-only / backend-only) behind
|
||||
a shared nginx proxy service, and a Tailscale sidecar per compose whose
|
||||
serve configs only use funnel ports 443 / 8443 / 10000. Images are pushed
|
||||
to the Gitea registry with deploy/push_*.sh (multi-arch, :latest + :sha,
|
||||
GIT_HASH baked in for the version pill).
|
||||
a shared nginx proxy service, and a Tailscale sidecar per compose.
|
||||
serve.*.json are the public Funnel variants (only ports 443 / 8443 / 10000);
|
||||
serve.*.tailnet.json + compose.tailnet*.yml are the same stacks without
|
||||
AllowFunnel (tailnet-only, no public exposure). Images are pushed to the
|
||||
Gitea registry with deploy/push_*.sh (multi-arch, :latest + :sha, GIT_HASH
|
||||
baked in for the version pill); deploy/gen_env.sh generates .env with
|
||||
openssl secrets (--update keeps SECRET_KEY, --force rotates it).
|
||||
- Periodic commands (follow syncs, similarity cleanup, guest blacklist
|
||||
refresh) run in the composes' `scheduler` service — the backend image with
|
||||
the j621-scheduler entrypoint, intervals via J621_*_EVERY. No host cron.
|
||||
- CI/CD lives in .gitea/workflows: ci.yml runs on every push/PR (Django checks
|
||||
+ the full backend suite against MariaDB/Redis service containers, frontend
|
||||
lint/type-check/build); cd.yml is manual and builds/pushes both images
|
||||
multi-arch plus the desktop packages (attached to the Gitea release
|
||||
`desktop-v<version>`). Jobs run on the user-scoped runners: `ubuntu-latest`
|
||||
on nitro-ci, `desktop` on msi-mortar-ci. Do not add actions/cache
|
||||
(`cache: pip`/`npm`) to these workflows: Gitea's cache service hangs the job
|
||||
on restore/save. Desktop updates read the release assets (latest*.yml) from
|
||||
the Gitea API at check time; the older website feed (deploy/data/desktop)
|
||||
only matters for installs before 0.1.2 and is refreshed with
|
||||
`deploy/push_desktop.sh` from a machine with SSH to the deploy host.
|
||||
- Security/permission tests live in backend/apps/core/tests and need a
|
||||
one-time grant: GRANT ALL ON `test_j621`.* TO 'j621'@'%';
|
||||
|
||||
@@ -74,7 +96,12 @@ Security hardening (do not weaken):
|
||||
SECRET_KEY (apps/accounts/crypto.py); rotating SECRET_KEY invalidates them
|
||||
(and all signed media URLs), so users must re-enter the key.
|
||||
- API throttles live in REST_FRAMEWORK (env-overridable): anon 120/min,
|
||||
user 600/min, login 5/min, register 20/hour, e621_proxy 60/hour.
|
||||
user 600/min, login 5/min, register 20/hour, e621_proxy 60/hour. Signed
|
||||
media URLs (raw/thumbnail/staged-file/similarity-file actions) are exempt
|
||||
on purpose: <img>/<video> tags fetch them without an Authorization header,
|
||||
so a gallery would otherwise drain the anonymous bucket and get 429 JSON
|
||||
instead of images. THROTTLE_ENABLED=false removes the anon+user limits for
|
||||
private/tailnet deployments (the login/register/proxy guards stay).
|
||||
- Only admins (superusers) may grant/revoke the staff role or delete
|
||||
staff/admin accounts; staff manage regular/uploader accounts only.
|
||||
- Storage, duplicates, delete, temp-clear, uploads and downloads require
|
||||
|
||||
@@ -5,8 +5,10 @@ Self-hosted media library and e621 archive manager, rebuilt as a **React SPA + D
|
||||
## Structure
|
||||
|
||||
```
|
||||
backend/ Django 6 + DRF API (SQLite in dev, MariaDB in production)
|
||||
backend/ Django 6 + DRF API (MariaDB + Redis via docker compose)
|
||||
frontend/ Vite + React + TypeScript SPA
|
||||
deploy/ Docker images, compose variants and Tailscale serve configs
|
||||
extras/ shell integrations (fish_greeting with fastfetch)
|
||||
```
|
||||
|
||||
## Development
|
||||
@@ -33,9 +35,53 @@ npm run dev # http://localhost:5173, proxies /api to
|
||||
|
||||
### Production
|
||||
|
||||
Not wired up yet — planned: Nginx serving the SPA build, `/media` and `/library` media
|
||||
directly, and proxying `/api` to Waitress/Django. See `frontend/` design docs for the UI
|
||||
specification.
|
||||
Docker: see [`deploy/`](deploy/README.md) for the two images (SPA on static
|
||||
nginx, API on gunicorn), the three compose variants (both / frontend-only /
|
||||
backend-only) behind a shared nginx service and a Tailscale sidecar, and the
|
||||
public-funnel or tailnet-only serve configs. `deploy/push_*.sh` builds and
|
||||
pushes the multi-arch images to the Gitea registry.
|
||||
|
||||
## Random image endpoint
|
||||
|
||||
Used by the SPA's Random page and by shell greetings (fish_greeting +
|
||||
fastfetch):
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Token <token>" \
|
||||
"https://j621.example.ts.net/api/random/?rating=s,q&fastfetch=1"
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"j_id": "J-59",
|
||||
"filename": "J-59.jpg",
|
||||
"extension": "jpg",
|
||||
"rating": "e",
|
||||
"url": "https://j621.example.ts.net/api/files/J-59/raw/?sig=…",
|
||||
"download_url": "https://j621.example.ts.net/api/files/J-59/raw/?sig=…&download=1",
|
||||
"thumbnail_url": "https://j621.example.ts.net/api/files/J-59/thumbnail/?sig=…",
|
||||
"fastfetch": true
|
||||
}
|
||||
```
|
||||
|
||||
- `rating` — comma separated subset of `s`, `q`, `e` (default: any).
|
||||
- `fastfetch=1`, or any request whose User-Agent contains `fastfetch`, limits
|
||||
the roll to `png`/`jpg`/`gif` so terminals can display it. Images are the
|
||||
only candidates in both modes.
|
||||
- `url` is absolute, and signed for authenticated callers, so fastfetch can
|
||||
load it without headers. Guests get an unsigned URL and only see
|
||||
guest-visible items.
|
||||
- `/random` and `/random/` are aliases of `/api/random/` for scripts. Behind
|
||||
the bundled nginx those aliases negotiate on `Accept`: browsers get the SPA
|
||||
page, requesters like curl/wget/fastfetch get the JSON. `/api/random/` is
|
||||
the unambiguous path for scripts; 404 when nothing matches the filters.
|
||||
- A ready-made shell greeting that uses this endpoint lives in
|
||||
[`extras/fish_greeting/`](extras/fish_greeting/README.md).
|
||||
- **Scoped tokens for scripts**: the Account page's *Shell tokens* section
|
||||
(also `/tokens`) issues `j621r_…` tokens that only authenticate
|
||||
`/api/random/` — the rest of the API rejects them. They are stored as
|
||||
hashes, shown once, and revocable any time
|
||||
(`/api/auth/greeting-tokens/`).
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -20,7 +20,23 @@ they land.
|
||||
- [x] Tag cloud in the sidebar (click to search, hidden from guests for
|
||||
blacklisted items)
|
||||
- [x] Status filter (matched / not_found / deleted / custom / unknown)
|
||||
- [x] **Cacheable media serving**
|
||||
- [x] Stable signed URLs (7 d TTL, 24 h rotation) plus a `v=<md5>` cache
|
||||
buster, so replacing a file under the same J-ID invalidates it
|
||||
- [x] ETag/Last-Modified and a private `Cache-Control` (immutable for
|
||||
versioned media), conditional 304s
|
||||
- [x] Real 480 px image thumbnails (cached by MD5) instead of serving
|
||||
full-size originals through the thumbnail endpoint
|
||||
|
||||
- [x] **Random image** endpoint and page: `GET /api/random/` (aliases
|
||||
`/random`, `/random/`) with `rating=s,q,e` filters; fastfetch mode
|
||||
(`?fastfetch=1` or a Fastfetch User-Agent) only returns png/jpg/gif as
|
||||
JSON with a signed absolute link, for the fish_greeting scripts; the
|
||||
`/random` SPA page rolls with rating pills and the `R` key
|
||||
- **Scoped `j621r_…` greeting tokens** (Account → Shell tokens, `/tokens`):
|
||||
only `/api/random/` accepts them, they are stored hashed, shown once and
|
||||
revocable; `install.fish` in `extras/fish_greeting` fills them into the
|
||||
shell greeting config
|
||||
- [x] **Ephemeral similarity check** (`/similar`)
|
||||
- [x] Drop a file: exact MD5 match, perceptual matches against the library,
|
||||
and e621 IQDB candidates (auto-run for images)
|
||||
@@ -86,18 +102,25 @@ Files now stage first and are resolved before entering the library.
|
||||
- [x] `cleanup_temp_uploads` command for old staged files
|
||||
- [x] **Auto-upload / auto-match**
|
||||
- [x] MD5 computed on staging; exact duplicates resolve immediately
|
||||
- [x] MD5 batch-checked against e621; matches auto-complete with post
|
||||
metadata stored and rating seeded
|
||||
- [x] **IQDB similarity on upload** (SPA-driven)
|
||||
- [x] Automatic + manual IQDB checks with candidate posts
|
||||
- [x] "Visual Similarity Detected" state with candidate picker
|
||||
- [x] Perceptual-hash comparison against the library (staged uploads are
|
||||
flagged with their library matches as soon as they land)
|
||||
- [x] MD5 batch-checked against e621 in chunks of 75; matches auto-complete
|
||||
with post metadata stored and rating seeded
|
||||
- [x] **Background pipeline** (server-side)
|
||||
- [x] Daemon-thread worker runs MD5 → visual similarity → IQDB for every
|
||||
staged upload, so the work continues after the page or tab is closed
|
||||
- [x] Durable progress (`UploadRun` + per-file phase flags) polled by the
|
||||
shell indicator; rate-limited runs retry with backoff
|
||||
- [x] Perceptual-hash comparison against the library, loaded once per batch
|
||||
- [x] IQDB candidates stored with one batched enrichment request
|
||||
- [x] Indexed uploads are announced through a bounded per-run feed and the
|
||||
staged row is deleted; nothing persists on the board to dismiss
|
||||
- [x] **Upload UI**
|
||||
- [x] Three-column board: Pending & Unmatched / Visual Similarity Detected /
|
||||
Auto-uploaded & Indexed
|
||||
- [x] Four-column board: Pending & Unmatched / Visual Similarity Detected /
|
||||
Auto-uploaded & Indexed (session feed) / Failed, private per user
|
||||
(staff included)
|
||||
- [x] Metadata modal (link to e621 post, IQDB candidates, custom metadata)
|
||||
- [x] Per-file progress plus batch processing indicator
|
||||
- [x] Per-file progress plus background pipeline status
|
||||
- [x] Bulk actions: bulk rate and discard all (chunked past the API's
|
||||
1000-id cap)
|
||||
|
||||
## 4. Staff tools
|
||||
|
||||
@@ -136,7 +159,9 @@ Files now stage first and are resolved before entering the library.
|
||||
- [x] Profile pictures: staff Users page and a self-service Account picker
|
||||
(searchable library grid, remove supported) set avatars from library J-IDs
|
||||
- [x] Profile extras: per-user landing page, default rating filter/sort, items
|
||||
per page and thumbnail size (synced to the account, applied on load)
|
||||
per page and thumbnail size (synced to the account, applied on load),
|
||||
plus e621 posts per page (48/100/200/320) for the Online browser and
|
||||
pool loading
|
||||
- [x] Backend-less local mode: with no backend connected the SPA runs on the
|
||||
e621-facing pages only (Online, Pools) using credentials stored in the
|
||||
browser, and the shell offers a "Setup Backend" button instead
|
||||
@@ -145,10 +170,12 @@ Files now stage first and are resolved before entering the library.
|
||||
|
||||
## 6. Infrastructure
|
||||
|
||||
- [ ] Guest blacklist refresh on a timer (in-container scheduler)
|
||||
- [ ] Follow sync on a timer (in-container scheduler, e.g. every 30 minutes)
|
||||
- [ ] Similarity temp cleanup on a timer (in-container scheduler; the TTL
|
||||
also cleans lazily when new checks are created)
|
||||
- [x] Guest blacklist refresh on a timer (the composes' `scheduler` service;
|
||||
`J621_BLACKLIST_EVERY`, default daily)
|
||||
- [x] Follow sync on a timer (same scheduler: `sync_followed_tags` +
|
||||
`sync_followed_pools`, default every 30 minutes)
|
||||
- [x] Similarity temp cleanup on a timer (same scheduler, default hourly;
|
||||
the TTL also cleans lazily when new checks are created)
|
||||
- [x] Production setup: two Docker images (SPA on static nginx, API on
|
||||
gunicorn + whitenoise with ffmpeg) and three compose variants (both /
|
||||
frontend-only / backend-only) behind a shared nginx proxy service, each
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Authentication for scope-limited bearer tokens.
|
||||
|
||||
`GreetingTokenAuthentication` understands the same header a normal API token
|
||||
uses (``Authorization: Token <key>``) but only resolves tokens issued for the
|
||||
random-image endpoint. It is registered per-view (currently only
|
||||
`RandomItemView`), so a greeting token is rejected everywhere else by the
|
||||
regular DRF token authentication.
|
||||
"""
|
||||
|
||||
from rest_framework import authentication, exceptions
|
||||
|
||||
from .models import GreetingToken
|
||||
|
||||
|
||||
class GreetingTokenAuthentication(authentication.BaseAuthentication):
|
||||
keyword = b"token"
|
||||
|
||||
def authenticate_header(self, request):
|
||||
# DRF answers 401 (instead of 403) for AuthenticationFailed only when
|
||||
# the first authenticator can name the scheme.
|
||||
return "Token"
|
||||
|
||||
def authenticate(self, request):
|
||||
header = authentication.get_authorization_header(request).split()
|
||||
if not header or header[0].lower() != self.keyword:
|
||||
return None
|
||||
if len(header) != 2:
|
||||
raise exceptions.AuthenticationFailed("Invalid token header.")
|
||||
try:
|
||||
key = header[1].decode()
|
||||
except UnicodeError:
|
||||
raise exceptions.AuthenticationFailed("Invalid token header.")
|
||||
|
||||
# Not one of ours: let the regular token authentication handle it.
|
||||
if not key.startswith(GreetingToken.PREFIX):
|
||||
return None
|
||||
|
||||
token = GreetingToken.resolve(key)
|
||||
if token is None:
|
||||
raise exceptions.AuthenticationFailed("Invalid token.")
|
||||
token.touch()
|
||||
return (token.user, token)
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 6.1.1 on 2026-09-18 18:25
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0006_encrypt_e621_api_keys'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='GreetingToken',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('key_hash', models.CharField(max_length=64, unique=True)),
|
||||
('prefix', models.CharField(max_length=16)),
|
||||
('label', models.CharField(blank=True, default='', max_length=100)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('last_used_at', models.DateTimeField(blank=True, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='greeting_tokens', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,9 @@
|
||||
import secrets
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
@@ -50,3 +54,63 @@ class User(AbstractUser):
|
||||
return bool(
|
||||
self.is_superuser or self.is_staff or self.role == self.ROLE_STAFF
|
||||
)
|
||||
|
||||
|
||||
def hash_bearer_token(value):
|
||||
"""SHA-256 of a high-entropy bearer token (no salt needed)."""
|
||||
import hashlib
|
||||
|
||||
return hashlib.sha256(value.encode()).hexdigest()
|
||||
|
||||
|
||||
class GreetingToken(models.Model):
|
||||
"""Long-lived token that only authenticates the random-image endpoint.
|
||||
|
||||
Meant for shell greetings and similar scripts, so it is safe to keep in a
|
||||
config file: it cannot read the library, upload, or touch an account. Only
|
||||
the SHA-256 hash is stored; the plaintext is returned once at creation.
|
||||
"""
|
||||
|
||||
PREFIX = "j621r_"
|
||||
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="greeting_tokens",
|
||||
)
|
||||
key_hash = models.CharField(max_length=64, unique=True)
|
||||
prefix = models.CharField(max_length=16)
|
||||
label = models.CharField(max_length=100, blank=True, default="")
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
last_used_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.prefix}… ({self.user})"
|
||||
|
||||
@classmethod
|
||||
def issue(cls, user, label=""):
|
||||
"""Create a token and return ``(token, plaintext_key)``."""
|
||||
key = cls.PREFIX + secrets.token_hex(20)
|
||||
token = cls.objects.create(
|
||||
user=user,
|
||||
key_hash=hash_bearer_token(key),
|
||||
prefix=key[:12],
|
||||
label=label.strip()[:100],
|
||||
)
|
||||
return token, key
|
||||
|
||||
@classmethod
|
||||
def resolve(cls, key):
|
||||
if not key.startswith(cls.PREFIX):
|
||||
return None
|
||||
return (
|
||||
cls.objects.select_related("user")
|
||||
.filter(key_hash=hash_bearer_token(key))
|
||||
.first()
|
||||
)
|
||||
|
||||
def touch(self):
|
||||
GreetingToken.objects.filter(pk=self.pk).update(last_used_at=timezone.now())
|
||||
|
||||
@@ -6,7 +6,7 @@ from rest_framework import serializers
|
||||
from apps.library.services import VIDEO_EXTENSIONS
|
||||
from apps.library.services import signed_media_url as signed_library_url
|
||||
|
||||
from .models import User
|
||||
from .models import User, GreetingToken
|
||||
|
||||
|
||||
def signed_media_url(request, item):
|
||||
@@ -92,6 +92,13 @@ class UserUpdateSerializer(serializers.Serializer):
|
||||
role = serializers.ChoiceField(choices=User.ROLE_CHOICES, required=False)
|
||||
|
||||
|
||||
class GreetingTokenSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = GreetingToken
|
||||
fields = ["id", "prefix", "label", "created_at", "last_used_at"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class RegisterSerializer(serializers.ModelSerializer):
|
||||
password = serializers.CharField(write_only=True, validators=[validate_password])
|
||||
|
||||
@@ -127,5 +134,7 @@ class PreferencesSerializer(serializers.Serializer):
|
||||
ordering = serializers.CharField(max_length=30, required=False)
|
||||
per_page = serializers.IntegerField(min_value=12, max_value=200, required=False)
|
||||
zoom = serializers.IntegerField(min_value=120, max_value=400, required=False)
|
||||
# e621 renders at most 320 posts per request.
|
||||
e621_per_page = serializers.IntegerField(min_value=12, max_value=320, required=False)
|
||||
# Open Online with the order:hot metatag when no search is given.
|
||||
online_hot_default = serializers.BooleanField(required=False)
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Scope-limited greeting tokens (`j621r_…`).
|
||||
|
||||
They exist so shell greetings and scripts can hold a credential that only
|
||||
authenticates `/api/random/` — everything else must reject them — and they
|
||||
are stored hashed, shown once.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase, override_settings
|
||||
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from apps.accounts.models import GreetingToken, hash_bearer_token
|
||||
from apps.library.models import MediaItem, MediaLocation
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def jpost(client, path, body=None):
|
||||
return client.post(path, data=json.dumps(body or {}), content_type="application/json")
|
||||
|
||||
|
||||
class GreetingTokenTests(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls._tmp = tempfile.mkdtemp(prefix="j621-tokens-")
|
||||
cls._watched = Path(cls._tmp) / "library"
|
||||
cls._watched.mkdir(parents=True, exist_ok=True)
|
||||
cls._settings = override_settings(
|
||||
MEDIA_ROOT=cls._tmp, WATCHED_FOLDER=str(cls._watched)
|
||||
)
|
||||
cls._settings.enable()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls._settings.disable()
|
||||
shutil.rmtree(cls._tmp, ignore_errors=True)
|
||||
super().tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="token-user", password="token-pass-123456"
|
||||
)
|
||||
self.other = User.objects.create_user(
|
||||
username="token-other", password="token-pass-123456"
|
||||
)
|
||||
self.client = self.api_client(self.user)
|
||||
self.other_client = self.api_client(self.other)
|
||||
|
||||
# One image so the random endpoint can answer.
|
||||
path = self._watched / "token-test.png"
|
||||
path.write_bytes(b"token-test")
|
||||
self.item = MediaItem.objects.create(
|
||||
md5=hashlib.md5(b"token-test").hexdigest(),
|
||||
size=path.stat().st_size,
|
||||
rating="s",
|
||||
uploaded_by=self.user,
|
||||
)
|
||||
MediaLocation.objects.create(
|
||||
item=self.item, path=str(path), rel_path=path.name, mtime=time.time()
|
||||
)
|
||||
|
||||
def api_client(self, user):
|
||||
client = Client()
|
||||
client.defaults["HTTP_AUTHORIZATION"] = (
|
||||
f"Token {Token.objects.create(user=user).key}"
|
||||
)
|
||||
return client
|
||||
|
||||
def token_client(self, key):
|
||||
client = Client()
|
||||
client.defaults["HTTP_AUTHORIZATION"] = f"Token {key}"
|
||||
return client
|
||||
|
||||
def issue(self, client=None, label=""):
|
||||
response = jpost(client or self.client, "/api/auth/greeting-tokens/", {"label": label})
|
||||
self.assertEqual(response.status_code, 201)
|
||||
return response.json()
|
||||
|
||||
def test_create_returns_the_key_once_and_stores_only_a_hash(self):
|
||||
created = self.issue(self.client, "shell")
|
||||
key = created["key"]
|
||||
self.assertTrue(key.startswith("j621r_"))
|
||||
self.assertEqual(created["label"], "shell")
|
||||
token = GreetingToken.objects.get(pk=created["id"])
|
||||
self.assertEqual(token.key_hash, hash_bearer_token(key))
|
||||
self.assertNotIn(key, token.key_hash)
|
||||
self.assertEqual(token.prefix, key[:12])
|
||||
self.assertIsNone(token.last_used_at)
|
||||
|
||||
def test_list_hides_keys_and_hashes(self):
|
||||
self.issue(self.client, "one")
|
||||
self.issue(self.client, "two")
|
||||
rows = self.client.get("/api/auth/greeting-tokens/").json()
|
||||
self.assertEqual([row["label"] for row in rows], ["two", "one"])
|
||||
for row in rows:
|
||||
self.assertNotIn("key", row)
|
||||
self.assertNotIn("key_hash", row)
|
||||
self.assertTrue(row["prefix"].startswith("j621r_"))
|
||||
|
||||
def test_token_authenticates_random_and_nothing_else(self):
|
||||
key = self.issue(self.other_client, "shell")["key"]
|
||||
client = self.token_client(key)
|
||||
|
||||
response = client.get("/api/random/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("sig=", response.json()["url"])
|
||||
|
||||
for method, path, body in (
|
||||
("get", "/api/files/", None),
|
||||
("get", "/api/storage/", None),
|
||||
("get", "/api/auth/me/", None),
|
||||
("get", "/api/tags/cloud/", None),
|
||||
("post", "/api/delete/", {"j_ids": []}),
|
||||
("get", "/api/auth/greeting-tokens/", None),
|
||||
):
|
||||
call = getattr(client, method)
|
||||
if body is None:
|
||||
self.assertEqual(call(path).status_code, 401, path)
|
||||
else:
|
||||
self.assertEqual(jpost(client, path, body).status_code, 401, path)
|
||||
|
||||
def test_normal_api_token_still_authenticates_random(self):
|
||||
response = self.client.get("/api/random/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("sig=", response.json()["url"])
|
||||
|
||||
def test_unknown_greeting_key_is_rejected(self):
|
||||
client = self.token_client("j621r_" + "0" * 40)
|
||||
self.assertEqual(client.get("/api/random/").status_code, 401)
|
||||
|
||||
def test_revoked_token_stops_working(self):
|
||||
created = self.issue(self.client, "temporary")
|
||||
client = self.token_client(created["key"])
|
||||
self.assertEqual(client.get("/api/random/").status_code, 200)
|
||||
|
||||
response = self.client.delete(f"/api/auth/greeting-tokens/{created['id']}/")
|
||||
self.assertEqual(response.status_code, 204)
|
||||
self.assertEqual(client.get("/api/random/").status_code, 401)
|
||||
self.assertFalse(GreetingToken.objects.filter(pk=created["id"]).exists())
|
||||
|
||||
def test_cannot_revoke_someone_elses_token(self):
|
||||
created = self.issue(self.other_client, "theirs")
|
||||
response = self.client.delete(f"/api/auth/greeting-tokens/{created['id']}/")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
self.assertEqual(self.token_client(created["key"]).get("/api/random/").status_code, 200)
|
||||
|
||||
def test_last_used_is_recorded(self):
|
||||
created = self.issue(self.client, "used")
|
||||
self.token_client(created["key"]).get("/api/random/")
|
||||
token = GreetingToken.objects.get(pk=created["id"])
|
||||
self.assertIsNotNone(token.last_used_at)
|
||||
|
||||
def test_long_labels_are_rejected(self):
|
||||
response = jpost(
|
||||
self.client, "/api/auth/greeting-tokens/", {"label": "x" * 101}
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Per-user browse preferences: validation and merge semantics."""
|
||||
|
||||
import json
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase
|
||||
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class PreferenceTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="prefs-user", password="prefs-pass-123456"
|
||||
)
|
||||
self.client = Client()
|
||||
self.client.defaults["HTTP_AUTHORIZATION"] = (
|
||||
f"Token {Token.objects.create(user=self.user).key}"
|
||||
)
|
||||
|
||||
def post(self, payload):
|
||||
return self.client.post(
|
||||
"/api/auth/preferences/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_e621_per_page_round_trips_within_range(self):
|
||||
response = self.post({"e621_per_page": 320})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["e621_per_page"], 320)
|
||||
self.assertEqual(
|
||||
self.client.get("/api/auth/me/").json()["preferences"]["e621_per_page"],
|
||||
320,
|
||||
)
|
||||
|
||||
def test_e621_per_page_rejects_out_of_range(self):
|
||||
for value in (0, 8, 321, 1000):
|
||||
self.assertEqual(self.post({"e621_per_page": value}).status_code, 400, value)
|
||||
|
||||
def test_merge_keeps_other_keys(self):
|
||||
self.post({"ratings": ["s"], "e621_per_page": 100})
|
||||
response = self.post({"per_page": 24})
|
||||
data = response.json()
|
||||
self.assertEqual(data["ratings"], ["s"])
|
||||
self.assertEqual(data["e621_per_page"], 100)
|
||||
self.assertEqual(data["per_page"], 24)
|
||||
@@ -3,6 +3,8 @@ from django.urls import path
|
||||
from .views import (
|
||||
AvatarView,
|
||||
E621CredentialsView,
|
||||
GreetingTokenDetailView,
|
||||
GreetingTokenListView,
|
||||
LoginView,
|
||||
LogoutView,
|
||||
MeView,
|
||||
@@ -18,4 +20,14 @@ urlpatterns = [
|
||||
path("avatar/", AvatarView.as_view(), name="avatar"),
|
||||
path("preferences/", PreferencesView.as_view(), name="preferences"),
|
||||
path("e621/", E621CredentialsView.as_view(), name="e621_credentials"),
|
||||
path(
|
||||
"greeting-tokens/",
|
||||
GreetingTokenListView.as_view(),
|
||||
name="greeting_tokens",
|
||||
),
|
||||
path(
|
||||
"greeting-tokens/<int:pk>/",
|
||||
GreetingTokenDetailView.as_view(),
|
||||
name="greeting_token",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
|
||||
from django.http import Http404
|
||||
from rest_framework import mixins, status, viewsets
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.authtoken.views import ObtainAuthToken
|
||||
@@ -13,9 +14,10 @@ from apps.core.permissions import IsAppStaff
|
||||
from apps.library.models import MediaItem
|
||||
|
||||
from .crypto import encrypt_secret
|
||||
from .models import User
|
||||
from .models import GreetingToken, User
|
||||
from .serializers import (
|
||||
E621CredentialsSerializer,
|
||||
GreetingTokenSerializer,
|
||||
PreferencesSerializer,
|
||||
RegisterSerializer,
|
||||
UserListSerializer,
|
||||
@@ -157,6 +159,52 @@ class PreferencesView(APIView):
|
||||
return Response(preferences)
|
||||
|
||||
|
||||
class GreetingTokenListView(APIView):
|
||||
"""List and create the caller's random-endpoint tokens.
|
||||
|
||||
The plaintext key is returned once on creation; only its hash is stored,
|
||||
and it only authenticates `/api/random/` (see GreetingToken).
|
||||
"""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
tokens = GreetingToken.objects.filter(user=request.user)
|
||||
return Response(GreetingTokenSerializer(tokens, many=True).data)
|
||||
|
||||
def post(self, request):
|
||||
label = str(request.data.get("label") or "").strip()
|
||||
if len(label) > 100:
|
||||
return Response(
|
||||
{"detail": "Label is too long (100 characters max)."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
token, key = GreetingToken.issue(request.user, label)
|
||||
logger.info(
|
||||
"Greeting token %s created by %s", token.prefix, request.user.username
|
||||
)
|
||||
return Response(
|
||||
{**GreetingTokenSerializer(token).data, "key": key},
|
||||
status=status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
|
||||
class GreetingTokenDetailView(APIView):
|
||||
"""Revoke one of the caller's tokens."""
|
||||
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def delete(self, request, pk):
|
||||
token = GreetingToken.objects.filter(pk=pk, user=request.user).first()
|
||||
if token is None:
|
||||
raise Http404
|
||||
logger.info(
|
||||
"Greeting token %s revoked by %s", token.prefix, request.user.username
|
||||
)
|
||||
token.delete()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class UserViewSet(
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Redis cache that degrades instead of taking the whole API down.
|
||||
|
||||
Redis backs the DRF throttles and a few caches (storage stats, guest
|
||||
blacklist, tag clouds). With Django's stock ``RedisCache``, a Redis that is
|
||||
unreachable — or merely refusing writes because its RDB snapshot failed, the
|
||||
default ``stop-writes-on-bgsave-error`` behaviour — raises inside the
|
||||
throttle check on every request, so a cache outage becomes a blanket 500.
|
||||
|
||||
This backend treats cache failures as misses: rate limits and cached values
|
||||
simply stop working until Redis is back, and the first failure per worker is
|
||||
logged once so the cause is still visible.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.cache.backends.redis import RedisCache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_warned = False
|
||||
|
||||
|
||||
def _degrade(operation: str, error: Exception, default):
|
||||
global _warned
|
||||
if not _warned:
|
||||
_warned = True
|
||||
logger.warning(
|
||||
"Cache unavailable (%s failed: %s) — continuing without it.",
|
||||
operation,
|
||||
error,
|
||||
)
|
||||
return default
|
||||
|
||||
|
||||
class ResilientRedisCache(RedisCache):
|
||||
"""``RedisCache`` where a broken Redis behaves like an empty cache."""
|
||||
|
||||
def add(self, *args, **kwargs):
|
||||
try:
|
||||
return super().add(*args, **kwargs)
|
||||
except Exception as error: # noqa: BLE001 - any backend failure degrades
|
||||
return _degrade("add", error, False)
|
||||
|
||||
def get(self, key, default=None, version=None):
|
||||
try:
|
||||
return super().get(key, default, version)
|
||||
except Exception as error: # noqa: BLE001
|
||||
return _degrade("get", error, default)
|
||||
|
||||
def set(self, *args, **kwargs):
|
||||
try:
|
||||
return super().set(*args, **kwargs)
|
||||
except Exception as error: # noqa: BLE001
|
||||
return _degrade("set", error, None)
|
||||
|
||||
def touch(self, *args, **kwargs):
|
||||
try:
|
||||
return super().touch(*args, **kwargs)
|
||||
except Exception as error: # noqa: BLE001
|
||||
return _degrade("touch", error, False)
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
try:
|
||||
return super().delete(*args, **kwargs)
|
||||
except Exception as error: # noqa: BLE001
|
||||
return _degrade("delete", error, False)
|
||||
|
||||
def get_many(self, *args, **kwargs):
|
||||
try:
|
||||
return super().get_many(*args, **kwargs)
|
||||
except Exception as error: # noqa: BLE001
|
||||
return _degrade("get_many", error, {})
|
||||
|
||||
def has_key(self, *args, **kwargs):
|
||||
try:
|
||||
return super().has_key(*args, **kwargs)
|
||||
except Exception as error: # noqa: BLE001
|
||||
return _degrade("has_key", error, False)
|
||||
|
||||
def incr(self, *args, **kwargs):
|
||||
try:
|
||||
return super().incr(*args, **kwargs)
|
||||
except Exception as error: # noqa: BLE001
|
||||
return _degrade("incr", error, None)
|
||||
|
||||
def set_many(self, *args, **kwargs):
|
||||
try:
|
||||
return super().set_many(*args, **kwargs)
|
||||
except Exception as error: # noqa: BLE001
|
||||
return _degrade("set_many", error, [])
|
||||
|
||||
def delete_many(self, *args, **kwargs):
|
||||
try:
|
||||
return super().delete_many(*args, **kwargs)
|
||||
except Exception as error: # noqa: BLE001
|
||||
return _degrade("delete_many", error, None)
|
||||
|
||||
def clear(self, *args, **kwargs):
|
||||
try:
|
||||
return super().clear(*args, **kwargs)
|
||||
except Exception as error: # noqa: BLE001
|
||||
return _degrade("clear", error, None)
|
||||
|
||||
def close(self, *args, **kwargs):
|
||||
try:
|
||||
return super().close(*args, **kwargs)
|
||||
except Exception as error: # noqa: BLE001
|
||||
return _degrade("close", error, None)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""A broken Redis must degrade the cache, not 500 the API.
|
||||
|
||||
A Redis that cannot persist (the default ``stop-writes-on-bgsave-error``)
|
||||
or is simply unreachable used to raise inside the DRF throttle check on
|
||||
every request; ``ResilientRedisCache`` treats that as a cache miss.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.core.cache.backends.redis import RedisCacheClient
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
|
||||
def failing(method: str):
|
||||
return mock.patch.object(
|
||||
RedisCacheClient,
|
||||
method,
|
||||
side_effect=RuntimeError("redis is down"),
|
||||
)
|
||||
|
||||
|
||||
class ResilientCacheTests(SimpleTestCase):
|
||||
def test_get_returns_the_default_when_redis_fails(self):
|
||||
with failing("get"):
|
||||
self.assertIsNone(cache.get("j621-cache-test"))
|
||||
self.assertEqual(cache.get("j621-cache-test", "fallback"), "fallback")
|
||||
|
||||
def test_writes_report_failure_without_raising(self):
|
||||
with failing("set"):
|
||||
self.assertIsNone(cache.set("j621-cache-test", "value"))
|
||||
|
||||
def test_bulk_and_delete_operations_degrade(self):
|
||||
with failing("get_many"), failing("delete"):
|
||||
self.assertEqual(cache.get_many(["a", "b"]), {})
|
||||
self.assertFalse(cache.delete("a"))
|
||||
@@ -17,6 +17,7 @@ import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import signing
|
||||
@@ -35,6 +36,7 @@ from apps.library.models import (
|
||||
TempUpload,
|
||||
)
|
||||
from apps.library.services import MEDIA_FILE_SALT
|
||||
from apps.library.signing_urls import sign_payload
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -121,21 +123,15 @@ class SecurityTestCase(TestCase):
|
||||
return item
|
||||
|
||||
def old_signature(self, item, action="raw", age=3 * 86400):
|
||||
"""A valid signature minted `age` seconds ago."""
|
||||
real_time = signing.time
|
||||
|
||||
class Backdated:
|
||||
def time(self):
|
||||
return real_time.time() - age
|
||||
|
||||
try:
|
||||
signing.time = Backdated()
|
||||
return signing.dumps(
|
||||
{"item": item.id, "user": self.users["sec-uploader"].id, "action": action},
|
||||
salt=MEDIA_FILE_SALT,
|
||||
)
|
||||
finally:
|
||||
signing.time = real_time
|
||||
"""A signed media URL whose expiry is `age` seconds in the past."""
|
||||
return signing.Signer(salt=MEDIA_FILE_SALT).sign_object(
|
||||
{
|
||||
"item": item.id,
|
||||
"user": self.users["sec-uploader"].id,
|
||||
"action": action,
|
||||
"exp": int(time.time()) - age,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class GuestVisibilityTests(SecurityTestCase):
|
||||
@@ -182,9 +178,9 @@ class GuestVisibilityTests(SecurityTestCase):
|
||||
def test_authenticated_users_and_signed_urls_see_protected_items(self):
|
||||
uploader = self.client_for("sec-uploader")
|
||||
self.assertEqual(uploader.get(f"/api/files/J-{self.hidden.id}/").status_code, 200)
|
||||
signed = signing.dumps(
|
||||
signed = sign_payload(
|
||||
{"item": self.hidden.id, "user": self.users["sec-uploader"].id, "action": "raw"},
|
||||
salt=MEDIA_FILE_SALT,
|
||||
MEDIA_FILE_SALT,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.guest.get(f"/api/files/J-{self.hidden.id}/raw/?sig={signed}").status_code,
|
||||
@@ -192,9 +188,9 @@ class GuestVisibilityTests(SecurityTestCase):
|
||||
)
|
||||
|
||||
def test_signature_integrity(self):
|
||||
signed = signing.dumps(
|
||||
signed = sign_payload(
|
||||
{"item": self.hidden.id, "user": self.users["sec-uploader"].id, "action": "raw"},
|
||||
salt=MEDIA_FILE_SALT,
|
||||
MEDIA_FILE_SALT,
|
||||
)
|
||||
raw = f"/api/files/J-{self.hidden.id}/raw/"
|
||||
thumbnail = f"/api/files/J-{self.hidden.id}/thumbnail/"
|
||||
@@ -202,10 +198,29 @@ class GuestVisibilityTests(SecurityTestCase):
|
||||
self.assertEqual(self.guest.get(f"{raw}?sig={signed[:-4]}AAAA").status_code, 404)
|
||||
# Valid signature, wrong action.
|
||||
self.assertEqual(self.guest.get(f"{thumbnail}?sig={signed}").status_code, 404)
|
||||
# Expired signature (minted three days ago).
|
||||
# Expired signature (expiry three days ago).
|
||||
expired = self.old_signature(self.hidden)
|
||||
self.assertEqual(self.guest.get(f"{raw}?sig={expired}").status_code, 404)
|
||||
|
||||
def test_signed_media_urls_are_stable_and_versioned(self):
|
||||
"""The same item must keep the same URL across responses.
|
||||
|
||||
A per-second signature made browsers re-download every image on every
|
||||
poll; the MD5 version parameter busts caches only when the file itself
|
||||
changes (the optimize flow rewrites files under the same J-ID).
|
||||
"""
|
||||
item = self.visible
|
||||
first = services.signed_media_url(item, self.users["sec-uploader"])
|
||||
time.sleep(1.1)
|
||||
second = services.signed_media_url(item, self.users["sec-uploader"])
|
||||
self.assertEqual(first, second)
|
||||
self.assertIn(f"v={item.md5}", first)
|
||||
MediaItem.objects.filter(pk=item.pk).update(md5="b" * 32)
|
||||
item.refresh_from_db()
|
||||
self.assertNotEqual(
|
||||
services.signed_media_url(item, self.users["sec-uploader"]), first
|
||||
)
|
||||
|
||||
|
||||
class RoleBoundaryTests(SecurityTestCase):
|
||||
def test_non_uploader_is_read_only(self):
|
||||
@@ -344,7 +359,13 @@ class PrivacyTests(SecurityTestCase):
|
||||
)
|
||||
self.assertEqual(self.guest.get(f"/api/uploads/{temp.id}/file/").status_code, 401)
|
||||
self.assertNotIn(
|
||||
str(temp.id), self.client_for("sec-uploader").get("/api/uploads/").content.decode()
|
||||
str(temp.id),
|
||||
self.client_for("sec-uploader").get("/api/uploads/").content.decode(),
|
||||
)
|
||||
# The board is per-user: staff only see their own staged uploads.
|
||||
self.assertNotIn(
|
||||
str(temp.id),
|
||||
self.client_for("sec-staff").get("/api/uploads/").content.decode(),
|
||||
)
|
||||
|
||||
def test_similarity_checks_are_private(self):
|
||||
@@ -422,6 +443,36 @@ class ThrottleTests(SecurityTestCase):
|
||||
{self.guest.get("/api/status/").status_code for _ in range(12)}, {200}
|
||||
)
|
||||
|
||||
def test_signed_media_urls_are_not_throttled(self):
|
||||
"""<img>/<video> tags fetch these without an Authorization header.
|
||||
|
||||
Regression: they were charged to the anonymous bucket, so galleries
|
||||
and the fish-greeting download started returning 429 JSON instead of
|
||||
the image bytes.
|
||||
"""
|
||||
item = self.make_item("throttle-media", owner=self.users["sec-uploader"])
|
||||
codes = {
|
||||
self.guest.get(f"/api/files/J-{item.id}/raw/").status_code
|
||||
for _ in range(150)
|
||||
}
|
||||
self.assertEqual(codes, {200})
|
||||
|
||||
def test_staged_upload_files_are_not_throttled(self):
|
||||
temp = TempUpload.objects.create(
|
||||
user=self.users["sec-uploader"],
|
||||
file=SimpleUploadedFile("throttle-temp.bin", b"staged"),
|
||||
original_filename="throttle-temp.bin",
|
||||
md5=hashlib.md5(b"throttle-temp").hexdigest(),
|
||||
size=6,
|
||||
)
|
||||
signature = sign_payload(
|
||||
{"temp": str(temp.id), "user": self.users["sec-uploader"].id},
|
||||
services.UPLOAD_FILE_SALT,
|
||||
)
|
||||
url = f"/api/uploads/{temp.id}/file/?sig={signature}"
|
||||
codes = {self.guest.get(url).status_code for _ in range(150)}
|
||||
self.assertEqual(codes, {200})
|
||||
|
||||
|
||||
class RemoteUrlTests(SecurityTestCase):
|
||||
def test_allowlist(self):
|
||||
@@ -433,6 +484,28 @@ class RemoteUrlTests(SecurityTestCase):
|
||||
"https://static1.e621.net/data/x.png",
|
||||
)
|
||||
|
||||
@mock.patch("requests.get")
|
||||
def test_redirects_off_the_allowlist_are_refused(self, mocked_get):
|
||||
redirect = mock.Mock(is_redirect=True, is_permanent_redirect=False)
|
||||
redirect.headers = {"Location": "http://127.0.0.1:8000/health"}
|
||||
mocked_get.return_value = redirect
|
||||
|
||||
with self.assertRaises(services.RemoteUrlError):
|
||||
services.open_remote("https://static1.e621.net/x.png")
|
||||
# The internal address was never requested: only the first hop was.
|
||||
self.assertEqual(mocked_get.call_count, 1)
|
||||
redirect.close.assert_called()
|
||||
|
||||
@mock.patch("requests.get")
|
||||
def test_redirects_within_the_allowlist_are_followed(self, mocked_get):
|
||||
redirect = mock.Mock(is_redirect=True, is_permanent_redirect=False)
|
||||
redirect.headers = {"Location": "https://static2.e621.net/x.png"}
|
||||
final = mock.Mock(is_redirect=False, is_permanent_redirect=False)
|
||||
mocked_get.side_effect = [redirect, final]
|
||||
|
||||
self.assertIs(services.open_remote("https://static1.e621.net/x.png"), final)
|
||||
self.assertEqual(mocked_get.call_count, 2)
|
||||
|
||||
def test_download_creation_rejects_internal_urls(self):
|
||||
uploader = self.client_for("sec-uploader")
|
||||
for url in ("http://127.0.0.1:1/", "http://192.168.1.1/", "file:///etc/passwd"):
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""The follow lists are complete sets, not pages.
|
||||
|
||||
The SPA uses `GET /api/follows/tags/` (and pools) for two things: deciding a
|
||||
follow toggle's state, and rendering every card on the Followed page. With the
|
||||
standard 48-item pagination that silently broke past 48 follows — a 49th tag
|
||||
could not be followed (the toggle never saw it) and the page hid it. These
|
||||
tests pin the unpaginated behaviour.
|
||||
"""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase
|
||||
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from apps.follows.models import FollowedPool, FollowedTag
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class FollowListTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="follow-user", password="follow-pass-123456"
|
||||
)
|
||||
self.other = User.objects.create_user(
|
||||
username="follow-other", password="follow-pass-123456"
|
||||
)
|
||||
self.client = self.api_client(self.user)
|
||||
self.other_client = self.api_client(self.other)
|
||||
|
||||
def api_client(self, user):
|
||||
client = Client()
|
||||
client.defaults["HTTP_AUTHORIZATION"] = (
|
||||
f"Token {Token.objects.create(user=user).key}"
|
||||
)
|
||||
return client
|
||||
|
||||
def test_tag_list_is_not_paginated_past_48(self):
|
||||
FollowedTag.objects.bulk_create(
|
||||
[
|
||||
FollowedTag(user=self.user, tag=f"tag_{index:03d}")
|
||||
for index in range(60)
|
||||
]
|
||||
)
|
||||
response = self.client.get("/api/follows/tags/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIsInstance(data, list, "the list must not be paginated")
|
||||
self.assertEqual(len(data), 60)
|
||||
self.assertEqual(data[0]["tag"], "tag_000")
|
||||
self.assertEqual(data[-1]["tag"], "tag_059")
|
||||
# The shape the SPA uses for its follow state.
|
||||
self.assertIn("unseen_count", data[0])
|
||||
self.assertIn("id", data[0])
|
||||
|
||||
def test_pool_list_is_not_paginated(self):
|
||||
FollowedPool.objects.bulk_create(
|
||||
[
|
||||
FollowedPool(user=self.user, pool_id=59000 + index, name=f"pool {index}")
|
||||
for index in range(55)
|
||||
]
|
||||
)
|
||||
data = self.client.get("/api/follows/pools/").json()
|
||||
self.assertIsInstance(data, list)
|
||||
self.assertEqual(len(data), 55)
|
||||
|
||||
def test_lists_only_contain_the_callers_follows(self):
|
||||
FollowedTag.objects.create(user=self.user, tag="mine")
|
||||
FollowedTag.objects.create(user=self.other, tag="theirs")
|
||||
|
||||
mine = self.client.get("/api/follows/tags/").json()
|
||||
theirs = self.other_client.get("/api/follows/tags/").json()
|
||||
self.assertEqual([row["tag"] for row in mine], ["mine"])
|
||||
self.assertEqual([row["tag"] for row in theirs], ["theirs"])
|
||||
|
||||
def test_anonymous_cannot_list(self):
|
||||
self.assertEqual(Client().get("/api/follows/tags/").status_code, 401)
|
||||
self.assertEqual(Client().get("/api/follows/pools/").status_code, 401)
|
||||
@@ -36,10 +36,16 @@ class FollowedTagViewSet(
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""Tags the current user follows."""
|
||||
"""Tags the current user follows.
|
||||
|
||||
Deliberately unpaginated: the SPA treats this as the complete set (follow
|
||||
toggles read their state from it and the Followed page lists every card),
|
||||
so a 48-item page silently broke following past 48 entries.
|
||||
"""
|
||||
|
||||
serializer_class = FollowedTagSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
pagination_class = None
|
||||
http_method_names = ["get", "post", "delete", "head", "options"]
|
||||
|
||||
def get_queryset(self):
|
||||
@@ -112,10 +118,11 @@ class FollowedPoolViewSet(
|
||||
mixins.DestroyModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""Pools the current user follows."""
|
||||
"""Pools the current user follows (unpaginated, like the tag list)."""
|
||||
|
||||
serializer_class = FollowedPoolSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
pagination_class = None
|
||||
http_method_names = ["get", "post", "delete", "head", "options"]
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
"""Minimal e621 API client for server-side matching and metadata refresh.
|
||||
|
||||
The SPA talks to e621 directly for browsing; this client exists for work the
|
||||
browser cannot do reliably: long batch scans, and requests tied to a library
|
||||
item rather than an open page. It uses the requesting user's stored
|
||||
credentials and a global throttle (e621 asks for at most two requests per
|
||||
second).
|
||||
browser cannot do reliably: long batch scans, staged-upload processing and
|
||||
requests tied to a library item rather than an open page. It uses the
|
||||
requesting user's stored credentials and a global throttle (e621 asks for at
|
||||
most two requests per second, one per second sustained).
|
||||
|
||||
e621's load balancer also sheds load with 429s (sometimes with an HTML
|
||||
"shedding" page instead of JSON) and the IQDB endpoint has its own, much
|
||||
stricter throttle. Every call therefore retries with exponential backoff and
|
||||
honours ``Retry-After``; only 401/403 are treated as fatal.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import random
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
|
||||
REQUEST_INTERVAL = 0.5 # seconds between requests, per process
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Seconds between requests, per process. e621 allows 2/s hard and 1/s
|
||||
# sustained; each gunicorn worker throttles on its own, so leave enough
|
||||
# headroom that combined traffic does not trip the limit.
|
||||
REQUEST_INTERVAL = 1.0
|
||||
# IQDB is throttled far more aggressively than the rest of the API.
|
||||
IQDB_INTERVAL = 2.0
|
||||
|
||||
MAX_ATTEMPTS = 4
|
||||
BACKOFF_BASE = 2.0
|
||||
BACKOFF_CAP = 60.0
|
||||
RETRYABLE_STATUSES = {429, 500, 502, 503, 504}
|
||||
|
||||
|
||||
class E621Error(Exception):
|
||||
@@ -24,6 +44,14 @@ class E621NotFound(E621Error):
|
||||
"""The requested post does not exist (HTTP 404)."""
|
||||
|
||||
|
||||
class E621AuthError(E621Error):
|
||||
"""e621 rejected the stored credentials (401/403)."""
|
||||
|
||||
|
||||
class E621RateLimited(E621Error):
|
||||
"""e621 shed load or throttled the request after every retry."""
|
||||
|
||||
|
||||
_throttle_lock = threading.Lock()
|
||||
_last_request_at = 0.0
|
||||
|
||||
@@ -32,56 +60,170 @@ def credentials_configured(user):
|
||||
return bool(user is not None and getattr(user, "e621_configured", False))
|
||||
|
||||
|
||||
def _wait_for_slot():
|
||||
def _wait_for_slot(interval=REQUEST_INTERVAL):
|
||||
global _last_request_at
|
||||
with _throttle_lock:
|
||||
delay = _last_request_at + REQUEST_INTERVAL - time.monotonic()
|
||||
delay = _last_request_at + interval - time.monotonic()
|
||||
if delay > 0:
|
||||
time.sleep(delay)
|
||||
_last_request_at = time.monotonic()
|
||||
|
||||
|
||||
def _retry_delay(attempt, response=None):
|
||||
"""Backoff for a retryable failure, honouring ``Retry-After``."""
|
||||
if response is not None:
|
||||
retry_after = response.headers.get("Retry-After")
|
||||
if retry_after:
|
||||
try:
|
||||
return max(float(retry_after), 1.0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
delay = min(BACKOFF_BASE * (2**attempt), BACKOFF_CAP)
|
||||
return delay + random.uniform(0, delay * 0.25)
|
||||
|
||||
|
||||
def _request(
|
||||
user,
|
||||
method,
|
||||
path,
|
||||
*,
|
||||
params=None,
|
||||
data=None,
|
||||
files=None,
|
||||
timeout=30,
|
||||
require_auth=True,
|
||||
interval=REQUEST_INTERVAL,
|
||||
attempts=MAX_ATTEMPTS,
|
||||
):
|
||||
"""One e621 call with retries; returns the parsed JSON payload.
|
||||
|
||||
``files`` may be a callable returning the multipart mapping, which is
|
||||
called once per attempt: streamed uploads consume their file handle, so a
|
||||
retry needs a freshly opened file.
|
||||
|
||||
Raises E621NotFound for 404s, E621AuthError for 401/403 and
|
||||
E621RateLimited when e621 keeps shedding/throttling after every attempt.
|
||||
"""
|
||||
configured = credentials_configured(user)
|
||||
if require_auth and not configured:
|
||||
raise E621Error("Configure your e621 credentials in Account first.")
|
||||
base = (getattr(user, "e621_base_url", "") or "https://e621.net").rstrip("/")
|
||||
auth = (
|
||||
(user.e621_username, user.e621_api_key_plain) if configured else None
|
||||
)
|
||||
url = f"{base}{path}"
|
||||
|
||||
last_error = None
|
||||
for attempt in range(attempts):
|
||||
request_files = files() if callable(files) else files
|
||||
_wait_for_slot(interval)
|
||||
response = None
|
||||
try:
|
||||
response = requests.request(
|
||||
method,
|
||||
url,
|
||||
params=params,
|
||||
data=data,
|
||||
files=request_files,
|
||||
auth=auth,
|
||||
headers={"User-Agent": settings.USER_AGENT},
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
last_error = E621Error(f"Could not reach e621: {exc}")
|
||||
else:
|
||||
if response.status_code == 404:
|
||||
raise E621NotFound(f"e621 returned 404 for {path}")
|
||||
if response.status_code in {401, 403}:
|
||||
raise E621AuthError(
|
||||
f"e621 rejected the request ({response.status_code}). "
|
||||
"Check the stored e621 credentials."
|
||||
)
|
||||
if response.status_code == 429:
|
||||
# Throttles and load-shedding can arrive as JSON ({"message":
|
||||
# "Throttled: ..."}) or as an HTML page.
|
||||
message = ""
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
payload = None
|
||||
if isinstance(payload, dict):
|
||||
message = str(
|
||||
payload.get("message") or payload.get("error") or ""
|
||||
)
|
||||
last_error = E621RateLimited(
|
||||
message or f"e621 throttled the request for {path}"
|
||||
)
|
||||
elif response.status_code < 400:
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
# An HTML page with a 2xx status.
|
||||
last_error = E621RateLimited(
|
||||
f"e621 returned an unexpected {response.status_code} response."
|
||||
)
|
||||
elif response.status_code in RETRYABLE_STATUSES:
|
||||
last_error = E621RateLimited(
|
||||
f"e621 replied {response.status_code} for {path}"
|
||||
)
|
||||
else:
|
||||
raise E621Error(f"e621 replied {response.status_code} for {path}")
|
||||
finally:
|
||||
_close_upload_files(request_files)
|
||||
if attempt + 1 < attempts:
|
||||
delay = _retry_delay(attempt, response)
|
||||
logger.info(
|
||||
"e621 %s %s failed (%s); retrying in %.1fs",
|
||||
method,
|
||||
path,
|
||||
last_error,
|
||||
delay,
|
||||
)
|
||||
time.sleep(delay)
|
||||
|
||||
if last_error is None:
|
||||
last_error = E621Error("e621 request failed.")
|
||||
raise last_error
|
||||
|
||||
|
||||
def _close_upload_files(files):
|
||||
"""Close the handles behind a multipart mapping (see _request)."""
|
||||
if not isinstance(files, dict):
|
||||
return
|
||||
for value in files.values():
|
||||
handle = value[1] if isinstance(value, tuple) and len(value) > 1 else value
|
||||
close = getattr(handle, "close", None)
|
||||
if close is not None:
|
||||
try:
|
||||
close()
|
||||
except Exception: # noqa: BLE001 - closing must never mask errors
|
||||
pass
|
||||
|
||||
|
||||
def get(user, path, params=None, timeout=30, require_auth=True):
|
||||
"""GET an e621 API path using the user's credentials.
|
||||
|
||||
Reads that e621 serves anonymously (searches, pools, tags) can pass
|
||||
require_auth=False; matching endpoints keep requiring credentials.
|
||||
|
||||
Raises E621NotFound for 404s and E621Error for everything else that isn't
|
||||
a 2xx, so callers never see requests exceptions.
|
||||
"""
|
||||
configured = credentials_configured(user)
|
||||
if require_auth and not configured:
|
||||
raise E621Error("Configure your e621 credentials in Account first.")
|
||||
base = (getattr(user, "e621_base_url", "") or "https://e621.net").rstrip("/")
|
||||
_wait_for_slot()
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{base}{path}",
|
||||
params=params,
|
||||
auth=(
|
||||
(user.e621_username, user.e621_api_key_plain)
|
||||
if configured
|
||||
else None
|
||||
),
|
||||
headers={"User-Agent": settings.USER_AGENT},
|
||||
timeout=timeout,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise E621Error(f"Could not reach e621: {exc}") from exc
|
||||
if response.status_code == 404:
|
||||
raise E621NotFound(f"e621 returned 404 for {path}")
|
||||
if response.status_code >= 400:
|
||||
raise E621Error(f"e621 replied {response.status_code} for {path}")
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise E621Error("e621 returned an unexpected response.") from exc
|
||||
return _request(
|
||||
user,
|
||||
"GET",
|
||||
path,
|
||||
params=params,
|
||||
timeout=timeout,
|
||||
require_auth=require_auth,
|
||||
)
|
||||
|
||||
|
||||
def find_post_by_md5(user, md5):
|
||||
"""The e621 post with this exact MD5, or None."""
|
||||
payload = get(user, "/posts.json", params={"tags": f"md5:{md5}", "limit": 1})
|
||||
payload = get(
|
||||
user,
|
||||
"/posts.json",
|
||||
params={"tags": f"md5:{md5}", "limit": 1},
|
||||
require_auth=False,
|
||||
)
|
||||
posts = payload.get("posts") if isinstance(payload, dict) else None
|
||||
if not posts:
|
||||
return None
|
||||
@@ -95,3 +237,87 @@ def fetch_post(user, post_id):
|
||||
if not isinstance(post, dict):
|
||||
raise E621Error("e621 returned an unexpected post payload.")
|
||||
return post
|
||||
|
||||
|
||||
def check_md5_batch(user, md5s):
|
||||
"""Look many MD5s up in one posts.json query.
|
||||
|
||||
Returns ``{md5: post}`` for the ones e621 knows; missing MD5s are simply
|
||||
absent. Works anonymously, like the original app's batch cache command.
|
||||
"""
|
||||
wanted = {str(value).strip().lower() for value in md5s if value}
|
||||
if not wanted:
|
||||
return {}
|
||||
values = sorted(wanted)
|
||||
payload = get(
|
||||
user,
|
||||
"/posts.json",
|
||||
params={
|
||||
"tags": f"md5:{','.join(values)}",
|
||||
"limit": min(len(values), 320),
|
||||
},
|
||||
require_auth=False,
|
||||
)
|
||||
posts = payload.get("posts") if isinstance(payload, dict) else None
|
||||
found = {}
|
||||
for post in posts or []:
|
||||
if not isinstance(post, dict):
|
||||
continue
|
||||
file_data = post.get("file") or {}
|
||||
md5 = str(file_data.get("md5") or "").strip().lower()
|
||||
if md5 in wanted:
|
||||
found[md5] = post
|
||||
return found
|
||||
|
||||
|
||||
def fetch_posts_by_ids(user, ids):
|
||||
"""Fetch many posts in one query (up to 320 ids). Missing ids are absent."""
|
||||
values = sorted({int(value) for value in ids})
|
||||
if not values:
|
||||
return []
|
||||
payload = get(
|
||||
user,
|
||||
"/posts.json",
|
||||
params={
|
||||
"tags": f"id:{','.join(str(value) for value in values)}",
|
||||
"limit": min(len(values), 320),
|
||||
},
|
||||
require_auth=False,
|
||||
)
|
||||
posts = payload.get("posts") if isinstance(payload, dict) else None
|
||||
return [post for post in posts or [] if isinstance(post, dict)]
|
||||
|
||||
|
||||
def iqdb_search(user, path, timeout=60):
|
||||
"""Reverse-image search one file through e621's IQDB endpoint.
|
||||
|
||||
Returns the legacy match list. Uses the extra-strict IQDB interval and
|
||||
retries through e621's throttle; raises E621RateLimited when it persists.
|
||||
"""
|
||||
path = Path(path)
|
||||
|
||||
def open_file():
|
||||
# A fresh handle per attempt: the stream is consumed by the request.
|
||||
return {"search[file]": (path.name, open(path, "rb"))}
|
||||
|
||||
payload = _request(
|
||||
user,
|
||||
"POST",
|
||||
"/iqdb_queries.json",
|
||||
files=open_file,
|
||||
timeout=timeout,
|
||||
require_auth=False,
|
||||
interval=IQDB_INTERVAL,
|
||||
)
|
||||
if isinstance(payload, list):
|
||||
return payload
|
||||
if isinstance(payload, dict):
|
||||
matches = payload.get("matches")
|
||||
if isinstance(matches, list):
|
||||
return matches
|
||||
# e621 answers its throttle with {"success": false, "message": ...}.
|
||||
message = payload.get("message") or payload.get("error")
|
||||
if message:
|
||||
raise E621RateLimited(str(message))
|
||||
raise E621Error("e621 returned an unexpected IQDB payload.")
|
||||
return []
|
||||
|
||||
@@ -18,6 +18,9 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
from apps.library.upload_pipeline import reap_stale_claims
|
||||
|
||||
reap_stale_claims()
|
||||
cutoff = timezone.now() - timedelta(hours=options["hours"])
|
||||
queryset = TempUpload.objects.filter(created_at__lt=cutoff)
|
||||
if not options["include_completed"]:
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Process staged uploads: e621 MD5, visual similarity, IQDB.
|
||||
|
||||
Runs synchronously, unlike the daemon thread the API starts on demand. Useful
|
||||
for tests, for a manual drain after an outage and for the scheduler if a
|
||||
deployment wants a periodic safety net.
|
||||
|
||||
python manage.py process_uploads # every user with queued work, once
|
||||
python manage.py process_uploads --user 3 # one user
|
||||
python manage.py process_uploads --loop 60 # keep draining every 60s
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.library.models import TempUpload
|
||||
from apps.library.upload_pipeline import (
|
||||
MAX_ATTEMPTS,
|
||||
OUTSTANDING_Q,
|
||||
WORK_STATUSES,
|
||||
reap_stale_claims,
|
||||
run_pipeline,
|
||||
)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Run the staged-upload pipeline (MD5 -> visual similarity -> IQDB)."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--user",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Only process this user id.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--loop",
|
||||
type=int,
|
||||
default=0,
|
||||
metavar="SECONDS",
|
||||
help="Keep draining every SECONDS seconds instead of exiting.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
interval = options["loop"] or 0
|
||||
while True:
|
||||
self.drain(user_id=options["user"])
|
||||
if interval <= 0:
|
||||
return
|
||||
time.sleep(interval)
|
||||
|
||||
def drain(self, user_id=None):
|
||||
reap_stale_claims()
|
||||
queryset = (
|
||||
TempUpload.objects.filter(status__in=WORK_STATUSES)
|
||||
.filter(OUTSTANDING_Q)
|
||||
.filter(attempts__lt=MAX_ATTEMPTS)
|
||||
)
|
||||
if user_id is not None:
|
||||
queryset = queryset.filter(user_id=user_id)
|
||||
user_ids = list(queryset.values_list("user_id", flat=True).distinct())
|
||||
if not user_ids:
|
||||
self.stdout.write("No staged uploads need processing.")
|
||||
return
|
||||
for value in user_ids:
|
||||
self.stdout.write(f"Processing staged uploads for user {value}...")
|
||||
run_pipeline(value)
|
||||
self.stdout.write(self.style.SUCCESS(f"Processed {len(user_ids)} queue(s)."))
|
||||
@@ -0,0 +1,81 @@
|
||||
# Generated by Django 6.1.1 on 2026-09-21 13:27
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def backfill_pipeline_checks(apps, schema_editor):
|
||||
"""Mark pre-pipeline rows as already MD5/visual-checked when they were.
|
||||
|
||||
Rows with an e621 post id (or already completed) clearly went through the
|
||||
MD5 phase; rows with visual matches went through the visual phase.
|
||||
Everything else stays unset so the new pipeline picks it up once after
|
||||
deploy — a re-check of stale pending uploads is the desired behavior.
|
||||
"""
|
||||
TempUpload = apps.get_model("library", "TempUpload")
|
||||
TempUpload.objects.filter(
|
||||
models.Q(e621_post_id__isnull=False) | models.Q(status="completed")
|
||||
).update(e621_checked_at=models.F("updated_at"))
|
||||
TempUpload.objects.filter(visual_matches__isnull=False).update(
|
||||
visual_checked_at=models.F("updated_at")
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('library', '0010_similaritycheck'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tempupload',
|
||||
name='attempts',
|
||||
field=models.PositiveSmallIntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tempupload',
|
||||
name='claimed_at',
|
||||
field=models.DateTimeField(blank=True, db_index=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tempupload',
|
||||
name='e621_checked_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tempupload',
|
||||
name='pipeline_error',
|
||||
field=models.TextField(blank=True, default=''),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tempupload',
|
||||
name='visual_checked_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='UploadRun',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('status', models.CharField(choices=[('idle', 'Idle'), ('running', 'Running'), ('paused', 'Paused'), ('error', 'Error')], default='idle', max_length=20)),
|
||||
('phase', models.CharField(blank=True, choices=[('', 'None'), ('md5', 'e621 MD5'), ('visual', 'Visual similarity'), ('iqdb', 'IQDB')], default='', max_length=20)),
|
||||
('total', models.IntegerField(default=0)),
|
||||
('processed', models.IntegerField(default=0)),
|
||||
('matched', models.IntegerField(default=0)),
|
||||
('failed', models.IntegerField(default=0)),
|
||||
('error', models.TextField(blank=True, default='')),
|
||||
('started_at', models.DateTimeField(blank=True, null=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='upload_run', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-updated_at'],
|
||||
},
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=backfill_pipeline_checks, reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 6.1.1 on 2026-09-23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def purge_completed_uploads(apps, schema_editor):
|
||||
"""Completed staged uploads are notifications, not records.
|
||||
|
||||
The board used to keep every auto-uploaded/indexed row until it was
|
||||
dismissed by hand, so they accumulated without bound (and bulk dismissal
|
||||
is capped at 1000 ids). Completions now live in a small per-run feed, so
|
||||
the old rows are removed here.
|
||||
"""
|
||||
TempUpload = apps.get_model("library", "TempUpload")
|
||||
for temp in TempUpload.objects.filter(status="completed").iterator():
|
||||
if temp.file:
|
||||
temp.file.delete(save=False)
|
||||
temp.delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("library", "0011_upload_pipeline"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="uploadrun",
|
||||
name="recent_completions",
|
||||
field=models.JSONField(blank=True, default=list),
|
||||
),
|
||||
migrations.RunPython(purge_completed_uploads, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -164,6 +164,16 @@ class TempUpload(models.Model):
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="temp_uploads",
|
||||
)
|
||||
# Background pipeline bookkeeping (see apps/library/upload_pipeline.py).
|
||||
# e621_checked_at/visual_checked_at are set once the corresponding phase
|
||||
# ran, so "never checked" and "checked, nothing found" stay distinct.
|
||||
e621_checked_at = models.DateTimeField(null=True, blank=True)
|
||||
visual_checked_at = models.DateTimeField(null=True, blank=True)
|
||||
# Worker claim for cross-process mutual exclusion; stale claims are
|
||||
# reaped and the row queued again.
|
||||
claimed_at = models.DateTimeField(null=True, blank=True, db_index=True)
|
||||
pipeline_error = models.TextField(blank=True, default="")
|
||||
attempts = models.PositiveSmallIntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -174,6 +184,66 @@ class TempUpload(models.Model):
|
||||
return f"{self.original_filename} ({self.status})"
|
||||
|
||||
|
||||
class UploadRun(models.Model):
|
||||
"""Per-user state of the background upload pipeline.
|
||||
|
||||
One row per user acts as the cheap status source the SPA polls and as a
|
||||
place for batch-level failures (broken e621 credentials, outages) that
|
||||
would otherwise be repeated on every row.
|
||||
"""
|
||||
|
||||
STATUS_IDLE = "idle"
|
||||
STATUS_RUNNING = "running"
|
||||
STATUS_PAUSED = "paused"
|
||||
STATUS_ERROR = "error"
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_IDLE, "Idle"),
|
||||
(STATUS_RUNNING, "Running"),
|
||||
(STATUS_PAUSED, "Paused"),
|
||||
(STATUS_ERROR, "Error"),
|
||||
]
|
||||
|
||||
PHASE_MD5 = "md5"
|
||||
PHASE_VISUAL = "visual"
|
||||
PHASE_IQDB = "iqdb"
|
||||
PHASE_CHOICES = [
|
||||
("", "None"),
|
||||
(PHASE_MD5, "e621 MD5"),
|
||||
(PHASE_VISUAL, "Visual similarity"),
|
||||
(PHASE_IQDB, "IQDB"),
|
||||
]
|
||||
|
||||
user = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="upload_run",
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=20, choices=STATUS_CHOICES, default=STATUS_IDLE
|
||||
)
|
||||
phase = models.CharField(
|
||||
max_length=20, choices=PHASE_CHOICES, blank=True, default=""
|
||||
)
|
||||
total = models.IntegerField(default=0)
|
||||
processed = models.IntegerField(default=0)
|
||||
matched = models.IntegerField(default=0)
|
||||
failed = models.IntegerField(default=0)
|
||||
error = models.TextField(blank=True, default="")
|
||||
# Rolling feed of recent completions for the upload board. Completed
|
||||
# staged uploads are deleted as soon as they are indexed; this only tells
|
||||
# the live page "filename -> J-x" while it watches. Bounded, never
|
||||
# dismissed, and ignored by fresh page loads.
|
||||
recent_completions = models.JSONField(default=list, blank=True)
|
||||
started_at = models.DateTimeField(null=True, blank=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-updated_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"Upload run for {self.user_id} ({self.status})"
|
||||
|
||||
|
||||
class DownloadTask(models.Model):
|
||||
"""A background 'Download to Library' job with progress tracking."""
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import signing
|
||||
from rest_framework import serializers
|
||||
|
||||
from .models import (
|
||||
@@ -20,6 +19,7 @@ from .services import (
|
||||
VIDEO_EXTENSIONS,
|
||||
signed_media_url,
|
||||
)
|
||||
from .signing_urls import sign_payload
|
||||
|
||||
|
||||
class MediaLocationSerializer(serializers.ModelSerializer):
|
||||
@@ -145,6 +145,12 @@ class TempUploadSerializer(serializers.ModelSerializer):
|
||||
library_j_id = serializers.SerializerMethodField()
|
||||
file_url = serializers.SerializerMethodField()
|
||||
preview_url = serializers.SerializerMethodField()
|
||||
md5_checked = serializers.SerializerMethodField()
|
||||
visual_checked = serializers.SerializerMethodField()
|
||||
iqdb_checked = serializers.SerializerMethodField()
|
||||
processing = serializers.SerializerMethodField()
|
||||
similar_count = serializers.SerializerMethodField()
|
||||
visual_matches = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = TempUpload
|
||||
@@ -165,6 +171,13 @@ class TempUploadSerializer(serializers.ModelSerializer):
|
||||
"library_j_id",
|
||||
"file_url",
|
||||
"preview_url",
|
||||
"pipeline_error",
|
||||
"attempts",
|
||||
"md5_checked",
|
||||
"visual_checked",
|
||||
"iqdb_checked",
|
||||
"processing",
|
||||
"similar_count",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
@@ -187,9 +200,9 @@ class TempUploadSerializer(serializers.ModelSerializer):
|
||||
user = self._request_user()
|
||||
if user is None:
|
||||
return None
|
||||
signature = signing.dumps(
|
||||
signature = sign_payload(
|
||||
{"temp": str(obj.id), "user": user.id},
|
||||
salt=UPLOAD_FILE_SALT,
|
||||
UPLOAD_FILE_SALT,
|
||||
)
|
||||
url = f"/api/uploads/{obj.id}/file/?sig={signature}"
|
||||
request = self.context.get("request")
|
||||
@@ -215,6 +228,89 @@ class TempUploadSerializer(serializers.ModelSerializer):
|
||||
item, user, action, request=self.context.get("request")
|
||||
)
|
||||
|
||||
def get_md5_checked(self, obj):
|
||||
return obj.e621_checked_at is not None
|
||||
|
||||
def get_visual_checked(self, obj):
|
||||
return obj.visual_checked_at is not None
|
||||
|
||||
def get_iqdb_checked(self, obj):
|
||||
return obj.iqdb_data is not None
|
||||
|
||||
def get_processing(self, obj):
|
||||
return obj.claimed_at is not None
|
||||
|
||||
def get_similar_count(self, obj):
|
||||
return len(obj.iqdb_data or []) + len(obj.visual_matches or [])
|
||||
|
||||
@staticmethod
|
||||
def _visual_item_id(entry):
|
||||
if not isinstance(entry, dict):
|
||||
return None
|
||||
item_id = entry.get("item_id")
|
||||
if item_id is None:
|
||||
j_id = str(entry.get("j_id") or "")
|
||||
if j_id.upper().startswith("J-"):
|
||||
j_id = j_id[2:]
|
||||
item_id = j_id if j_id.isdigit() else None
|
||||
try:
|
||||
return int(item_id)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def get_visual_matches(self, obj):
|
||||
"""Rebuild match rows with fresh signed thumbnail URLs.
|
||||
|
||||
Storing the signed URL meant it aged out (or came from an older
|
||||
signing scheme) and the "Already in your library" grid showed broken
|
||||
tiles. The stored rows only carry the item reference now.
|
||||
"""
|
||||
entries = obj.visual_matches or []
|
||||
if not entries:
|
||||
return entries
|
||||
wanted = {}
|
||||
for entry in entries:
|
||||
item_id = self._visual_item_id(entry)
|
||||
if item_id is not None:
|
||||
wanted[item_id] = None
|
||||
items = MediaItem.objects.in_bulk(list(wanted))
|
||||
user = self._request_user()
|
||||
request = self.context.get("request")
|
||||
matches = []
|
||||
for entry in entries:
|
||||
item_id = self._visual_item_id(entry)
|
||||
item = items.get(item_id) if item_id is not None else None
|
||||
if item is None:
|
||||
continue
|
||||
matches.append(
|
||||
{
|
||||
"j_id": f"J-{item.id}",
|
||||
"filename": entry.get("filename") or item.md5,
|
||||
"similarity": entry.get("similarity"),
|
||||
"thumbnail_url": signed_media_url(
|
||||
item, user, "thumbnail", request=request
|
||||
),
|
||||
}
|
||||
)
|
||||
return matches
|
||||
|
||||
|
||||
class TempUploadListSerializer(TempUploadSerializer):
|
||||
"""Compact staged-upload row for the board and the status polling.
|
||||
|
||||
Drops the heavy post/IQDB payloads (the metadata modal fetches the full
|
||||
row) while keeping the pipeline flags the board renders per tile.
|
||||
"""
|
||||
|
||||
class Meta(TempUploadSerializer.Meta):
|
||||
fields = [
|
||||
field
|
||||
for field in TempUploadSerializer.Meta.fields
|
||||
if field
|
||||
not in {"e621_data", "iqdb_data", "visual_matches", "custom_tags", "custom_notes"}
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class DownloadTaskSerializer(serializers.ModelSerializer):
|
||||
task_id = serializers.UUIDField(source="id", read_only=True)
|
||||
@@ -295,9 +391,9 @@ class SimilarityCheckSerializer(serializers.ModelSerializer):
|
||||
user = self._request_user()
|
||||
if user is None or not obj.file:
|
||||
return None
|
||||
signature = signing.dumps(
|
||||
signature = sign_payload(
|
||||
{"check": str(obj.id), "user": user.id},
|
||||
salt=UPLOAD_FILE_SALT,
|
||||
UPLOAD_FILE_SALT,
|
||||
)
|
||||
url = f"/api/similarity/{obj.id}/file/?sig={signature}"
|
||||
request = self.context.get("request")
|
||||
|
||||
@@ -6,16 +6,21 @@ import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import imagehash
|
||||
from django.conf import settings
|
||||
from django.core import signing
|
||||
from django.http import FileResponse, Http404, HttpResponse
|
||||
from django.utils.cache import get_conditional_response
|
||||
from django.utils.http import http_date
|
||||
from django.utils.text import get_valid_filename
|
||||
from PIL import Image
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
from .models import MediaItem, MediaLocation
|
||||
from .signing_urls import sign_payload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,6 +42,11 @@ UPLOAD_FILE_SALT = "j621.upload-file"
|
||||
MEDIA_FILE_SALT = "j621.media-file"
|
||||
CHUNK_SIZE = 1024 * 1024
|
||||
RANGE_RE = re.compile(r"bytes=(\d*)-(\d*)$")
|
||||
# Versioned media URLs are immutable, so they may sit in the browser cache for
|
||||
# as long as the signature is guaranteed to stay valid (7 days).
|
||||
MEDIA_CACHE_SECONDS = 6 * 86400
|
||||
# Staged uploads and similarity files can be deleted at any moment.
|
||||
TEMP_CACHE_SECONDS = 3600
|
||||
|
||||
|
||||
def compute_md5(path):
|
||||
@@ -78,14 +88,24 @@ def signed_media_url(item, user, action="raw", request=None):
|
||||
|
||||
With a ``request`` the URL is absolute, so the SPA also works when it is
|
||||
served from a different origin; without one it stays relative.
|
||||
|
||||
The ``v`` parameter is the item's MD5: it busts the browser cache exactly
|
||||
when the file is replaced (the optimize flow rewrites files under the same
|
||||
J-ID), which is what lets the URL be cached for days instead of re-minted
|
||||
on every response.
|
||||
"""
|
||||
path = f"/api/files/J-{item.id}/{action}/"
|
||||
params = {}
|
||||
if user is not None and getattr(user, "is_authenticated", False):
|
||||
signature = signing.dumps(
|
||||
{"item": item.id, "user": user.id, "action": action},
|
||||
salt=MEDIA_FILE_SALT,
|
||||
)
|
||||
path = f"{path}?sig={signature}"
|
||||
params = {
|
||||
"v": item.md5,
|
||||
"sig": sign_payload(
|
||||
{"item": item.id, "user": user.id, "action": action},
|
||||
MEDIA_FILE_SALT,
|
||||
),
|
||||
}
|
||||
if params:
|
||||
path = f"{path}?{urlencode(params)}"
|
||||
if request is None:
|
||||
return path
|
||||
return request.build_absolute_uri(path)
|
||||
@@ -207,12 +227,36 @@ class RangeFileWrapper:
|
||||
self.file.close()
|
||||
|
||||
|
||||
def serve_file(request, path, download=False):
|
||||
"""Serve a file with HTTP range support (needed for video seeking)."""
|
||||
def _apply_cache_headers(response, cache_control, etag, mtime):
|
||||
response["Cache-Control"] = cache_control
|
||||
response["ETag"] = etag
|
||||
response["Last-Modified"] = http_date(mtime)
|
||||
return response
|
||||
|
||||
|
||||
def serve_file(request, path, download=False, *, max_age=TEMP_CACHE_SECONDS, immutable=False):
|
||||
"""Serve a file with HTTP range support (needed for video seeking).
|
||||
|
||||
Responses carry validators (ETag/Last-Modified) and a private
|
||||
``Cache-Control`` so browsers reuse media instead of re-downloading it on
|
||||
every SPA poll. ``max_age``/``immutable`` are chosen by the caller: versioned
|
||||
library media can be cached hard, staged files only briefly.
|
||||
"""
|
||||
path = Path(path)
|
||||
if not path.is_file():
|
||||
raise Http404
|
||||
size = path.stat().st_size
|
||||
stat = path.stat()
|
||||
size = stat.st_size
|
||||
etag = f'W/"{size:x}-{stat.st_mtime_ns:x}"'
|
||||
last_modified = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
|
||||
conditional = get_conditional_response(
|
||||
request, etag=etag, last_modified=last_modified
|
||||
)
|
||||
if conditional is not None:
|
||||
return conditional
|
||||
cache_control = f"private, max-age={int(max_age)}"
|
||||
if immutable:
|
||||
cache_control += ", immutable"
|
||||
content_type = mimetypes.guess_type(str(path))[0] or "application/octet-stream"
|
||||
range_header = request.headers.get("Range", "").strip()
|
||||
if range_header:
|
||||
@@ -240,7 +284,9 @@ def serve_file(request, path, download=False):
|
||||
response["Content-Length"] = str(length)
|
||||
response["Content-Range"] = f"bytes {start}-{end}/{size}"
|
||||
response["Accept-Ranges"] = "bytes"
|
||||
return response
|
||||
return _apply_cache_headers(
|
||||
response, cache_control, etag, stat.st_mtime
|
||||
)
|
||||
response = FileResponse(
|
||||
open(path, "rb"),
|
||||
content_type=content_type,
|
||||
@@ -248,7 +294,7 @@ def serve_file(request, path, download=False):
|
||||
filename=path.name,
|
||||
)
|
||||
response["Accept-Ranges"] = "bytes"
|
||||
return response
|
||||
return _apply_cache_headers(response, cache_control, etag, stat.st_mtime)
|
||||
|
||||
|
||||
class DownloadCancelled(Exception):
|
||||
@@ -436,15 +482,38 @@ def sanitize_iqdb_results(results):
|
||||
return cleaned
|
||||
|
||||
|
||||
def _thumbnail_is_fresh(target, path):
|
||||
"""True when the cached thumbnail exists and is at least as new as source."""
|
||||
try:
|
||||
stat = target.stat()
|
||||
if stat.st_size <= 0:
|
||||
return False
|
||||
return stat.st_mtime >= os.path.getmtime(path)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _thumbs_dir():
|
||||
thumbs_dir = Path(settings.MEDIA_ROOT) / "thumbs"
|
||||
thumbs_dir.mkdir(parents=True, exist_ok=True)
|
||||
return thumbs_dir
|
||||
|
||||
|
||||
def generate_video_thumbnail(md5, path):
|
||||
"""Extract a JPEG thumbnail from a video, cached under MEDIA_ROOT/thumbs."""
|
||||
if not shutil.which("ffmpeg"):
|
||||
return None
|
||||
thumbs_dir = Path(settings.MEDIA_ROOT) / "thumbs"
|
||||
thumbs_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
thumbs_dir = _thumbs_dir()
|
||||
except OSError:
|
||||
logger.exception("Could not create the thumbnail folder")
|
||||
return None
|
||||
target = thumbs_dir / f"{md5}.jpg"
|
||||
if target.exists() and target.stat().st_mtime >= os.path.getmtime(path):
|
||||
if _thumbnail_is_fresh(target, path):
|
||||
return target
|
||||
# Write beside the target and move it into place, so a concurrent request
|
||||
# can never read a half-written JPEG.
|
||||
temp = thumbs_dir / f".{md5}.{uuid.uuid4().hex}.part.jpg"
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
@@ -458,10 +527,63 @@ def generate_video_thumbnail(md5, path):
|
||||
"scale=480:-2",
|
||||
"-loglevel",
|
||||
"error",
|
||||
str(target),
|
||||
str(temp),
|
||||
]
|
||||
try:
|
||||
subprocess.run(command, check=True, capture_output=True, timeout=60)
|
||||
os.replace(temp, target)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
logger.exception("Could not build a video thumbnail for %s", path)
|
||||
temp.unlink(missing_ok=True)
|
||||
return None
|
||||
return target if target.exists() else None
|
||||
|
||||
|
||||
def generate_image_thumbnail(md5, path):
|
||||
"""Downscale an image, cached under MEDIA_ROOT/thumbs like video thumbs.
|
||||
|
||||
The thumbnail action used to serve full-size originals for images; a
|
||||
cached 480px JPEG keeps the library grid light without touching the
|
||||
original file. Returns ``None`` when the source is missing or Pillow
|
||||
cannot decode it, so callers can fall back to the original.
|
||||
"""
|
||||
try:
|
||||
thumbs_dir = _thumbs_dir()
|
||||
except OSError:
|
||||
logger.exception("Could not create the thumbnail folder")
|
||||
return None
|
||||
target = thumbs_dir / f"{md5}.jpg"
|
||||
if _thumbnail_is_fresh(target, path):
|
||||
return target
|
||||
temp = thumbs_dir / f".{md5}.{uuid.uuid4().hex}.part.jpg"
|
||||
try:
|
||||
with Image.open(path) as image:
|
||||
# Animated formats: the first frame is the preview.
|
||||
image.seek(0)
|
||||
frame = ImageOps.exif_transpose(image) or image
|
||||
frame = frame.convert("RGB")
|
||||
frame.thumbnail((480, 480))
|
||||
frame.save(temp, "JPEG", quality=82, optimize=True)
|
||||
os.replace(temp, target)
|
||||
except Exception: # noqa: BLE001 - previews must never break serving
|
||||
logger.exception("Could not build an image thumbnail for %s", path)
|
||||
temp.unlink(missing_ok=True)
|
||||
return None
|
||||
return target if target.exists() else None
|
||||
|
||||
|
||||
def ensure_thumbnail(item):
|
||||
"""Generate an item's cached thumbnail if it is missing or stale.
|
||||
|
||||
Warming thumbnails when a file is indexed keeps image decoding out of the
|
||||
request path, where the upload pipeline's hashing used to starve it.
|
||||
"""
|
||||
location = item.locations.first()
|
||||
if location is None:
|
||||
return None
|
||||
path = Path(location.path)
|
||||
if not path.is_file():
|
||||
return None
|
||||
if path.suffix.lower() in VIDEO_EXTENSIONS:
|
||||
return generate_video_thumbnail(item.md5, path)
|
||||
return generate_image_thumbnail(item.md5, path)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Stable, expiring signatures for media URLs.
|
||||
|
||||
The SPA loads media with ``<img>``/``<video>`` tags, which cannot send the
|
||||
API's ``Authorization`` header, so those URLs carry a signature instead. The
|
||||
signature has to be *stable*: a URL that changes on every response makes the
|
||||
browser treat every refetch as a new resource and re-download the file.
|
||||
|
||||
URLs are signed with a plain ``Signer`` (no per-second timestamp) plus an
|
||||
explicit ``exp`` claim quantized to a bucket, so every request inside a bucket
|
||||
mints the exact same URL. The URL rotates once per bucket and is valid for at
|
||||
least ``URL_TTL_SECONDS`` and at most ``URL_TTL_SECONDS + URL_BUCKET_SECONDS``.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
from django.core import signing
|
||||
|
||||
URL_TTL_SECONDS = 7 * 86400
|
||||
URL_BUCKET_SECONDS = 24 * 3600
|
||||
_BUCKETS = URL_TTL_SECONDS // URL_BUCKET_SECONDS
|
||||
|
||||
|
||||
def _expiry(now=None):
|
||||
current = time.time() if now is None else now
|
||||
bucket = int(current // URL_BUCKET_SECONDS)
|
||||
return (bucket + _BUCKETS + 1) * URL_BUCKET_SECONDS
|
||||
|
||||
|
||||
def sign_payload(payload, salt, now=None):
|
||||
"""Sign a payload with a stable, bucket-quantized expiry."""
|
||||
return signing.Signer(salt=salt).sign_object(
|
||||
{**payload, "exp": _expiry(now)}
|
||||
)
|
||||
|
||||
|
||||
def load_payload(signature, salt, legacy_max_age=86400):
|
||||
"""Verify a signed payload; ``None`` when missing, tampered with or expired.
|
||||
|
||||
Legacy ``TimestampSigner`` values are still accepted for one release.
|
||||
Detect them by their extra separator (``payload:timestamp:signature``):
|
||||
a plain ``Signer`` accepts the HMAC a ``TimestampSigner`` computed over
|
||||
``payload:timestamp`` and then chokes on the embedded timestamp while
|
||||
decoding the JSON payload, which used to surface as a 500.
|
||||
"""
|
||||
if not signature:
|
||||
return None
|
||||
if signature.count(":") >= 2:
|
||||
try:
|
||||
return signing.TimestampSigner(salt=salt).unsign_object(
|
||||
signature, max_age=legacy_max_age
|
||||
)
|
||||
except (signing.BadSignature, ValueError):
|
||||
return None
|
||||
try:
|
||||
data = signing.Signer(salt=salt).unsign_object(signature)
|
||||
except (signing.BadSignature, ValueError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
try:
|
||||
expired = int(data.get("exp", 0)) < time.time()
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None if expired else data
|
||||
@@ -11,7 +11,6 @@ from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import signing
|
||||
from django.utils import timezone
|
||||
from rest_framework import mixins, status, viewsets
|
||||
from rest_framework.decorators import action
|
||||
@@ -22,6 +21,7 @@ from rest_framework.response import Response
|
||||
from . import services
|
||||
from .models import MediaItem, SimilarityCheck
|
||||
from .serializers import SimilarityCheckSerializer
|
||||
from .signing_urls import load_payload
|
||||
from .tools import item_brief
|
||||
from .uploads import find_library_matches
|
||||
|
||||
@@ -136,7 +136,12 @@ class SimilarityCheckViewSet(
|
||||
self.get_serializer(check).data, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get", "head"], permission_classes=[AllowAny])
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["get", "head"],
|
||||
permission_classes=[AllowAny],
|
||||
throttle_classes=[],
|
||||
)
|
||||
def file(self, request, pk=None):
|
||||
"""Serve the temp file; accepts a signed URL like staged uploads."""
|
||||
check = None
|
||||
@@ -144,14 +149,7 @@ class SimilarityCheckViewSet(
|
||||
check = self.get_queryset().filter(pk=pk).first()
|
||||
else:
|
||||
signature = request.query_params.get("sig")
|
||||
payload = None
|
||||
if signature:
|
||||
try:
|
||||
payload = signing.loads(
|
||||
signature, salt=services.UPLOAD_FILE_SALT, max_age=86400
|
||||
)
|
||||
except signing.BadSignature:
|
||||
payload = None
|
||||
payload = load_payload(signature, services.UPLOAD_FILE_SALT) if signature else None
|
||||
if payload and payload.get("check") == str(pk):
|
||||
check = SimilarityCheck.objects.filter(pk=pk).first()
|
||||
if check is None or not check.file:
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
"""The server-side e621 client: batching, retries and IQDB stream handling."""
|
||||
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest import mock
|
||||
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.library import e621
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, status_code, payload=None, headers=None):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.headers = headers or {}
|
||||
|
||||
def json(self):
|
||||
if self._payload is None:
|
||||
raise ValueError("not json")
|
||||
return self._payload
|
||||
|
||||
|
||||
class E621ClientTests(SimpleTestCase):
|
||||
def test_iqdb_search_reopens_the_file_on_retry(self):
|
||||
bodies = []
|
||||
|
||||
def fake_request(method, url, **kwargs):
|
||||
handle = kwargs["files"]["search[file]"][1]
|
||||
bodies.append(handle.read())
|
||||
if len(bodies) == 1:
|
||||
return FakeResponse(
|
||||
429, {"success": False, "message": "Throttled"}
|
||||
)
|
||||
return FakeResponse(200, [{"post_id": 1, "score": 90.0}])
|
||||
|
||||
with TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "x.png"
|
||||
path.write_bytes(b"image-bytes")
|
||||
with mock.patch.object(
|
||||
e621.requests, "request", side_effect=fake_request
|
||||
), mock.patch.object(e621.time, "sleep"), mock.patch.object(
|
||||
e621, "_wait_for_slot"
|
||||
):
|
||||
result = e621.iqdb_search(None, path)
|
||||
|
||||
# Both attempts must send the full body, not the consumed handle.
|
||||
self.assertEqual(bodies, [b"image-bytes", b"image-bytes"])
|
||||
self.assertEqual(result, [{"post_id": 1, "score": 90.0}])
|
||||
|
||||
def test_check_md5_batch_keys_by_md5(self):
|
||||
payload = {
|
||||
"posts": [
|
||||
{"id": 5, "file": {"md5": "a" * 32}},
|
||||
{"id": 6, "file": {"md5": "b" * 32}},
|
||||
]
|
||||
}
|
||||
with mock.patch.object(e621, "get", return_value=payload) as getter:
|
||||
found = e621.check_md5_batch(None, ["A" * 32, "b" * 32])
|
||||
self.assertEqual(set(found), {"a" * 32, "b" * 32})
|
||||
params = getter.call_args.kwargs["params"]
|
||||
self.assertTrue(params["tags"].startswith("md5:"))
|
||||
self.assertEqual(params["limit"], 2)
|
||||
|
||||
def test_auth_errors_are_not_retried(self):
|
||||
calls = []
|
||||
|
||||
def fake_request(*args, **kwargs):
|
||||
calls.append(1)
|
||||
return FakeResponse(403, {"error": "nope"})
|
||||
|
||||
with mock.patch.object(
|
||||
e621.requests, "request", side_effect=fake_request
|
||||
), mock.patch.object(e621, "_wait_for_slot"):
|
||||
with self.assertRaises(e621.E621AuthError):
|
||||
e621._request(None, "GET", "/posts.json", require_auth=False)
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
def test_load_shedding_html_raises_rate_limited_after_retries(self):
|
||||
with mock.patch.object(
|
||||
e621.requests,
|
||||
"request",
|
||||
return_value=FakeResponse(200, None),
|
||||
), mock.patch.object(e621.time, "sleep"), mock.patch.object(
|
||||
e621, "_wait_for_slot"
|
||||
):
|
||||
with self.assertRaises(e621.E621RateLimited):
|
||||
e621._request(
|
||||
None, "GET", "/posts.json", require_auth=False, attempts=2
|
||||
)
|
||||
|
||||
def test_fetch_posts_by_ids_queries_with_id_tag(self):
|
||||
with mock.patch.object(
|
||||
e621, "get", return_value={"posts": [{"id": 9}]}
|
||||
) as getter:
|
||||
posts = e621.fetch_posts_by_ids(None, [9])
|
||||
self.assertEqual(posts, [{"id": 9}])
|
||||
params = getter.call_args.kwargs["params"]
|
||||
self.assertEqual(params["tags"], "id:9")
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Signed media URLs must be stable, versioned and cacheable.
|
||||
|
||||
Regression: signatures embedded the current second, so every API response
|
||||
re-minted every URL and browsers re-downloaded each image on every poll; the
|
||||
file responses also carried no cache headers at all.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import signing
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client, TestCase, override_settings
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from apps.library import services
|
||||
from apps.library.models import MediaItem, MediaLocation, TempUpload
|
||||
from apps.library.signing_urls import load_payload, sign_payload
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
TINY_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
def png_bytes(width=1200, height=800, color=(20, 120, 200)):
|
||||
buffer = io.BytesIO()
|
||||
Image.new("RGB", (width, height), color).save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
class MediaCacheTests(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls._tmp = tempfile.mkdtemp(prefix="j621-cache-")
|
||||
cls._media = Path(cls._tmp) / "media"
|
||||
cls._watched = cls._media / "library"
|
||||
cls._watched.mkdir(parents=True, exist_ok=True)
|
||||
cls._settings = override_settings(
|
||||
MEDIA_ROOT=str(cls._media), WATCHED_FOLDER=str(cls._watched)
|
||||
)
|
||||
cls._settings.enable()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls._settings.disable()
|
||||
shutil.rmtree(cls._tmp, ignore_errors=True)
|
||||
super().tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="cache-uploader", password="cache-pass-123456"
|
||||
)
|
||||
self.user.role = "uploader"
|
||||
self.user.save(update_fields=["role"])
|
||||
self.token = Token.objects.create(user=self.user).key
|
||||
payload = png_bytes()
|
||||
path = self._watched / "cache-image.png"
|
||||
path.write_bytes(payload)
|
||||
self.item = MediaItem.objects.create(
|
||||
md5=hashlib.md5(payload).hexdigest(), size=len(payload)
|
||||
)
|
||||
MediaLocation.objects.create(
|
||||
item=self.item, path=str(path), rel_path=path.name, mtime=time.time()
|
||||
)
|
||||
self.client = Client()
|
||||
|
||||
def signed(self, action):
|
||||
return {
|
||||
"sig": sign_payload(
|
||||
{"item": self.item.id, "user": self.user.id, "action": action},
|
||||
services.MEDIA_FILE_SALT,
|
||||
)
|
||||
}
|
||||
|
||||
def test_media_response_carries_cache_headers(self):
|
||||
response = self.client.get(
|
||||
f"/api/files/J-{self.item.id}/raw/", self.signed("raw")
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("private", response["Cache-Control"])
|
||||
self.assertIn(
|
||||
f"max-age={services.MEDIA_CACHE_SECONDS}", response["Cache-Control"]
|
||||
)
|
||||
self.assertIn("immutable", response["Cache-Control"])
|
||||
self.assertTrue(response["ETag"])
|
||||
self.assertTrue(response["Last-Modified"])
|
||||
|
||||
def test_media_revalidation_returns_304(self):
|
||||
url = f"/api/files/J-{self.item.id}/raw/"
|
||||
first = self.client.get(url, self.signed("raw"))
|
||||
second = self.client.get(
|
||||
url, self.signed("raw"), HTTP_IF_NONE_MATCH=first["ETag"]
|
||||
)
|
||||
self.assertEqual(second.status_code, 304)
|
||||
self.assertEqual(second.content, b"")
|
||||
|
||||
def test_image_thumbnail_is_generated_and_reused(self):
|
||||
url = f"/api/files/J-{self.item.id}/thumbnail/"
|
||||
response = self.client.get(url, self.signed("thumbnail"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["Content-Type"], "image/jpeg")
|
||||
thumb = self._media / "thumbs" / f"{self.item.md5}.jpg"
|
||||
self.assertTrue(thumb.exists())
|
||||
with Image.open(thumb) as image:
|
||||
self.assertLessEqual(max(image.size), 480)
|
||||
before = thumb.stat().st_mtime_ns
|
||||
self.client.get(url, self.signed("thumbnail"))
|
||||
self.assertEqual(thumb.stat().st_mtime_ns, before)
|
||||
|
||||
def test_ensure_thumbnail_reuses_the_cache(self):
|
||||
first = services.ensure_thumbnail(self.item)
|
||||
self.assertIsNotNone(first)
|
||||
self.assertTrue(first.exists())
|
||||
mtime = first.stat().st_mtime_ns
|
||||
second = services.ensure_thumbnail(self.item)
|
||||
self.assertEqual(second, first)
|
||||
self.assertEqual(second.stat().st_mtime_ns, mtime)
|
||||
|
||||
def test_thumbnail_of_a_missing_source_does_not_error(self):
|
||||
"""Regression: getmtime() on a vanished source used to raise a 500."""
|
||||
thumbs = self._media / "thumbs"
|
||||
thumbs.mkdir(parents=True, exist_ok=True)
|
||||
(thumbs / f"{self.item.md5}.jpg").write_bytes(b"stale")
|
||||
Path(self.item.locations.first().path).unlink()
|
||||
self.assertIsNone(services.ensure_thumbnail(self.item))
|
||||
response = self.client.get(
|
||||
f"/api/files/J-{self.item.id}/thumbnail/", self.signed("thumbnail")
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_staged_files_cache_briefly(self):
|
||||
temp = TempUpload.objects.create(
|
||||
user=self.user,
|
||||
file=SimpleUploadedFile("staged.png", TINY_PNG, content_type="image/png"),
|
||||
original_filename="staged.png",
|
||||
md5=hashlib.md5(b"staged").hexdigest(),
|
||||
size=len(TINY_PNG),
|
||||
)
|
||||
signature = sign_payload(
|
||||
{"temp": str(temp.id), "user": self.user.id}, services.UPLOAD_FILE_SALT
|
||||
)
|
||||
response = self.client.get(
|
||||
f"/api/uploads/{temp.id}/file/", {"sig": signature}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
f"max-age={services.TEMP_CACHE_SECONDS}", response["Cache-Control"]
|
||||
)
|
||||
self.assertNotIn("immutable", response["Cache-Control"])
|
||||
|
||||
def test_legacy_timestamp_signatures_are_accepted(self):
|
||||
"""URLs minted before the stable scheme must not 500.
|
||||
|
||||
A TimestampSigner HMAC also passes a plain Signer's check, so the
|
||||
embedded timestamp used to reach the JSON decoder and blow up.
|
||||
"""
|
||||
payload = {"item": self.item.id, "user": self.user.id, "action": "raw"}
|
||||
legacy = signing.dumps(payload, salt=services.MEDIA_FILE_SALT)
|
||||
self.assertEqual(
|
||||
load_payload(legacy, services.MEDIA_FILE_SALT)["item"], self.item.id
|
||||
)
|
||||
response = self.client.get(
|
||||
f"/api/files/J-{self.item.id}/raw/", {"sig": legacy}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_expired_and_malformed_signatures_return_none(self):
|
||||
payload = {"item": self.item.id, "user": self.user.id, "action": "raw"}
|
||||
with mock.patch.object(signing, "time") as clock:
|
||||
clock.time.return_value = time.time() - 3 * 86400
|
||||
expired = signing.dumps(payload, salt=services.MEDIA_FILE_SALT)
|
||||
self.assertIsNone(load_payload(expired, services.MEDIA_FILE_SALT))
|
||||
self.assertIsNone(load_payload("bogus", services.MEDIA_FILE_SALT))
|
||||
self.assertIsNone(load_payload("a:b", services.MEDIA_FILE_SALT))
|
||||
self.assertIsNone(load_payload("", services.MEDIA_FILE_SALT))
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Tests for the random image endpoint (`/api/random/`, `/random`).
|
||||
|
||||
Used by the SPA's Random page and by shell greeting scripts (fish_greeting
|
||||
with fastfetch), so the response contract matters:
|
||||
* JSON with an absolute, directly fetchable URL,
|
||||
* signed for authenticated callers (image viewers send no headers),
|
||||
* fastfetch mode restricted to png/jpg/gif,
|
||||
* rating filters and guest visibility applied server-side.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.test import Client, TestCase, override_settings
|
||||
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from apps.library.models import MediaItem, MediaLocation
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp", "apng"}
|
||||
FASTFETCH_EXTENSIONS = {"png", "jpg", "jpeg", "gif"}
|
||||
|
||||
|
||||
class RandomItemTests(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls._tmp = tempfile.mkdtemp(prefix="j621-random-")
|
||||
cls._watched = Path(cls._tmp) / "library"
|
||||
cls._watched.mkdir(parents=True, exist_ok=True)
|
||||
cls._settings = override_settings(
|
||||
MEDIA_ROOT=cls._tmp, WATCHED_FOLDER=str(cls._watched)
|
||||
)
|
||||
cls._settings.enable()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls._settings.disable()
|
||||
shutil.rmtree(cls._tmp, ignore_errors=True)
|
||||
super().tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="random-user", password="random-pass-123456"
|
||||
)
|
||||
token = Token.objects.create(user=self.user)
|
||||
self.authed = Client()
|
||||
self.authed.defaults["HTTP_AUTHORIZATION"] = f"Token {token.key}"
|
||||
self.guest = Client()
|
||||
|
||||
def make_item(self, label, extension, rating, *, hidden=False):
|
||||
path = self._watched / f"{label}.{extension}"
|
||||
path.write_bytes(b"random-" + label.encode())
|
||||
item = MediaItem.objects.create(
|
||||
md5=hashlib.md5(label.encode()).hexdigest(),
|
||||
size=path.stat().st_size,
|
||||
rating=rating,
|
||||
uploaded_by=self.user,
|
||||
)
|
||||
MediaLocation.objects.create(
|
||||
item=item, path=str(path), rel_path=path.name, mtime=time.time()
|
||||
)
|
||||
if hidden:
|
||||
MediaItem.objects.filter(pk=item.pk).update(hidden_from_guests=True)
|
||||
return item
|
||||
|
||||
def test_returns_image_with_signed_absolute_url(self):
|
||||
item = self.make_item("plain", "png", "s")
|
||||
response = self.authed.get("/api/random/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertEqual(data["j_id"], f"J-{item.id}")
|
||||
self.assertEqual(data["extension"], "png")
|
||||
self.assertEqual(data["kind"], "image")
|
||||
self.assertEqual(data["rating"], "s")
|
||||
self.assertTrue(data["url"].startswith("http"))
|
||||
self.assertIn("sig=", data["url"])
|
||||
self.assertIn("download=1", data["download_url"])
|
||||
self.assertFalse(data["fastfetch"])
|
||||
|
||||
def test_guest_url_is_unsigned_and_still_serves(self):
|
||||
self.make_item("guest", "jpg", "s")
|
||||
data = self.guest.get("/api/random/").json()
|
||||
self.assertNotIn("sig=", data["url"])
|
||||
path = data["url"].replace("http://testserver", "")
|
||||
self.assertEqual(self.guest.get(path).status_code, 200)
|
||||
|
||||
def test_default_mode_returns_images_only(self):
|
||||
self.make_item("movie", "mp4", "s")
|
||||
self.make_item("picture", "webp", "s")
|
||||
for _ in range(10):
|
||||
extension = self.authed.get("/api/random/").json()["extension"]
|
||||
self.assertIn(extension, IMAGE_EXTENSIONS)
|
||||
|
||||
def test_fastfetch_mode_flag_and_user_agent_restrict_formats(self):
|
||||
self.make_item("movie", "mp4", "s")
|
||||
self.make_item("modern", "webp", "s")
|
||||
self.make_item("picture", "png", "s")
|
||||
self.make_item("animation", "gif", "s")
|
||||
|
||||
attempts = [("flag", {"fastfetch": "1"}, {}), ("ua", {}, {"HTTP_USER_AGENT": "fastfetch/2.18.1"})]
|
||||
for label, params, headers in attempts:
|
||||
for _ in range(15):
|
||||
response = self.authed.get("/api/random/", params, **headers)
|
||||
self.assertEqual(response.status_code, 200, label)
|
||||
data = response.json()
|
||||
self.assertIn(data["extension"], FASTFETCH_EXTENSIONS, label)
|
||||
self.assertTrue(data["fastfetch"], label)
|
||||
|
||||
def test_rating_filter(self):
|
||||
self.make_item("safe", "png", "s")
|
||||
explicit = self.make_item("explicit", "png", "e")
|
||||
for _ in range(10):
|
||||
data = self.authed.get("/api/random/", {"rating": "e"}).json()
|
||||
self.assertEqual(data["j_id"], f"J-{explicit.id}")
|
||||
self.assertEqual(data["rating"], "e")
|
||||
self.assertEqual(self.authed.get("/api/random/", {"rating": "q"}).status_code, 404)
|
||||
|
||||
def test_guests_never_receive_hidden_items(self):
|
||||
self.make_item("hidden", "png", "s", hidden=True)
|
||||
self.assertEqual(self.guest.get("/api/random/").status_code, 404)
|
||||
self.assertEqual(self.authed.get("/api/random/").status_code, 200)
|
||||
|
||||
def test_no_match_returns_404(self):
|
||||
self.make_item("movie", "mp4", "s") # images only
|
||||
self.assertEqual(self.authed.get("/api/random/").status_code, 404)
|
||||
|
||||
def test_short_top_level_alias(self):
|
||||
self.make_item("alias", "gif", "s")
|
||||
self.assertEqual(self.guest.get("/random/").status_code, 200)
|
||||
self.assertEqual(self.guest.get("/random").status_code, 200)
|
||||
@@ -0,0 +1,788 @@
|
||||
"""Staged uploads: listing, phases (MD5/visual/IQDB) and the bulk tool."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client, TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from apps.library import upload_pipeline
|
||||
from apps.library.models import MediaItem, TempUpload, UploadRun
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
# 1x1 transparent PNG so indexing/hashing has a real image to chew on.
|
||||
TINY_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
def similar_png(color=(200, 30, 40)):
|
||||
"""A 1x1 PNG with the same visual hashes but a different MD5."""
|
||||
buffer = io.BytesIO()
|
||||
Image.new("RGB", (1, 1), color).save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def jpost(client, path, body=None):
|
||||
return client.post(path, data=json.dumps(body or {}), content_type="application/json")
|
||||
|
||||
|
||||
class TempUploadListTests(TestCase):
|
||||
def setUp(self):
|
||||
self.uploader = User.objects.create_user(
|
||||
username="upload-user", password="upload-pass-123456"
|
||||
)
|
||||
self.uploader.role = "uploader"
|
||||
self.uploader.save(update_fields=["role"])
|
||||
self.other = User.objects.create_user(
|
||||
username="upload-other", password="upload-pass-123456"
|
||||
)
|
||||
self.other.role = "uploader"
|
||||
self.other.save(update_fields=["role"])
|
||||
|
||||
def api_client(self, user):
|
||||
client = Client()
|
||||
client.defaults["HTTP_AUTHORIZATION"] = (
|
||||
f"Token {Token.objects.create(user=user).key}"
|
||||
)
|
||||
return client
|
||||
|
||||
def test_list_is_not_paginated_past_48(self):
|
||||
TempUpload.objects.bulk_create(
|
||||
[
|
||||
TempUpload(
|
||||
user=self.uploader,
|
||||
original_filename=f"file_{index:03d}.png",
|
||||
md5=hashlib.md5(f"file-{index}".encode()).hexdigest(),
|
||||
size=index,
|
||||
)
|
||||
for index in range(69)
|
||||
]
|
||||
)
|
||||
response = self.api_client(self.uploader).get("/api/uploads/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIsInstance(data, list, "the list must not be paginated")
|
||||
self.assertEqual(len(data), 69)
|
||||
|
||||
def test_list_only_contains_the_callers_uploads(self):
|
||||
TempUpload.objects.create(
|
||||
user=self.uploader,
|
||||
original_filename="mine.png",
|
||||
md5=hashlib.md5(b"mine").hexdigest(),
|
||||
size=1,
|
||||
)
|
||||
TempUpload.objects.create(
|
||||
user=self.other,
|
||||
original_filename="theirs.png",
|
||||
md5=hashlib.md5(b"theirs").hexdigest(),
|
||||
size=1,
|
||||
)
|
||||
mine = self.api_client(self.uploader).get("/api/uploads/").json()
|
||||
self.assertEqual([row["original_filename"] for row in mine], ["mine.png"])
|
||||
|
||||
def test_anonymous_cannot_list(self):
|
||||
self.assertEqual(Client().get("/api/uploads/").status_code, 401)
|
||||
|
||||
|
||||
@override_settings(UPLOAD_PIPELINE_AUTOSTART=False)
|
||||
class StagedUploadWorkflowTests(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls._tmp = tempfile.mkdtemp(prefix="j621-bulk-")
|
||||
cls._watched = Path(cls._tmp) / "library"
|
||||
cls._watched.mkdir(parents=True, exist_ok=True)
|
||||
cls._settings = override_settings(
|
||||
MEDIA_ROOT=cls._tmp, WATCHED_FOLDER=str(cls._watched)
|
||||
)
|
||||
cls._settings.enable()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls._settings.disable()
|
||||
shutil.rmtree(cls._tmp, ignore_errors=True)
|
||||
super().tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
self.uploader = User.objects.create_user(
|
||||
username="bulk-uploader", password="bulk-pass-123456"
|
||||
)
|
||||
self.uploader.role = "uploader"
|
||||
self.uploader.save(update_fields=["role"])
|
||||
self.other = User.objects.create_user(
|
||||
username="bulk-other", password="bulk-pass-123456"
|
||||
)
|
||||
self.other.role = "uploader"
|
||||
self.other.save(update_fields=["role"])
|
||||
|
||||
def api_client(self, user):
|
||||
client = Client()
|
||||
client.defaults["HTTP_AUTHORIZATION"] = (
|
||||
f"Token {Token.objects.create(user=user).key}"
|
||||
)
|
||||
return client
|
||||
|
||||
def make_temp(
|
||||
self, user, label, status=TempUpload.STATUS_PENDING, payload=None
|
||||
):
|
||||
return TempUpload.objects.create(
|
||||
user=user,
|
||||
file=SimpleUploadedFile(
|
||||
f"{label}.png", payload or TINY_PNG, content_type="image/png"
|
||||
),
|
||||
original_filename=f"{label}.png",
|
||||
md5=hashlib.md5(label.encode()).hexdigest(),
|
||||
size=len(payload or TINY_PNG),
|
||||
status=status,
|
||||
)
|
||||
|
||||
def resolve_bulk(self, client, ids, rating):
|
||||
return jpost(
|
||||
client,
|
||||
"/api/uploads/resolve-bulk/",
|
||||
{"temp_ids": [str(value) for value in ids], "rating": rating},
|
||||
)
|
||||
|
||||
def test_resolves_selected_uploads_with_the_rating(self):
|
||||
client = self.api_client(self.uploader)
|
||||
first = self.make_temp(self.uploader, "one")
|
||||
second = self.make_temp(self.uploader, "two")
|
||||
untouched = self.make_temp(self.uploader, "three")
|
||||
|
||||
response = self.resolve_bulk(client, [first.id, second.id], "q")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(len(body["resolved"]), 2)
|
||||
self.assertEqual(body["errors"], [])
|
||||
|
||||
# Completed uploads are notifications now: the staged rows are gone
|
||||
# and the live feed carries the filename -> J-ID mapping.
|
||||
self.assertFalse(
|
||||
TempUpload.objects.filter(pk__in=[first.id, second.id]).exists()
|
||||
)
|
||||
feed = UploadRun.objects.get(user=self.uploader).recent_completions
|
||||
self.assertEqual(
|
||||
{entry["filename"] for entry in feed}, {"one.png", "two.png"}
|
||||
)
|
||||
for item in MediaItem.objects.all():
|
||||
self.assertEqual(item.rating, "q")
|
||||
self.assertEqual(item.uploaded_by_id, self.uploader.id)
|
||||
untouched.refresh_from_db()
|
||||
self.assertEqual(untouched.status, TempUpload.STATUS_PENDING)
|
||||
|
||||
def test_rejects_bad_input(self):
|
||||
client = self.api_client(self.uploader)
|
||||
temp = self.make_temp(self.uploader, "input")
|
||||
self.assertEqual(self.resolve_bulk(client, [], "s").status_code, 400)
|
||||
self.assertEqual(self.resolve_bulk(client, [temp.id], "").status_code, 400)
|
||||
self.assertEqual(self.resolve_bulk(client, [temp.id], "x").status_code, 400)
|
||||
self.assertEqual(
|
||||
jpost(
|
||||
client,
|
||||
"/api/uploads/resolve-bulk/",
|
||||
{"temp_ids": ["not-a-uuid"], "rating": "s"},
|
||||
).status_code,
|
||||
400,
|
||||
)
|
||||
|
||||
def test_other_users_uploads_are_left_alone(self):
|
||||
client = self.api_client(self.uploader)
|
||||
theirs = self.make_temp(self.other, "theirs")
|
||||
response = self.resolve_bulk(client, [theirs.id], "s")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(body["resolved"], [])
|
||||
self.assertEqual(body["errors"][0]["error"], "not found")
|
||||
theirs.refresh_from_db()
|
||||
self.assertEqual(theirs.status, TempUpload.STATUS_PENDING)
|
||||
|
||||
def test_completed_uploads_report_an_error_but_others_resolve(self):
|
||||
client = self.api_client(self.uploader)
|
||||
done = self.make_temp(self.uploader, "done", status=TempUpload.STATUS_COMPLETED)
|
||||
pending = self.make_temp(self.uploader, "pending")
|
||||
response = self.resolve_bulk(client, [done.id, pending.id], "e")
|
||||
body = response.json()
|
||||
self.assertEqual(len(body["resolved"]), 1)
|
||||
self.assertEqual(body["errors"][0]["error"], "already in the library")
|
||||
self.assertEqual(MediaItem.objects.count(), 1)
|
||||
|
||||
def upload_via_api(self, client, label="phase.png"):
|
||||
return client.post(
|
||||
"/api/uploads/",
|
||||
{
|
||||
"file": SimpleUploadedFile(
|
||||
label, similar_png(), content_type="image/png"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
def seed_library_item(self, client, label):
|
||||
seed = self.make_temp(self.uploader, label)
|
||||
response = self.resolve_bulk(client, [seed.id], "s")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
return seed
|
||||
|
||||
def test_duplicate_upload_returns_a_completion_without_a_record(self):
|
||||
client = self.api_client(self.uploader)
|
||||
first = self.upload_via_api(client, "same.png")
|
||||
self.assertEqual(first.status_code, 201)
|
||||
# Index the first upload so the second one is byte-identical.
|
||||
seed = TempUpload.objects.get(pk=first.json()["temp_id"])
|
||||
self.resolve_bulk(client, [seed.id], "s")
|
||||
|
||||
response = self.upload_via_api(client, "same.png")
|
||||
self.assertEqual(response.status_code, 201)
|
||||
body = response.json()
|
||||
self.assertEqual(body["status"], TempUpload.STATUS_COMPLETED)
|
||||
self.assertEqual(body["resolution"], TempUpload.RESOLUTION_DUPLICATE)
|
||||
self.assertTrue(body["library_j_id"].startswith("J-"))
|
||||
# Duplicates never become board records; the feed announces them.
|
||||
self.assertFalse(TempUpload.objects.filter(pk=body["temp_id"]).exists())
|
||||
feed = UploadRun.objects.get(user=self.uploader).recent_completions
|
||||
self.assertEqual(feed[-1]["filename"], "same.png")
|
||||
|
||||
def test_upload_defers_visual_similarity_to_its_phase(self):
|
||||
client = self.api_client(self.uploader)
|
||||
self.seed_library_item(client, "seed-defer")
|
||||
response = self.upload_via_api(client)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
body = response.json()
|
||||
# Uploading must not run the expensive library scan.
|
||||
self.assertEqual(body["status"], TempUpload.STATUS_PENDING)
|
||||
self.assertFalse(body["visual_matches"])
|
||||
|
||||
def test_visual_match_phase_flags_similar_library_items(self):
|
||||
client = self.api_client(self.uploader)
|
||||
self.seed_library_item(client, "seed-visual")
|
||||
temp = self.make_temp(self.uploader, "check-visual")
|
||||
response = client.post(f"/api/uploads/{temp.id}/visual-match/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(body["status"], TempUpload.STATUS_VISUAL_MATCH)
|
||||
self.assertGreaterEqual(len(body["visual_matches"]), 1)
|
||||
temp.refresh_from_db()
|
||||
self.assertEqual(temp.status, TempUpload.STATUS_VISUAL_MATCH)
|
||||
|
||||
def test_visual_match_phase_with_empty_library_stays_pending(self):
|
||||
client = self.api_client(self.uploader)
|
||||
temp = self.make_temp(self.uploader, "no-matches")
|
||||
response = client.post(f"/api/uploads/{temp.id}/visual-match/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(body["visual_matches"], [])
|
||||
self.assertEqual(body["status"], TempUpload.STATUS_PENDING)
|
||||
|
||||
def test_detail_resigns_stored_visual_match_urls(self):
|
||||
"""Stored matches carry only the item reference; URLs are re-minted.
|
||||
|
||||
Embedding the signed URL meant it expired (or used an older signing
|
||||
scheme) and the modal showed alt text instead of thumbnails.
|
||||
"""
|
||||
client = self.api_client(self.uploader)
|
||||
self.seed_library_item(client, "seed-resign")
|
||||
item = MediaItem.objects.get()
|
||||
temp = self.make_temp(self.uploader, "resign")
|
||||
TempUpload.objects.filter(pk=temp.pk).update(
|
||||
visual_matches=[
|
||||
{
|
||||
"item_id": item.id,
|
||||
"j_id": f"J-{item.id}",
|
||||
"filename": "seed-resign.png",
|
||||
"similarity": 96.5,
|
||||
"thumbnail_url": "/api/files/J-x/thumbnail/?sig=stale",
|
||||
},
|
||||
{"item_id": 999999, "j_id": "J-999999", "filename": "gone.png"},
|
||||
]
|
||||
)
|
||||
body = client.get(f"/api/uploads/{temp.id}/").json()
|
||||
self.assertEqual(len(body["visual_matches"]), 1)
|
||||
match = body["visual_matches"][0]
|
||||
self.assertEqual(match["j_id"], f"J-{item.id}")
|
||||
self.assertEqual(match["similarity"], 96.5)
|
||||
self.assertNotIn("stale", match["thumbnail_url"])
|
||||
self.assertIn("/thumbnail/", match["thumbnail_url"])
|
||||
self.assertIn(f"v={item.md5}", match["thumbnail_url"])
|
||||
|
||||
def test_visual_match_phase_rejects_completed_uploads(self):
|
||||
client = self.api_client(self.uploader)
|
||||
temp = self.make_temp(
|
||||
self.uploader, "done-visual", status=TempUpload.STATUS_COMPLETED
|
||||
)
|
||||
response = client.post(f"/api/uploads/{temp.id}/visual-match/")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_link_bulk_moves_a_batch_with_one_call(self):
|
||||
client = self.api_client(self.uploader)
|
||||
first = self.make_temp(self.uploader, "bulk-link-1")
|
||||
second = self.make_temp(
|
||||
self.uploader, "bulk-link-2", payload=similar_png((10, 200, 30))
|
||||
)
|
||||
response = jpost(
|
||||
client,
|
||||
"/api/uploads/link-bulk/",
|
||||
{
|
||||
"links": [
|
||||
{
|
||||
"temp_id": str(first.id),
|
||||
"post": {
|
||||
"id": 900001,
|
||||
"rating": "s",
|
||||
"file": {
|
||||
"md5": first.md5,
|
||||
# Identical MD5: the staged file is indexed
|
||||
# without downloading the URL.
|
||||
"url": "https://static1.e621.net/data/fake-1.png",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
"temp_id": str(second.id),
|
||||
"post": {
|
||||
"id": 900002,
|
||||
"rating": "q",
|
||||
"file": {
|
||||
"md5": second.md5,
|
||||
"url": "https://static1.e621.net/data/fake-2.png",
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(body["errors"], [])
|
||||
self.assertEqual(len(body["updated"]), 2)
|
||||
for entry, post_id in zip(body["updated"], (900001, 900002)):
|
||||
self.assertEqual(entry["resolution"], TempUpload.RESOLUTION_AUTO_MD5)
|
||||
self.assertEqual(entry["e621_post_id"], post_id)
|
||||
self.assertTrue(entry["library_j_id"].startswith("J-"))
|
||||
# Indexed uploads no longer leave a board record; the completion feed
|
||||
# carries them for the live page instead.
|
||||
self.assertFalse(TempUpload.objects.exists())
|
||||
self.assertEqual(
|
||||
{item.e621_post_id for item in MediaItem.objects.all()},
|
||||
{900001, 900002},
|
||||
)
|
||||
feed = UploadRun.objects.get(user=self.uploader).recent_completions
|
||||
self.assertEqual(len(feed), 2)
|
||||
self.assertEqual(
|
||||
{entry["filename"] for entry in feed},
|
||||
{"bulk-link-1.png", "bulk-link-2.png"},
|
||||
)
|
||||
|
||||
|
||||
class IqdbRecordingTests(TestCase):
|
||||
"""The modal needs to tell "checked, no match" from "never checked"."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls._tmp = tempfile.mkdtemp(prefix="j621-iqdb-")
|
||||
cls._settings = override_settings(MEDIA_ROOT=cls._tmp)
|
||||
cls._settings.enable()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls._settings.disable()
|
||||
shutil.rmtree(cls._tmp, ignore_errors=True)
|
||||
super().tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
self.uploader = User.objects.create_user(
|
||||
username="iqdb-uploader", password="iqdb-pass-123456"
|
||||
)
|
||||
self.uploader.role = "uploader"
|
||||
self.uploader.save(update_fields=["role"])
|
||||
self.client = Client()
|
||||
self.client.defaults["HTTP_AUTHORIZATION"] = (
|
||||
f"Token {Token.objects.create(user=self.uploader).key}"
|
||||
)
|
||||
|
||||
def make_temp(self):
|
||||
return TempUpload.objects.create(
|
||||
user=self.uploader,
|
||||
file=SimpleUploadedFile(
|
||||
"checked.png", TINY_PNG, content_type="image/png"
|
||||
),
|
||||
original_filename="checked.png",
|
||||
md5=hashlib.md5(b"checked").hexdigest(),
|
||||
size=len(TINY_PNG),
|
||||
)
|
||||
|
||||
def test_empty_result_records_the_check_without_a_match(self):
|
||||
temp = self.make_temp()
|
||||
response = jpost(
|
||||
self.client, f"/api/uploads/{temp.id}/iqdb/", {"results": []}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
temp.refresh_from_db()
|
||||
self.assertEqual(temp.iqdb_data, [])
|
||||
# No candidates must not masquerade as a visual match.
|
||||
self.assertEqual(temp.status, TempUpload.STATUS_PENDING)
|
||||
|
||||
def test_candidates_are_stored_and_flag_a_visual_match(self):
|
||||
temp = self.make_temp()
|
||||
response = jpost(
|
||||
self.client,
|
||||
f"/api/uploads/{temp.id}/iqdb/",
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"post_id": 123,
|
||||
"score": 91.5,
|
||||
"preview_url": "https://static1.e621.net/data/preview/ab/cd/x.jpg",
|
||||
"rating": "q",
|
||||
"md5": "a" * 32,
|
||||
"score_total": 12,
|
||||
"fav_count": 3,
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"tags_preview": ["canine", "solo"],
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
temp.refresh_from_db()
|
||||
self.assertEqual(len(temp.iqdb_data), 1)
|
||||
self.assertEqual(temp.iqdb_data[0]["post_id"], 123)
|
||||
self.assertEqual(temp.status, TempUpload.STATUS_VISUAL_MATCH)
|
||||
|
||||
def test_rejects_a_non_list_payload(self):
|
||||
temp = self.make_temp()
|
||||
response = jpost(
|
||||
self.client, f"/api/uploads/{temp.id}/iqdb/", {"results": "nope"}
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
|
||||
@override_settings(UPLOAD_PIPELINE_AUTOSTART=False)
|
||||
class UploadPipelineTests(TestCase):
|
||||
"""The server-side MD5 -> visual -> IQDB queue and its board API."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls._tmp = tempfile.mkdtemp(prefix="j621-pipeline-")
|
||||
cls._watched = Path(cls._tmp) / "library"
|
||||
cls._watched.mkdir(parents=True, exist_ok=True)
|
||||
cls._settings = override_settings(
|
||||
MEDIA_ROOT=cls._tmp, WATCHED_FOLDER=str(cls._watched)
|
||||
)
|
||||
cls._settings.enable()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls._settings.disable()
|
||||
shutil.rmtree(cls._tmp, ignore_errors=True)
|
||||
super().tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
self.uploader = User.objects.create_user(
|
||||
username="pipe-uploader", password="pipe-pass-123456"
|
||||
)
|
||||
self.uploader.role = "uploader"
|
||||
self.uploader.save(update_fields=["role"])
|
||||
self.other = User.objects.create_user(
|
||||
username="pipe-other", password="pipe-pass-123456"
|
||||
)
|
||||
self.other.role = "uploader"
|
||||
self.other.save(update_fields=["role"])
|
||||
|
||||
def api_client(self, user):
|
||||
client = Client()
|
||||
client.defaults["HTTP_AUTHORIZATION"] = (
|
||||
f"Token {Token.objects.create(user=user).key}"
|
||||
)
|
||||
return client
|
||||
|
||||
def stage(self, user=None, label="file", payload=None, filename=None):
|
||||
user = user or self.uploader
|
||||
payload = payload or TINY_PNG
|
||||
name = filename or f"{label}.png"
|
||||
return TempUpload.objects.create(
|
||||
user=user,
|
||||
file=SimpleUploadedFile(name, payload, content_type="image/png"),
|
||||
original_filename=name,
|
||||
md5=hashlib.md5(payload + label.encode()).hexdigest(),
|
||||
size=len(payload),
|
||||
)
|
||||
|
||||
def test_md5_match_auto_imports_the_file(self):
|
||||
temp = self.stage(label="match")
|
||||
temp_id = temp.id
|
||||
post = {
|
||||
"id": 123456,
|
||||
"rating": "s",
|
||||
"file": {
|
||||
"md5": temp.md5,
|
||||
"url": "https://static1.e621.net/data/m.png",
|
||||
},
|
||||
"tags": {"general": ["canine"]},
|
||||
}
|
||||
with mock.patch.object(
|
||||
upload_pipeline.e621,
|
||||
"check_md5_batch",
|
||||
return_value={temp.md5: post},
|
||||
):
|
||||
upload_pipeline.run_pipeline(self.uploader.id)
|
||||
|
||||
# The indexed upload leaves no board record; the feed reports it.
|
||||
self.assertFalse(TempUpload.objects.filter(pk=temp_id).exists())
|
||||
item = MediaItem.objects.get(e621_post_id=123456)
|
||||
self.assertEqual(item.uploaded_by_id, self.uploader.id)
|
||||
run = UploadRun.objects.get(user=self.uploader)
|
||||
self.assertEqual(run.status, UploadRun.STATUS_IDLE)
|
||||
self.assertEqual(run.matched, 1)
|
||||
self.assertEqual(run.processed, 1)
|
||||
self.assertEqual(len(run.recent_completions), 1)
|
||||
entry = run.recent_completions[0]
|
||||
self.assertEqual(entry["id"], str(temp_id))
|
||||
self.assertEqual(entry["item_id"], item.id)
|
||||
self.assertEqual(entry["filename"], "match.png")
|
||||
self.assertEqual(entry["resolution"], TempUpload.RESOLUTION_AUTO_MD5)
|
||||
|
||||
def test_status_reports_the_completion_feed(self):
|
||||
temp = self.stage(label="feed")
|
||||
client = self.api_client(self.uploader)
|
||||
post = {
|
||||
"id": 654321,
|
||||
"rating": "s",
|
||||
"file": {"md5": temp.md5, "url": "https://static1.e621.net/data/f.png"},
|
||||
}
|
||||
with mock.patch.object(
|
||||
upload_pipeline.e621,
|
||||
"check_md5_batch",
|
||||
return_value={temp.md5: post},
|
||||
):
|
||||
upload_pipeline.run_pipeline(self.uploader.id)
|
||||
|
||||
body = client.get("/api/uploads/status/").json()
|
||||
completions = body["recent_completions"]
|
||||
self.assertEqual(len(completions), 1)
|
||||
self.assertEqual(completions[0]["filename"], "feed.png")
|
||||
self.assertEqual(completions[0]["j_id"], f"J-{MediaItem.objects.get().id}")
|
||||
self.assertIn("/thumbnail/", completions[0]["thumbnail_url"])
|
||||
|
||||
def test_unmatched_file_runs_every_phase(self):
|
||||
temp = self.stage(label="nomatch")
|
||||
raw_iqdb = [
|
||||
{
|
||||
"post_id": 777,
|
||||
"score": 91.0,
|
||||
"post": {
|
||||
"id": 777,
|
||||
"rating": "q",
|
||||
"md5": "a" * 32,
|
||||
"score": 5,
|
||||
"fav_count": 2,
|
||||
"image_width": 800,
|
||||
"image_height": 600,
|
||||
},
|
||||
}
|
||||
]
|
||||
modern = [
|
||||
{
|
||||
"id": 777,
|
||||
"rating": "q",
|
||||
"fav_count": 4,
|
||||
"score": {"total": 9},
|
||||
"preview": {"url": "https://static1.e621.net/data/preview/x.jpg"},
|
||||
"file": {"md5": "a" * 32, "width": 801, "height": 601},
|
||||
"tags": {"general": ["canine", "solo"]},
|
||||
}
|
||||
]
|
||||
with mock.patch.object(
|
||||
upload_pipeline.e621, "check_md5_batch", return_value={}
|
||||
), mock.patch.object(
|
||||
upload_pipeline.e621, "iqdb_search", return_value=raw_iqdb
|
||||
), mock.patch.object(
|
||||
upload_pipeline.e621, "fetch_posts_by_ids", return_value=modern
|
||||
):
|
||||
upload_pipeline.run_pipeline(self.uploader.id)
|
||||
|
||||
temp.refresh_from_db()
|
||||
self.assertIsNotNone(temp.e621_checked_at)
|
||||
self.assertIsNotNone(temp.visual_checked_at)
|
||||
self.assertEqual(temp.status, TempUpload.STATUS_VISUAL_MATCH)
|
||||
self.assertEqual(len(temp.iqdb_data), 1)
|
||||
candidate = temp.iqdb_data[0]
|
||||
self.assertEqual(candidate["post_id"], 777)
|
||||
self.assertEqual(candidate["score_total"], 9)
|
||||
self.assertEqual(candidate["fav_count"], 4)
|
||||
self.assertEqual(candidate["width"], 801)
|
||||
self.assertEqual(
|
||||
candidate["preview_url"], "https://static1.e621.net/data/preview/x.jpg"
|
||||
)
|
||||
self.assertEqual(candidate["tags_preview"], ["canine", "solo"])
|
||||
run = UploadRun.objects.get(user=self.uploader)
|
||||
self.assertEqual(run.status, UploadRun.STATUS_IDLE)
|
||||
self.assertEqual(run.processed, 1)
|
||||
|
||||
def test_visual_match_flags_similar_library_items(self):
|
||||
from apps.library.uploads import complete_temp_upload
|
||||
|
||||
seed = self.stage(label="seed")
|
||||
complete_temp_upload(seed)
|
||||
self.assertTrue(MediaItem.objects.exists())
|
||||
|
||||
temp = self.stage(label="similar")
|
||||
with mock.patch.object(
|
||||
upload_pipeline.e621, "check_md5_batch", return_value={}
|
||||
), mock.patch.object(
|
||||
upload_pipeline.e621, "iqdb_search", return_value=[]
|
||||
):
|
||||
upload_pipeline.run_pipeline(self.uploader.id)
|
||||
|
||||
temp.refresh_from_db()
|
||||
self.assertGreaterEqual(len(temp.visual_matches or []), 1)
|
||||
self.assertEqual(temp.status, TempUpload.STATUS_VISUAL_MATCH)
|
||||
|
||||
def test_iqdb_rate_limit_pauses_the_run(self):
|
||||
temp = self.stage(label="throttled")
|
||||
with mock.patch.object(
|
||||
upload_pipeline.e621, "check_md5_batch", return_value={}
|
||||
), mock.patch.object(
|
||||
upload_pipeline.e621,
|
||||
"iqdb_search",
|
||||
side_effect=upload_pipeline.e621.E621RateLimited("Throttled"),
|
||||
):
|
||||
upload_pipeline.run_pipeline(self.uploader.id)
|
||||
|
||||
run = UploadRun.objects.get(user=self.uploader)
|
||||
self.assertEqual(run.status, UploadRun.STATUS_PAUSED)
|
||||
self.assertIn("e621", run.error)
|
||||
temp.refresh_from_db()
|
||||
self.assertEqual(temp.attempts, 1)
|
||||
self.assertNotEqual(temp.pipeline_error, "")
|
||||
self.assertIsNone(temp.iqdb_data)
|
||||
self.assertIsNone(temp.claimed_at)
|
||||
|
||||
client = self.api_client(self.uploader)
|
||||
response = jpost(client, f"/api/uploads/{temp.id}/retry/", {})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
temp.refresh_from_db()
|
||||
self.assertEqual(temp.attempts, 0)
|
||||
self.assertEqual(temp.pipeline_error, "")
|
||||
|
||||
def test_failed_rows_stop_after_the_attempt_cap(self):
|
||||
temp = self.stage(label="broken")
|
||||
for _ in range(upload_pipeline.MAX_ATTEMPTS):
|
||||
with mock.patch.object(
|
||||
upload_pipeline.e621, "check_md5_batch", return_value={}
|
||||
), mock.patch.object(
|
||||
upload_pipeline.e621,
|
||||
"iqdb_search",
|
||||
side_effect=upload_pipeline.e621.E621Error("boom"),
|
||||
):
|
||||
upload_pipeline.run_pipeline(self.uploader.id)
|
||||
temp.refresh_from_db()
|
||||
self.assertEqual(temp.attempts, upload_pipeline.MAX_ATTEMPTS)
|
||||
self.assertEqual(upload_pipeline.count_outstanding(self.uploader), 0)
|
||||
run = UploadRun.objects.get(user=self.uploader)
|
||||
self.assertGreaterEqual(run.failed, 1)
|
||||
|
||||
def test_status_process_and_compact_board_payload(self):
|
||||
temp = self.stage(label="board")
|
||||
client = self.api_client(self.uploader)
|
||||
|
||||
status = client.get("/api/uploads/status/").json()
|
||||
self.assertEqual(status["status"], UploadRun.STATUS_IDLE)
|
||||
self.assertTrue(status["active"])
|
||||
self.assertEqual(status["outstanding"], 1)
|
||||
self.assertEqual(status["waiting"]["md5"], 1)
|
||||
|
||||
self.assertEqual(client.post("/api/uploads/process/").status_code, 200)
|
||||
|
||||
rows = client.get("/api/uploads/").json()
|
||||
self.assertEqual(len(rows), 1)
|
||||
row = rows[0]
|
||||
for key in (
|
||||
"md5_checked",
|
||||
"visual_checked",
|
||||
"iqdb_checked",
|
||||
"processing",
|
||||
"similar_count",
|
||||
"pipeline_error",
|
||||
):
|
||||
self.assertIn(key, row)
|
||||
self.assertNotIn("e621_data", row)
|
||||
self.assertNotIn("iqdb_data", row)
|
||||
|
||||
detail = client.get(f"/api/uploads/{temp.id}/").json()
|
||||
self.assertIn("e621_data", detail)
|
||||
self.assertIn("iqdb_data", detail)
|
||||
|
||||
def test_discard_bulk_removes_only_own_rows(self):
|
||||
client = self.api_client(self.uploader)
|
||||
first = self.stage(label="discard-a")
|
||||
second = self.stage(label="discard-b")
|
||||
theirs = self.stage(self.other, label="discard-theirs")
|
||||
paths = [Path(first.file.path), Path(second.file.path)]
|
||||
|
||||
response = jpost(
|
||||
client,
|
||||
"/api/uploads/discard-bulk/",
|
||||
{"temp_ids": [str(first.id), str(second.id), str(theirs.id)]},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
body = response.json()
|
||||
self.assertEqual(len(body["discarded"]), 2)
|
||||
self.assertEqual(len(body["errors"]), 1)
|
||||
self.assertEqual(body["errors"][0]["error"], "not found")
|
||||
for path in paths:
|
||||
self.assertFalse(path.exists())
|
||||
self.assertFalse(
|
||||
TempUpload.objects.filter(pk__in=[first.id, second.id]).exists()
|
||||
)
|
||||
self.assertTrue(TempUpload.objects.filter(pk=theirs.id).exists())
|
||||
|
||||
def test_retry_with_phase_rechecks_iqdb(self):
|
||||
temp = self.stage(label="recheck")
|
||||
TempUpload.objects.filter(pk=temp.pk).update(
|
||||
iqdb_data=[{"post_id": 1}],
|
||||
status=TempUpload.STATUS_VISUAL_MATCH,
|
||||
)
|
||||
client = self.api_client(self.uploader)
|
||||
response = jpost(
|
||||
client, f"/api/uploads/{temp.id}/retry/", {"phase": "iqdb"}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
temp.refresh_from_db()
|
||||
self.assertIsNone(temp.iqdb_data)
|
||||
self.assertEqual(temp.status, TempUpload.STATUS_VISUAL_MATCH)
|
||||
|
||||
def test_stale_claims_are_released(self):
|
||||
temp = self.stage(label="stale")
|
||||
TempUpload.objects.filter(pk=temp.pk).update(
|
||||
claimed_at=timezone.now() - upload_pipeline.STALE_CLAIM_AFTER
|
||||
- timedelta(minutes=1)
|
||||
)
|
||||
UploadRun.objects.create(user=self.uploader, status=UploadRun.STATUS_RUNNING)
|
||||
UploadRun.objects.filter(user=self.uploader).update(
|
||||
updated_at=timezone.now() - upload_pipeline.STALE_RUN_AFTER
|
||||
- timedelta(minutes=1)
|
||||
)
|
||||
released, paused = upload_pipeline.reap_stale_claims()
|
||||
self.assertEqual(released, 1)
|
||||
self.assertEqual(paused, 1)
|
||||
temp.refresh_from_db()
|
||||
self.assertIsNone(temp.claimed_at)
|
||||
run = UploadRun.objects.get(user=self.uploader)
|
||||
self.assertEqual(run.status, UploadRun.STATUS_PAUSED)
|
||||
@@ -0,0 +1,648 @@
|
||||
"""Background processing for staged uploads.
|
||||
|
||||
Each staged upload runs through three phases, in batches of 75 (the same
|
||||
lookup size the original app used for its e621 MD5 cache command):
|
||||
|
||||
1. e621 MD5 lookup — one ``posts.json`` query per round; byte-identical
|
||||
matches are imported straight into the library (``auto_md5``).
|
||||
2. Local visual similarity — perceptual hashes are compared against the
|
||||
whole library once per round.
|
||||
3. e621 IQDB — reverse-image search for whatever is still unresolved.
|
||||
|
||||
The pipeline runs in a daemon thread started on demand (like the download and
|
||||
match scans), so the browser can navigate away and the work keeps going.
|
||||
Progress and pause/error state live in the ``UploadRun`` row; per-file state
|
||||
lives on ``TempUpload``. Rows are claimed with ``SELECT ... FOR UPDATE SKIP
|
||||
LOCKED`` so several gunicorn workers cannot process the same file, and stale
|
||||
claims left by a recycled worker are reaped and picked up again.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.db import connection, transaction
|
||||
from django.db.models import F, Q
|
||||
from django.utils import timezone
|
||||
|
||||
from . import e621, services
|
||||
from .models import MediaItem, TempUpload, UploadRun
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Same batch size as the original app's e621 cache command.
|
||||
MD5_BATCH_SIZE = 75
|
||||
# Rows one worker round claims; also the MD5 query size.
|
||||
CLAIM_SIZE = MD5_BATCH_SIZE
|
||||
# A claimed row is assumed dead after this long and is queued again.
|
||||
STALE_CLAIM_AFTER = timedelta(minutes=15)
|
||||
# A run whose heartbeat stopped this long ago can be taken over.
|
||||
STALE_RUN_AFTER = timedelta(minutes=15)
|
||||
# Per-row failures before the pipeline stops retrying automatically.
|
||||
MAX_ATTEMPTS = 3
|
||||
# Round-level e621 retries before the run is paused.
|
||||
ROUND_ATTEMPTS = 3
|
||||
ROUND_RETRY_SECONDS = 20
|
||||
# Completions kept in the live feed. The board only shows what happened while
|
||||
# the page was open, so a bounded rolling window is plenty.
|
||||
COMPLETION_FEED_LIMIT = 200
|
||||
|
||||
WORK_STATUSES = (TempUpload.STATUS_PENDING, TempUpload.STATUS_VISUAL_MATCH)
|
||||
VIDEO_RE = r"\.(mp4|webm)$"
|
||||
|
||||
# A staged upload still needs work when any phase has not run yet. IQDB is
|
||||
# skipped for videos, which never get iqdb_data, so they must not stay
|
||||
# "outstanding" forever.
|
||||
OUTSTANDING_Q = (
|
||||
Q(e621_checked_at__isnull=True)
|
||||
| Q(visual_checked_at__isnull=True)
|
||||
| (Q(iqdb_data__isnull=True) & ~Q(original_filename__iregex=VIDEO_RE))
|
||||
)
|
||||
|
||||
_running_lock = threading.Lock()
|
||||
_running_users: set[int] = set()
|
||||
|
||||
|
||||
class PipelinePaused(Exception):
|
||||
"""A round-level failure that should pause the run instead of failing rows."""
|
||||
|
||||
|
||||
def is_video(filename):
|
||||
return bool(filename) and filename.lower().endswith((".mp4", ".webm"))
|
||||
|
||||
|
||||
def is_finished(temp):
|
||||
"""True when every phase this file needs has run."""
|
||||
if temp.status == TempUpload.STATUS_COMPLETED:
|
||||
return True
|
||||
if temp.e621_checked_at is None or temp.visual_checked_at is None:
|
||||
return False
|
||||
return is_video(temp.original_filename) or temp.iqdb_data is not None
|
||||
|
||||
|
||||
def outstanding_queryset(user):
|
||||
return (
|
||||
TempUpload.objects.filter(user=user, status__in=WORK_STATUSES)
|
||||
.filter(OUTSTANDING_Q)
|
||||
.filter(attempts__lt=MAX_ATTEMPTS)
|
||||
)
|
||||
|
||||
|
||||
def count_outstanding(user):
|
||||
return outstanding_queryset(user).count()
|
||||
|
||||
|
||||
def count_failed(user):
|
||||
return (
|
||||
TempUpload.objects.filter(user=user, status__in=WORK_STATUSES)
|
||||
.filter(attempts__gte=MAX_ATTEMPTS)
|
||||
.count()
|
||||
)
|
||||
|
||||
|
||||
def waiting_counts(user):
|
||||
"""How many files are left per phase (phases overlap by design)."""
|
||||
base = TempUpload.objects.filter(
|
||||
user=user, status__in=WORK_STATUSES, attempts__lt=MAX_ATTEMPTS
|
||||
)
|
||||
return {
|
||||
"md5": base.filter(e621_checked_at__isnull=True).count(),
|
||||
"visual": base.filter(visual_checked_at__isnull=True).count(),
|
||||
"iqdb": (
|
||||
base.filter(iqdb_data__isnull=True)
|
||||
.exclude(original_filename__iregex=VIDEO_RE)
|
||||
.count()
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def status_payload(user, request=None):
|
||||
"""Cheap state for the shell/upload page to poll."""
|
||||
run = UploadRun.objects.filter(user=user).first()
|
||||
outstanding = count_outstanding(user)
|
||||
failed = count_failed(user)
|
||||
status = run.status if run is not None else UploadRun.STATUS_IDLE
|
||||
total = run.total if run is not None else 0
|
||||
processed = run.processed if run is not None else 0
|
||||
# A paused run with nothing left to do is not "active" (the user may have
|
||||
# resolved or discarded the failed rows); failed rows stay visible until
|
||||
# they are retried or dismissed.
|
||||
active = (
|
||||
outstanding > 0 or failed > 0 or status == UploadRun.STATUS_RUNNING
|
||||
)
|
||||
return {
|
||||
"status": status,
|
||||
"active": active,
|
||||
"phase": run.phase if run is not None else "",
|
||||
"total": max(total, processed + failed),
|
||||
"processed": processed,
|
||||
"matched": run.matched if run is not None else 0,
|
||||
"failed": max(failed, run.failed if run is not None else 0),
|
||||
"error": run.error if run is not None else "",
|
||||
"outstanding": outstanding,
|
||||
"waiting": waiting_counts(user),
|
||||
"recent_completions": completion_payload(run, user, request=request),
|
||||
"updated_at": run.updated_at.isoformat() if run is not None else None,
|
||||
}
|
||||
|
||||
|
||||
def completion_payload(run, user, request=None):
|
||||
"""The live completion feed: filename -> J-ID for freshly indexed uploads.
|
||||
|
||||
Completed ``TempUpload`` rows are deleted, so this is the only place the
|
||||
board learns about them. It is a notification feed, not durable state:
|
||||
bounded, never dismissed, and ignored by fresh page loads.
|
||||
"""
|
||||
entries = list(run.recent_completions or []) if run is not None else []
|
||||
if not entries:
|
||||
return []
|
||||
ids = [entry.get("item_id") for entry in entries if entry.get("item_id")]
|
||||
items = MediaItem.objects.in_bulk(ids)
|
||||
out = []
|
||||
for entry in reversed(entries): # newest first
|
||||
item = items.get(entry.get("item_id"))
|
||||
if item is None:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"id": entry.get("id"),
|
||||
"filename": entry.get("filename"),
|
||||
"j_id": f"J-{item.id}",
|
||||
"resolution": entry.get("resolution", ""),
|
||||
"post_id": entry.get("post_id"),
|
||||
"thumbnail_url": services.signed_media_url(
|
||||
item, user, "thumbnail", request=request
|
||||
),
|
||||
"at": entry.get("at"),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def record_completion(temp, item, resolution=""):
|
||||
"""Append one completion to the owner's feed; never fails an import."""
|
||||
entry = {
|
||||
"id": str(temp.pk),
|
||||
"filename": temp.original_filename,
|
||||
"item_id": item.pk,
|
||||
"resolution": resolution or temp.resolution or "",
|
||||
"post_id": temp.e621_post_id,
|
||||
"at": timezone.now().isoformat(),
|
||||
}
|
||||
try:
|
||||
with transaction.atomic():
|
||||
run, _ = UploadRun.objects.select_for_update().get_or_create(
|
||||
user_id=temp.user_id
|
||||
)
|
||||
feed = list(run.recent_completions or [])
|
||||
feed.append(entry)
|
||||
run.recent_completions = feed[-COMPLETION_FEED_LIMIT:]
|
||||
run.save(update_fields=["recent_completions", "updated_at"])
|
||||
except Exception: # noqa: BLE001 - a notification must not break an import
|
||||
logger.exception("Could not record the completion of %s", temp.pk)
|
||||
|
||||
|
||||
def reap_stale_claims():
|
||||
"""Queue rows left claimed by a recycled worker and pause dead runs."""
|
||||
cutoff = timezone.now() - STALE_CLAIM_AFTER
|
||||
released = TempUpload.objects.filter(claimed_at__lt=cutoff).update(
|
||||
claimed_at=None
|
||||
)
|
||||
paused = UploadRun.objects.filter(
|
||||
status=UploadRun.STATUS_RUNNING, updated_at__lt=cutoff
|
||||
).update(
|
||||
status=UploadRun.STATUS_PAUSED,
|
||||
phase="",
|
||||
error="The worker stopped before finishing. Retry to resume.",
|
||||
updated_at=timezone.now(),
|
||||
)
|
||||
if released or paused:
|
||||
logger.info(
|
||||
"Reaped %s stale upload claims and %s dead upload runs", released, paused
|
||||
)
|
||||
return released, paused
|
||||
|
||||
|
||||
def start_pipeline(user):
|
||||
"""Start the pipeline for one user in a daemon thread (idempotent)."""
|
||||
if not getattr(settings, "UPLOAD_PIPELINE_AUTOSTART", True):
|
||||
return False
|
||||
if user is None or not getattr(user, "can_upload", False):
|
||||
return False
|
||||
user_id = int(user.pk)
|
||||
with _running_lock:
|
||||
if user_id in _running_users:
|
||||
return False
|
||||
_running_users.add(user_id)
|
||||
reap_stale_claims()
|
||||
thread = threading.Thread(target=_thread_entry, args=(user_id,), daemon=True)
|
||||
thread.start()
|
||||
return True
|
||||
|
||||
|
||||
def _thread_entry(user_id):
|
||||
try:
|
||||
run_pipeline(user_id)
|
||||
except Exception: # noqa: BLE001 - a thread must never crash the worker
|
||||
logger.exception("Upload pipeline for user %s crashed", user_id)
|
||||
finally:
|
||||
with _running_lock:
|
||||
_running_users.discard(user_id)
|
||||
connection.close()
|
||||
|
||||
|
||||
def run_pipeline(user_id):
|
||||
user = get_user_model().objects.filter(pk=user_id).first()
|
||||
if user is None or not user.can_upload:
|
||||
return
|
||||
now = timezone.now()
|
||||
# Claim the run row so two gunicorn workers cannot own the same queue.
|
||||
with transaction.atomic():
|
||||
run, _ = UploadRun.objects.select_for_update().get_or_create(user=user)
|
||||
if (
|
||||
run.status == UploadRun.STATUS_RUNNING
|
||||
and run.updated_at is not None
|
||||
and run.updated_at > now - STALE_RUN_AFTER
|
||||
):
|
||||
# Another worker owns this run.
|
||||
return
|
||||
|
||||
# Reset the counters when a new queue starts cleanly; otherwise keep
|
||||
# accumulating so failed rows from an earlier pass stay visible.
|
||||
live_failed = count_failed(user)
|
||||
outstanding = count_outstanding(user)
|
||||
if run.status == UploadRun.STATUS_IDLE and live_failed == 0:
|
||||
run.total = outstanding
|
||||
run.processed = 0
|
||||
run.matched = 0
|
||||
run.failed = 0
|
||||
else:
|
||||
run.total = max(
|
||||
run.total or 0, run.processed + run.failed + outstanding
|
||||
)
|
||||
run.failed = max(run.failed, live_failed)
|
||||
run.status = UploadRun.STATUS_RUNNING
|
||||
run.phase = ""
|
||||
run.error = ""
|
||||
run.started_at = now
|
||||
run.save()
|
||||
|
||||
try:
|
||||
while True:
|
||||
rows = claim_round(user)
|
||||
if not rows:
|
||||
break
|
||||
process_round(run, user, rows)
|
||||
except PipelinePaused as exc:
|
||||
_save_run(run, status=UploadRun.STATUS_PAUSED, phase="", error=str(exc))
|
||||
except Exception as exc: # noqa: BLE001 - surface crashes as a run error
|
||||
logger.exception("Upload pipeline for user %s failed", user_id)
|
||||
_save_run(
|
||||
run,
|
||||
status=UploadRun.STATUS_ERROR,
|
||||
phase="",
|
||||
error=f"The upload pipeline stopped: {exc}",
|
||||
)
|
||||
else:
|
||||
_save_run(run, status=UploadRun.STATUS_IDLE, phase="")
|
||||
|
||||
|
||||
def claim_round(user, size=CLAIM_SIZE):
|
||||
"""Claim up to ``size`` outstanding rows for this worker."""
|
||||
now = timezone.now()
|
||||
with transaction.atomic():
|
||||
rows = list(
|
||||
TempUpload.objects.select_for_update(skip_locked=True)
|
||||
.filter(user=user, status__in=WORK_STATUSES)
|
||||
.filter(OUTSTANDING_Q)
|
||||
.filter(claimed_at__isnull=True, attempts__lt=MAX_ATTEMPTS)
|
||||
.order_by("created_at")[:size]
|
||||
)
|
||||
if rows:
|
||||
TempUpload.objects.filter(pk__in=[row.pk for row in rows]).update(
|
||||
claimed_at=now
|
||||
)
|
||||
for row in rows:
|
||||
row.claimed_at = now
|
||||
return rows
|
||||
|
||||
|
||||
def process_round(run, user, rows):
|
||||
"""Run every phase for one claimed round, then release/account the rows."""
|
||||
ids = [row.pk for row in rows]
|
||||
matched = 0
|
||||
try:
|
||||
matched += md5_phase(run, user, rows)
|
||||
rows = refresh(ids)
|
||||
visual_phase(run, user, rows)
|
||||
rows = refresh(ids)
|
||||
iqdb_phase(run, user, rows)
|
||||
finally:
|
||||
finalize_round(run, ids, matched)
|
||||
|
||||
|
||||
def refresh(ids):
|
||||
return list(TempUpload.objects.filter(pk__in=ids))
|
||||
|
||||
|
||||
def _save_run(run, **fields):
|
||||
for key, value in fields.items():
|
||||
setattr(run, key, value)
|
||||
run.save(
|
||||
update_fields=[*fields.keys(), "updated_at"]
|
||||
)
|
||||
|
||||
|
||||
def md5_phase(run, user, rows):
|
||||
"""One e621 MD5 batch query; matches are imported into the library."""
|
||||
targets = [row for row in rows if row.e621_checked_at is None]
|
||||
if not targets:
|
||||
return 0
|
||||
_save_run(
|
||||
run,
|
||||
phase=UploadRun.PHASE_MD5,
|
||||
total=run.processed + run.failed + count_outstanding(user),
|
||||
)
|
||||
posts = _e621_round(
|
||||
lambda: e621.check_md5_batch(user, [row.md5 for row in targets])
|
||||
)
|
||||
by_md5 = {}
|
||||
for post in posts.values():
|
||||
file_data = post.get("file") or {}
|
||||
md5 = str(file_data.get("md5") or "").strip().lower()
|
||||
if md5:
|
||||
by_md5[md5] = post
|
||||
|
||||
matched = 0
|
||||
now = timezone.now()
|
||||
for row in targets:
|
||||
post = by_md5.get(str(row.md5).strip().lower())
|
||||
if post is None:
|
||||
TempUpload.objects.filter(pk=row.pk).update(
|
||||
e621_checked_at=now, pipeline_error="", updated_at=now
|
||||
)
|
||||
continue
|
||||
try:
|
||||
trimmed = services.trim_e621_post(post)
|
||||
if trimmed is None or not trimmed.get("id"):
|
||||
raise e621.E621Error("e621 returned an unexpected post payload.")
|
||||
TempUpload.objects.filter(pk=row.pk).update(
|
||||
e621_post_id=int(trimmed["id"]),
|
||||
e621_data=trimmed,
|
||||
resolution=TempUpload.RESOLUTION_AUTO_MD5,
|
||||
e621_checked_at=now,
|
||||
pipeline_error="",
|
||||
updated_at=now,
|
||||
)
|
||||
row.refresh_from_db()
|
||||
from .uploads import complete_temp_upload
|
||||
|
||||
complete_temp_upload(row)
|
||||
matched += 1
|
||||
except Exception as exc: # noqa: BLE001 - keep going for other files
|
||||
logger.exception("Could not auto-import staged upload %s", row.pk)
|
||||
record_failure(row, f"Could not finish the upload: {exc}")
|
||||
return matched
|
||||
|
||||
|
||||
def visual_phase(run, user, rows):
|
||||
"""Compare each row's perceptual hashes against the library once."""
|
||||
from .uploads import build_hash_index, match_hashes
|
||||
|
||||
targets = [
|
||||
row
|
||||
for row in rows
|
||||
if row.visual_checked_at is None
|
||||
and row.status in WORK_STATUSES
|
||||
and row.file
|
||||
]
|
||||
if not targets:
|
||||
return
|
||||
_save_run(run, phase=UploadRun.PHASE_VISUAL)
|
||||
index = build_hash_index()
|
||||
now = timezone.now()
|
||||
for row in targets:
|
||||
try:
|
||||
hashes = services.compute_visual_hashes(row.file.path)
|
||||
if not hashes:
|
||||
TempUpload.objects.filter(pk=row.pk).update(
|
||||
visual_checked_at=now, pipeline_error="", updated_at=now
|
||||
)
|
||||
continue
|
||||
matches = match_hashes(hashes, index, user=user)
|
||||
update = {
|
||||
"visual_matches": matches,
|
||||
"visual_checked_at": now,
|
||||
"pipeline_error": "",
|
||||
"updated_at": now,
|
||||
}
|
||||
if matches and row.status == TempUpload.STATUS_PENDING:
|
||||
update["status"] = TempUpload.STATUS_VISUAL_MATCH
|
||||
TempUpload.objects.filter(pk=row.pk).update(**update)
|
||||
except Exception as exc: # noqa: BLE001 - keep going for other files
|
||||
logger.exception("Visual similarity failed for %s", row.pk)
|
||||
record_failure(row, f"Visual similarity failed: {exc}")
|
||||
|
||||
|
||||
def iqdb_phase(run, user, rows):
|
||||
"""Reverse-image search every unresolved image, one e621 query each."""
|
||||
targets = [
|
||||
row
|
||||
for row in rows
|
||||
if row.iqdb_data is None
|
||||
and row.status in WORK_STATUSES
|
||||
and row.file
|
||||
and not is_video(row.original_filename)
|
||||
]
|
||||
if not targets:
|
||||
return
|
||||
_save_run(run, phase=UploadRun.PHASE_IQDB)
|
||||
heartbeat_at = time.monotonic()
|
||||
for row in targets:
|
||||
# Keep the run row fresh: a 75-file IQDB round takes minutes and must
|
||||
# not look like a dead worker to another request.
|
||||
if time.monotonic() - heartbeat_at > 30:
|
||||
UploadRun.objects.filter(pk=run.pk).update(updated_at=timezone.now())
|
||||
heartbeat_at = time.monotonic()
|
||||
try:
|
||||
raw = e621.iqdb_search(user, row.file.path)
|
||||
results = normalize_iqdb_results(user, raw)
|
||||
except e621.E621AuthError as exc:
|
||||
record_failure(row, str(exc))
|
||||
raise PipelinePaused(
|
||||
"e621 rejected the credentials — fix them in Account and retry."
|
||||
) from exc
|
||||
except e621.E621RateLimited as exc:
|
||||
record_failure(row, str(exc))
|
||||
raise PipelinePaused(
|
||||
"e621 is throttling IQDB right now; the queue will resume."
|
||||
) from exc
|
||||
except Exception as exc: # noqa: BLE001 - keep going for other files
|
||||
logger.exception("IQDB search failed for %s", row.pk)
|
||||
record_failure(row, f"IQDB search failed: {exc}")
|
||||
continue
|
||||
now = timezone.now()
|
||||
update = {
|
||||
"iqdb_data": results,
|
||||
"pipeline_error": "",
|
||||
"updated_at": now,
|
||||
}
|
||||
if results and row.status == TempUpload.STATUS_PENDING:
|
||||
update["status"] = TempUpload.STATUS_VISUAL_MATCH
|
||||
TempUpload.objects.filter(pk=row.pk).update(**update)
|
||||
|
||||
|
||||
def record_failure(row, message):
|
||||
"""Count one failed attempt against a row and queue it for a retry."""
|
||||
TempUpload.objects.filter(pk=row.pk).update(
|
||||
attempts=F("attempts") + 1,
|
||||
pipeline_error=str(message)[:2000],
|
||||
claimed_at=None,
|
||||
updated_at=timezone.now(),
|
||||
)
|
||||
|
||||
|
||||
def finalize_round(run, ids, matched):
|
||||
"""Account finished/failed rows and release the rest of the claims."""
|
||||
rows = refresh(ids)
|
||||
finished = {row.pk for row in rows if is_finished(row)}
|
||||
failed = {row.pk for row in rows if row.attempts >= MAX_ATTEMPTS}
|
||||
# Release every claim: finished rows must not keep looking "processing"
|
||||
# to the board, and unfinished rows are re-queued for the next run.
|
||||
release = [row.pk for row in rows if row.claimed_at is not None]
|
||||
if release:
|
||||
TempUpload.objects.filter(pk__in=release).update(claimed_at=None)
|
||||
_save_run(
|
||||
run,
|
||||
# Completed rows are deleted as they are imported, so they cannot be
|
||||
# seen in the refreshed rows; count the matches explicitly.
|
||||
processed=run.processed + len(finished) + matched,
|
||||
failed=run.failed + len(failed - finished),
|
||||
matched=run.matched + matched,
|
||||
)
|
||||
|
||||
|
||||
def _e621_round(task):
|
||||
"""Run a round-level e621 call, retrying through rate limits."""
|
||||
last_error = None
|
||||
for attempt in range(ROUND_ATTEMPTS):
|
||||
try:
|
||||
return task()
|
||||
except e621.E621AuthError:
|
||||
raise
|
||||
except (e621.E621RateLimited, e621.E621Error) as exc:
|
||||
last_error = exc
|
||||
if attempt + 1 >= ROUND_ATTEMPTS:
|
||||
break
|
||||
delay = ROUND_RETRY_SECONDS * (attempt + 1)
|
||||
logger.info("e621 round failed (%s); retrying in %ss", exc, delay)
|
||||
time.sleep(delay)
|
||||
raise PipelinePaused(
|
||||
f"e621 is not answering right now ({last_error}); the queue will resume."
|
||||
) from last_error
|
||||
|
||||
|
||||
def flatten_tag_preview(tags, limit=8):
|
||||
"""First few tag names from a modern post payload, like the SPA shows."""
|
||||
if not isinstance(tags, dict):
|
||||
return []
|
||||
out = []
|
||||
for values in tags.values():
|
||||
if not isinstance(values, list):
|
||||
continue
|
||||
for tag in values:
|
||||
if isinstance(tag, str) and tag not in out:
|
||||
out.append(tag)
|
||||
if len(out) >= limit:
|
||||
return out
|
||||
return out
|
||||
|
||||
|
||||
def _legacy_iqdb_post(entry):
|
||||
"""Unwrap the post payload embedded in a legacy IQDB match."""
|
||||
post = entry.get("post")
|
||||
if not isinstance(post, dict):
|
||||
return {}
|
||||
inner = post.get("posts")
|
||||
return inner if isinstance(inner, dict) else post
|
||||
|
||||
|
||||
def normalize_iqdb_results(user, raw_results):
|
||||
"""Shape legacy IQDB matches like the SPA's E621IqdbCandidate entries.
|
||||
|
||||
The IQDB payload carries little post data, so candidates are enriched
|
||||
with one batched ``id:`` lookup before they are stored.
|
||||
"""
|
||||
candidates = []
|
||||
for entry in (raw_results or [])[:10]:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
post = _legacy_iqdb_post(entry)
|
||||
post_id = entry.get("post_id")
|
||||
if not isinstance(post_id, int):
|
||||
post_id = post.get("id")
|
||||
score = entry.get("score")
|
||||
candidates.append(
|
||||
{
|
||||
"post_id": post_id if isinstance(post_id, int) else None,
|
||||
"score": float(score) if isinstance(score, (int, float)) else None,
|
||||
"preview_url": None,
|
||||
"rating": (
|
||||
post.get("rating") if isinstance(post.get("rating"), str) else None
|
||||
),
|
||||
"md5": post.get("md5") if isinstance(post.get("md5"), str) else None,
|
||||
"score_total": (
|
||||
post.get("score") if isinstance(post.get("score"), int) else None
|
||||
),
|
||||
"fav_count": (
|
||||
post.get("fav_count")
|
||||
if isinstance(post.get("fav_count"), int)
|
||||
else None
|
||||
),
|
||||
"width": (
|
||||
post.get("image_width")
|
||||
if isinstance(post.get("image_width"), int)
|
||||
else None
|
||||
),
|
||||
"height": (
|
||||
post.get("image_height")
|
||||
if isinstance(post.get("image_height"), int)
|
||||
else None
|
||||
),
|
||||
"tags_preview": [],
|
||||
}
|
||||
)
|
||||
|
||||
ids = [entry["post_id"] for entry in candidates if entry["post_id"]]
|
||||
if not ids:
|
||||
return services.sanitize_iqdb_results(candidates)
|
||||
|
||||
try:
|
||||
posts = e621.fetch_posts_by_ids(user, ids)
|
||||
except e621.E621Error as exc:
|
||||
# Candidates without enrichment still show up; keep them.
|
||||
logger.info("Could not enrich IQDB candidates: %s", exc)
|
||||
posts = []
|
||||
by_id = {post.get("id"): post for post in posts}
|
||||
|
||||
for entry in candidates:
|
||||
post = by_id.get(entry["post_id"])
|
||||
if not isinstance(post, dict):
|
||||
continue
|
||||
file_data = post.get("file") or {}
|
||||
preview = post.get("preview") or {}
|
||||
score = post.get("score") or {}
|
||||
entry["preview_url"] = preview.get("url") or entry["preview_url"]
|
||||
entry["rating"] = post.get("rating") or entry["rating"]
|
||||
entry["md5"] = file_data.get("md5") or entry["md5"]
|
||||
if isinstance(score, dict):
|
||||
entry["score_total"] = score.get("total")
|
||||
entry["fav_count"] = post.get("fav_count")
|
||||
entry["width"] = file_data.get("width")
|
||||
entry["height"] = file_data.get("height")
|
||||
entry["tags_preview"] = flatten_tag_preview(post.get("tags"))
|
||||
|
||||
return services.sanitize_iqdb_results(candidates)
|
||||
@@ -16,7 +16,7 @@ from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core import signing
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.http import Http404
|
||||
from django.utils import timezone
|
||||
from rest_framework import mixins, status, viewsets
|
||||
@@ -28,32 +28,39 @@ from rest_framework.response import Response
|
||||
from . import services
|
||||
from .models import MediaItem, TempUpload
|
||||
from .permissions import CanUpload
|
||||
from .serializers import TempUploadSerializer
|
||||
from .tools import HASH_FIELDS, hashes_similarity
|
||||
from .serializers import TempUploadListSerializer, TempUploadSerializer
|
||||
from .signing_urls import load_payload
|
||||
from .tools import HASH_FIELDS, hashed_items, hashes_similarity
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def find_library_matches(path, limit=10, user=None, request=None):
|
||||
"""Library items visually similar to a staged file."""
|
||||
hashes = services.compute_visual_hashes(path)
|
||||
if not hashes:
|
||||
return []
|
||||
def build_hash_index():
|
||||
"""Library hash mappings for similarity scans, loaded once per batch.
|
||||
|
||||
Only items that actually carry perceptual hashes are included; the old
|
||||
per-file scan walked every row (including videos and unchecked items).
|
||||
"""
|
||||
algorithms = list(HASH_FIELDS)
|
||||
return [
|
||||
(item, {field: getattr(item, field, "") for field in algorithms})
|
||||
for item in hashed_items(algorithms)
|
||||
]
|
||||
|
||||
|
||||
def match_hashes(hashes, index, limit=10, user=None, request=None):
|
||||
"""Library items whose perceptual hashes are close to ``hashes``."""
|
||||
algorithms = list(HASH_FIELDS)
|
||||
threshold = settings.VISUAL_MATCH_THRESHOLD
|
||||
matches = []
|
||||
for item in MediaItem.objects.prefetch_related("locations"):
|
||||
similarity = hashes_similarity(
|
||||
hashes,
|
||||
{field: getattr(item, field, "") for field in algorithms},
|
||||
algorithms,
|
||||
threshold,
|
||||
)
|
||||
for item, item_hashes in index:
|
||||
similarity = hashes_similarity(hashes, item_hashes, algorithms, threshold)
|
||||
if similarity is None:
|
||||
continue
|
||||
location = item.locations.first()
|
||||
matches.append(
|
||||
{
|
||||
"item_id": item.id,
|
||||
"j_id": f"J-{item.id}",
|
||||
"filename": Path(location.rel_path).name if location else item.md5,
|
||||
"similarity": round(similarity * 100, 1),
|
||||
@@ -66,6 +73,20 @@ def find_library_matches(path, limit=10, user=None, request=None):
|
||||
return matches[:limit]
|
||||
|
||||
|
||||
def find_library_matches(path, limit=10, user=None, request=None, index=None):
|
||||
"""Library items visually similar to a staged file.
|
||||
|
||||
Pass a prebuilt ``index`` (see build_hash_index) to reuse it across a
|
||||
whole batch instead of rescanning the library per file.
|
||||
"""
|
||||
hashes = services.compute_visual_hashes(path)
|
||||
if not hashes:
|
||||
return []
|
||||
if index is None:
|
||||
index = build_hash_index()
|
||||
return match_hashes(hashes, index, limit=limit, user=user, request=request)
|
||||
|
||||
|
||||
def complete_temp_upload(temp, download_url=None):
|
||||
"""Index the upload into the library.
|
||||
|
||||
@@ -107,6 +128,12 @@ def complete_temp_upload(temp, download_url=None):
|
||||
services.ensure_visual_hashes(item)
|
||||
temp.file.delete(save=False)
|
||||
|
||||
# Warm the preview while the import is still off the request path.
|
||||
try:
|
||||
services.ensure_thumbnail(item)
|
||||
except Exception: # noqa: BLE001 - a preview must not fail the import
|
||||
logger.exception("Could not warm the thumbnail for J-%s", item.id)
|
||||
|
||||
temp.library_item = item
|
||||
temp.status = TempUpload.STATUS_COMPLETED
|
||||
|
||||
@@ -138,6 +165,13 @@ def complete_temp_upload(temp, download_url=None):
|
||||
item.save(update_fields=update_fields + ["updated_at"])
|
||||
|
||||
temp.save()
|
||||
from .upload_pipeline import record_completion
|
||||
|
||||
record_completion(temp, item)
|
||||
# The board learns about completions from the live feed, so the record is
|
||||
# deleted as soon as the file is indexed: nothing left to dismiss. Delete
|
||||
# through the queryset so callers keep ``temp.pk`` for their response.
|
||||
TempUpload.objects.filter(pk=temp.pk).delete()
|
||||
return item
|
||||
|
||||
|
||||
@@ -150,14 +184,47 @@ class TempUploadViewSet(
|
||||
serializer_class = TempUploadSerializer
|
||||
permission_classes = [CanUpload]
|
||||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||||
# Unpaginated: the board shows every staged upload (69-file batches were
|
||||
# silently cut to the API's 48-item page).
|
||||
pagination_class = None
|
||||
http_method_names = ["get", "post", "delete", "head", "options"]
|
||||
|
||||
def get_serializer_class(self):
|
||||
# The board polls the list, so its payload stays small; the metadata
|
||||
# modal fetches the full row from the detail endpoint.
|
||||
if self.action == "list":
|
||||
return TempUploadListSerializer
|
||||
return TempUploadSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = TempUpload.objects.select_related("library_item")
|
||||
user = self.request.user
|
||||
if not user.is_app_staff:
|
||||
queryset = queryset.filter(user=user)
|
||||
return queryset
|
||||
# Staged uploads are private: everyone, staff included, only sees
|
||||
# their own board. (The file action still lets staff read bytes by id
|
||||
# for support purposes.)
|
||||
return TempUpload.objects.select_related("library_item").filter(
|
||||
user=self.request.user
|
||||
)
|
||||
|
||||
def _completed_payload(self, temp, item):
|
||||
"""Synthetic row for an upload that is indexed immediately.
|
||||
|
||||
Duplicates and resolved uploads never leave a board record; the SPA
|
||||
turns this response (or the live completion feed) into a "J-x
|
||||
uploaded" card that lives only in the page session.
|
||||
"""
|
||||
return {
|
||||
"temp_id": str(temp.pk),
|
||||
"original_filename": temp.original_filename,
|
||||
"md5": temp.md5,
|
||||
"size": temp.size,
|
||||
"status": TempUpload.STATUS_COMPLETED,
|
||||
"resolution": temp.resolution,
|
||||
"e621_post_id": temp.e621_post_id,
|
||||
"library_j_id": f"J-{item.id}",
|
||||
"file_url": None,
|
||||
"preview_url": services.signed_media_url(
|
||||
item, self.request.user, "thumbnail", request=self.request
|
||||
),
|
||||
}
|
||||
|
||||
def create(self, request):
|
||||
upload = request.FILES.get("file")
|
||||
@@ -186,33 +253,190 @@ class TempUploadViewSet(
|
||||
temp.resolution = TempUpload.RESOLUTION_DUPLICATE
|
||||
temp.library_item = existing
|
||||
temp.file.delete(save=False)
|
||||
else:
|
||||
matches = find_library_matches(
|
||||
temp.file.path, user=request.user, request=request
|
||||
)
|
||||
if matches:
|
||||
temp.visual_matches = matches
|
||||
temp.status = TempUpload.STATUS_VISUAL_MATCH
|
||||
temp.save()
|
||||
from .upload_pipeline import record_completion
|
||||
|
||||
record_completion(temp, existing)
|
||||
payload = self._completed_payload(temp, existing)
|
||||
TempUpload.objects.filter(pk=temp.pk).delete()
|
||||
return Response(payload, status=status.HTTP_201_CREATED)
|
||||
# Visual similarity and IQDB run in the background pipeline so a large
|
||||
# batch uploads at full speed and the work survives the browser.
|
||||
temp.save()
|
||||
# Kick the server-side pipeline; staging no longer waits on e621 and
|
||||
# the work continues even if the browser navigates away.
|
||||
from .upload_pipeline import start_pipeline
|
||||
|
||||
start_pipeline(request.user)
|
||||
return Response(
|
||||
self.get_serializer(temp).data, status=status.HTTP_201_CREATED
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get", "head"], permission_classes=[AllowAny])
|
||||
@action(detail=True, methods=["post"], url_path="visual-match")
|
||||
def visual_match(self, request, pk=None):
|
||||
"""Run the local visual-similarity pass for one staged upload."""
|
||||
temp = self.get_object()
|
||||
if temp.status == TempUpload.STATUS_COMPLETED:
|
||||
return Response(
|
||||
{"detail": "This upload is already in the library."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if not temp.file:
|
||||
return Response(
|
||||
{"detail": "The staged file is missing."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
matches = find_library_matches(
|
||||
temp.file.path, user=request.user, request=request
|
||||
)
|
||||
temp.visual_matches = matches
|
||||
if matches and temp.status == TempUpload.STATUS_PENDING:
|
||||
temp.status = TempUpload.STATUS_VISUAL_MATCH
|
||||
temp.save(update_fields=["visual_matches", "status", "updated_at"])
|
||||
return Response(self.get_serializer(temp).data)
|
||||
|
||||
@action(detail=False, methods=["get"])
|
||||
def status(self, request):
|
||||
"""Cheap pipeline state for the shell indicator and the upload page."""
|
||||
from .upload_pipeline import status_payload
|
||||
|
||||
return Response(status_payload(request.user, request=request))
|
||||
|
||||
@action(detail=False, methods=["post"])
|
||||
def process(self, request):
|
||||
"""Start (or resume) the pipeline for the caller's staged uploads.
|
||||
|
||||
Idempotent: the client calls this after staging files, on page load
|
||||
and when a paused run should be retried.
|
||||
"""
|
||||
from .upload_pipeline import start_pipeline, status_payload
|
||||
|
||||
start_pipeline(request.user)
|
||||
return Response(status_payload(request.user, request=request))
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def retry(self, request, pk=None):
|
||||
"""Queue one staged upload for another pipeline pass."""
|
||||
from .upload_pipeline import start_pipeline, status_payload
|
||||
|
||||
temp = self.get_object()
|
||||
if temp.status == TempUpload.STATUS_COMPLETED:
|
||||
return Response(
|
||||
{"detail": "This upload is already in the library."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if not temp.file:
|
||||
return Response(
|
||||
{"detail": "The staged file is missing."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
update = {
|
||||
"claimed_at": None,
|
||||
"attempts": 0,
|
||||
"pipeline_error": "",
|
||||
"updated_at": timezone.now(),
|
||||
}
|
||||
# An explicit phase re-runs that one check even if it already ran.
|
||||
phase = str(request.data.get("phase") or "").strip()
|
||||
if phase == "md5":
|
||||
update["e621_checked_at"] = None
|
||||
elif phase == "visual":
|
||||
update["visual_matches"] = None
|
||||
update["visual_checked_at"] = None
|
||||
elif phase == "iqdb":
|
||||
update["iqdb_data"] = None
|
||||
if temp.status == TempUpload.STATUS_ERROR:
|
||||
# A failed import has to go through the MD5 phase again so the
|
||||
# completion is retried; other errors only re-run missing phases.
|
||||
update["status"] = TempUpload.STATUS_PENDING
|
||||
update["e621_checked_at"] = None
|
||||
elif temp.status not in (
|
||||
TempUpload.STATUS_PENDING,
|
||||
TempUpload.STATUS_VISUAL_MATCH,
|
||||
):
|
||||
update["status"] = TempUpload.STATUS_PENDING
|
||||
TempUpload.objects.filter(pk=temp.pk).update(**update)
|
||||
start_pipeline(request.user)
|
||||
return Response(status_payload(request.user, request=request))
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="retry-all")
|
||||
def retry_all(self, request):
|
||||
"""Queue every retryable staged upload for another pipeline pass."""
|
||||
from .upload_pipeline import start_pipeline, status_payload
|
||||
|
||||
now = timezone.now()
|
||||
retryable = self.get_queryset().filter(
|
||||
status__in=[
|
||||
TempUpload.STATUS_PENDING,
|
||||
TempUpload.STATUS_VISUAL_MATCH,
|
||||
TempUpload.STATUS_ERROR,
|
||||
]
|
||||
)
|
||||
retryable.exclude(file="").update(
|
||||
claimed_at=None,
|
||||
attempts=0,
|
||||
pipeline_error="",
|
||||
updated_at=now,
|
||||
)
|
||||
retryable.exclude(file="").filter(status=TempUpload.STATUS_ERROR).update(
|
||||
status=TempUpload.STATUS_PENDING,
|
||||
e621_checked_at=None,
|
||||
)
|
||||
start_pipeline(request.user)
|
||||
return Response(status_payload(request.user, request=request))
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="discard-bulk")
|
||||
def discard_bulk(self, request):
|
||||
"""Discard many staged uploads in one request (the board's "all")."""
|
||||
ids = request.data.get("temp_ids")
|
||||
if not isinstance(ids, list) or not ids:
|
||||
return Response(
|
||||
{"detail": "temp_ids must be a non-empty list."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if len(ids) > 1000:
|
||||
return Response(
|
||||
{"detail": "Too many ids in one request (max 1000)."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
values = list(dict.fromkeys(str(value) for value in ids))
|
||||
try:
|
||||
queryset = self.get_queryset().filter(pk__in=values)
|
||||
except (ValidationError, ValueError):
|
||||
return Response(
|
||||
{"detail": "One or more temp_ids are not valid upload ids."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
by_id = {str(temp.pk): temp for temp in queryset}
|
||||
|
||||
discarded: list[str] = []
|
||||
errors: list[dict[str, str]] = []
|
||||
for value in values:
|
||||
temp = by_id.get(value)
|
||||
if temp is None:
|
||||
errors.append({"temp_id": value, "error": "not found"})
|
||||
continue
|
||||
try:
|
||||
self.perform_destroy(temp)
|
||||
discarded.append(value)
|
||||
except Exception as exc: # noqa: BLE001 - report per-file failures
|
||||
logger.exception("Could not discard staged upload %s", value)
|
||||
errors.append({"temp_id": value, "error": str(exc)})
|
||||
return Response({"discarded": discarded, "errors": errors})
|
||||
|
||||
@action(
|
||||
detail=True,
|
||||
methods=["get", "head"],
|
||||
permission_classes=[AllowAny],
|
||||
throttle_classes=[],
|
||||
)
|
||||
def file(self, request, pk=None):
|
||||
"""Serve the staged file; accepts a signed URL for media tags."""
|
||||
user = request.user if request.user.is_authenticated else None
|
||||
if user is None:
|
||||
signature = request.query_params.get("sig")
|
||||
if signature:
|
||||
try:
|
||||
payload = signing.loads(
|
||||
signature,
|
||||
salt=services.UPLOAD_FILE_SALT,
|
||||
max_age=86400,
|
||||
)
|
||||
except signing.BadSignature:
|
||||
payload = None
|
||||
payload = load_payload(signature, services.UPLOAD_FILE_SALT)
|
||||
if payload and str(payload.get("temp")) == str(pk):
|
||||
user = (
|
||||
get_user_model()
|
||||
@@ -313,7 +537,7 @@ class TempUploadViewSet(
|
||||
|
||||
temp.save()
|
||||
try:
|
||||
complete_temp_upload(temp, download_url=download_url)
|
||||
item = complete_temp_upload(temp, download_url=download_url)
|
||||
except Exception as exc: # noqa: BLE001 - report completion failures
|
||||
logger.exception("Could not complete staged upload %s", temp.id)
|
||||
temp.status = TempUpload.STATUS_ERROR
|
||||
@@ -322,8 +546,146 @@ class TempUploadViewSet(
|
||||
{"detail": f"Could not finish the upload: {exc}"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
temp.refresh_from_db()
|
||||
return Response(self.get_serializer(temp).data)
|
||||
return Response(self._completed_payload(temp, item))
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="link-bulk")
|
||||
def link_bulk(self, request):
|
||||
"""Attach e621 posts to many staged uploads in one request.
|
||||
|
||||
The upload board's MD5 phase sends one posts.json query per 75 files
|
||||
and hands the matches over here, so a whole batch moves into the
|
||||
library in one update instead of one request per file.
|
||||
"""
|
||||
links = request.data.get("links")
|
||||
if not isinstance(links, list) or not links:
|
||||
return Response(
|
||||
{"detail": "links must be a non-empty list."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
ids = []
|
||||
for link in links:
|
||||
if not isinstance(link, dict) or link.get("temp_id") is None:
|
||||
return Response(
|
||||
{"detail": "Each link needs a temp_id."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
ids.append(str(link["temp_id"]))
|
||||
try:
|
||||
queryset = self.get_queryset().filter(pk__in=ids)
|
||||
except (ValidationError, ValueError):
|
||||
return Response(
|
||||
{"detail": "One or more temp_ids are not valid upload ids."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
by_id = {str(temp.id): temp for temp in queryset}
|
||||
|
||||
updated = []
|
||||
errors = []
|
||||
for link in links:
|
||||
temp_id = str(link["temp_id"])
|
||||
temp = by_id.get(temp_id)
|
||||
if temp is None:
|
||||
errors.append({"temp_id": temp_id, "error": "not found"})
|
||||
continue
|
||||
if temp.status == TempUpload.STATUS_COMPLETED:
|
||||
errors.append({"temp_id": temp_id, "error": "already in the library"})
|
||||
continue
|
||||
trimmed = services.trim_e621_post(link.get("post"))
|
||||
post_id = link.get("post_id")
|
||||
has_numeric_id = post_id is not None and str(post_id).isdigit()
|
||||
if trimmed is None and not has_numeric_id:
|
||||
errors.append({"temp_id": temp_id, "error": "no post payload"})
|
||||
continue
|
||||
if trimmed is not None and trimmed.get("id") is not None:
|
||||
temp.e621_post_id = int(trimmed["id"])
|
||||
elif has_numeric_id:
|
||||
temp.e621_post_id = int(post_id)
|
||||
temp.e621_data = trimmed
|
||||
temp.resolution = (
|
||||
TempUpload.RESOLUTION_AUTO_MD5
|
||||
if link.get("auto", True)
|
||||
else TempUpload.RESOLUTION_LINKED
|
||||
)
|
||||
# An MD5 match means the staged file is byte-identical, so there
|
||||
# is nothing to download from e621; only link a remote URL when
|
||||
# the post's file differs.
|
||||
file_data = (trimmed or {}).get("file") or {}
|
||||
candidate_url = str(
|
||||
link.get("file_url") or file_data.get("url") or ""
|
||||
).strip()
|
||||
post_md5 = str(file_data.get("md5") or "").strip().lower()
|
||||
if candidate_url and post_md5 and post_md5 == temp.md5.lower():
|
||||
candidate_url = ""
|
||||
temp.save()
|
||||
try:
|
||||
item = complete_temp_upload(temp, download_url=candidate_url or None)
|
||||
except Exception as exc: # noqa: BLE001 - report per-file failures
|
||||
logger.exception("Could not complete staged upload %s", temp.id)
|
||||
temp.status = TempUpload.STATUS_ERROR
|
||||
temp.save(update_fields=["status", "updated_at"])
|
||||
errors.append({"temp_id": temp_id, "error": str(exc)})
|
||||
continue
|
||||
updated.append(self._completed_payload(temp, item))
|
||||
|
||||
return Response({"updated": updated, "errors": errors})
|
||||
|
||||
@action(detail=False, methods=["post"], url_path="resolve-bulk")
|
||||
def resolve_bulk(self, request):
|
||||
"""Move many staged uploads into the library with one rating.
|
||||
|
||||
The upload board's bulk tool: pick a rating, tick pending files, and
|
||||
they are all resolved as custom entries (their staged tags/notes are
|
||||
kept). Own rows only, like the rest of the viewset.
|
||||
"""
|
||||
ids = request.data.get("temp_ids")
|
||||
if not isinstance(ids, list) or not ids:
|
||||
return Response(
|
||||
{"detail": "temp_ids must be a non-empty list."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
rating = str(request.data.get("rating") or "").strip()
|
||||
if rating not in {"s", "q", "e"}:
|
||||
return Response(
|
||||
{"detail": "rating must be one of s, q or e."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
queryset = self.get_queryset().filter(
|
||||
pk__in=[str(value) for value in ids]
|
||||
)
|
||||
except (ValidationError, ValueError):
|
||||
return Response(
|
||||
{"detail": "One or more ids are not valid upload ids."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
resolved: list[str] = []
|
||||
errors: list[dict[str, str]] = []
|
||||
for temp in queryset:
|
||||
if temp.status == TempUpload.STATUS_COMPLETED:
|
||||
errors.append(
|
||||
{"temp_id": str(temp.id), "error": "already in the library"}
|
||||
)
|
||||
continue
|
||||
temp.custom_rating = rating
|
||||
temp.resolution = TempUpload.RESOLUTION_CUSTOM
|
||||
temp.save(update_fields=["custom_rating", "resolution", "updated_at"])
|
||||
try:
|
||||
complete_temp_upload(temp)
|
||||
except Exception as exc: # noqa: BLE001 - report per-file failures
|
||||
logger.exception("Could not complete staged upload %s", temp.id)
|
||||
temp.status = TempUpload.STATUS_ERROR
|
||||
temp.save(update_fields=["status", "updated_at"])
|
||||
errors.append({"temp_id": str(temp.id), "error": str(exc)})
|
||||
continue
|
||||
resolved.append(str(temp.id))
|
||||
|
||||
found = {str(temp.id) for temp in queryset}
|
||||
for value in sorted({str(value) for value in ids} - found):
|
||||
errors.append({"temp_id": value, "error": "not found"})
|
||||
|
||||
return Response({"resolved": resolved, "errors": errors})
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if instance.file:
|
||||
|
||||
@@ -17,6 +17,7 @@ from .views import (
|
||||
DownloadTaskViewSet,
|
||||
MatchTaskViewSet,
|
||||
MediaItemViewSet,
|
||||
RandomItemView,
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
@@ -28,6 +29,7 @@ router.register("similarity", SimilarityCheckViewSet, basename="similarity")
|
||||
|
||||
urlpatterns = [
|
||||
path("", include(router.urls)),
|
||||
path("random/", RandomItemView.as_view(), name="random_item"),
|
||||
path("online/file/", ClientDownloadView.as_view(), name="client_download"),
|
||||
path(
|
||||
"duplicates/md5/",
|
||||
|
||||
@@ -5,19 +5,21 @@ from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core import signing
|
||||
from django.db.models import Min, Q
|
||||
from django.http import Http404, StreamingHttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils import timezone
|
||||
from django.utils.text import get_valid_filename
|
||||
from rest_framework import mixins, status, viewsets
|
||||
from rest_framework.authentication import TokenAuthentication
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticatedOrReadOnly
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.throttling import ScopedRateThrottle
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.accounts.auth import GreetingTokenAuthentication
|
||||
|
||||
from . import e621, matching, services
|
||||
from .downloads import reap_stale_downloads, start_download_task
|
||||
from .matching import reap_stale_match_tasks, start_match_task
|
||||
@@ -28,6 +30,7 @@ from .serializers import (
|
||||
MatchTaskSerializer,
|
||||
MediaItemSerializer,
|
||||
)
|
||||
from .signing_urls import load_payload
|
||||
|
||||
LIST_ORDERINGS = {"name", "-name", "size", "-size", "created_at", "-created_at"}
|
||||
MD5_RE = re.compile(r"[0-9a-fA-F]{32}")
|
||||
@@ -129,11 +132,8 @@ class MediaItemViewSet(
|
||||
signature = request.query_params.get("sig")
|
||||
if not signature:
|
||||
return None
|
||||
try:
|
||||
payload = signing.loads(
|
||||
signature, salt=services.MEDIA_FILE_SALT, max_age=86400
|
||||
)
|
||||
except signing.BadSignature:
|
||||
payload = load_payload(signature, services.MEDIA_FILE_SALT)
|
||||
if payload is None:
|
||||
return None
|
||||
if payload.get("action") != action_name:
|
||||
return None
|
||||
@@ -145,7 +145,7 @@ class MediaItemViewSet(
|
||||
return item
|
||||
return self.get_object()
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
@action(detail=True, methods=["get"], throttle_classes=[])
|
||||
def raw(self, request, pk=None):
|
||||
item = self._media_object(request, "raw")
|
||||
location = item.locations.first()
|
||||
@@ -155,10 +155,14 @@ class MediaItemViewSet(
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
return services.serve_file(
|
||||
request, location.path, download=request.query_params.get("download") == "1"
|
||||
request,
|
||||
location.path,
|
||||
download=request.query_params.get("download") == "1",
|
||||
max_age=services.MEDIA_CACHE_SECONDS,
|
||||
immutable=True,
|
||||
)
|
||||
|
||||
@action(detail=True, methods=["get"])
|
||||
@action(detail=True, methods=["get"], throttle_classes=[])
|
||||
def thumbnail(self, request, pk=None):
|
||||
item = self._media_object(request, "thumbnail")
|
||||
location = item.locations.first()
|
||||
@@ -170,13 +174,26 @@ class MediaItemViewSet(
|
||||
path = Path(location.path)
|
||||
if path.suffix.lower() in services.VIDEO_EXTENSIONS:
|
||||
thumbnail = services.generate_video_thumbnail(item.md5, path)
|
||||
if thumbnail is None:
|
||||
return Response(
|
||||
{"detail": "Thumbnail unavailable."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
return services.serve_file(request, thumbnail)
|
||||
return services.serve_file(request, path)
|
||||
else:
|
||||
thumbnail = services.generate_image_thumbnail(item.md5, path)
|
||||
if thumbnail is not None:
|
||||
return services.serve_file(
|
||||
request,
|
||||
thumbnail,
|
||||
max_age=services.MEDIA_CACHE_SECONDS,
|
||||
immutable=True,
|
||||
)
|
||||
if path.suffix.lower() in services.VIDEO_EXTENSIONS:
|
||||
return Response(
|
||||
{"detail": "Thumbnail unavailable."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
return services.serve_file(
|
||||
request,
|
||||
path,
|
||||
max_age=services.MEDIA_CACHE_SECONDS,
|
||||
immutable=True,
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["post"], permission_classes=[AllowAny])
|
||||
def lookup(self, request):
|
||||
@@ -520,6 +537,103 @@ class MatchTaskViewSet(
|
||||
return Response({"success": True})
|
||||
|
||||
|
||||
class RandomItemView(APIView):
|
||||
"""A random library image, optionally filtered by rating.
|
||||
|
||||
Two kinds of clients use this:
|
||||
|
||||
* the SPA's Random page, which renders the returned URL, and
|
||||
* shell greeting scripts (fish_greeting) that fetch the URL with
|
||||
fastfetch in a terminal.
|
||||
|
||||
Fastfetch mode — ``?fastfetch=1`` or a User-Agent containing "fastfetch"
|
||||
— only considers png/jpg/gif files, because that is what those terminals
|
||||
display. Responses always carry a signed absolute URL (minted for the
|
||||
requesting user) so image viewers can load it without auth headers;
|
||||
guests get unsigned URLs for guest-visible items only.
|
||||
|
||||
Accepts the normal API token *and* the scope-limited greeting tokens
|
||||
(``j621r_…``), which work here and nowhere else.
|
||||
"""
|
||||
|
||||
authentication_classes = [GreetingTokenAuthentication, TokenAuthentication]
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
FASTFETCH_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif"}
|
||||
|
||||
def get(self, request):
|
||||
fastfetch = request.query_params.get("fastfetch", "").lower() in {
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
} or "fastfetch" in (request.META.get("HTTP_USER_AGENT") or "").lower()
|
||||
|
||||
extensions = (
|
||||
self.FASTFETCH_EXTENSIONS
|
||||
if fastfetch
|
||||
else services.IMAGE_EXTENSIONS
|
||||
)
|
||||
|
||||
queryset = MediaItem.objects.prefetch_related("locations")
|
||||
if not request.user.is_authenticated:
|
||||
queryset = queryset.filter(hidden_from_guests=False)
|
||||
|
||||
ratings = [
|
||||
value
|
||||
for value in request.query_params.get("rating", "").split(",")
|
||||
if value in {"s", "q", "e"}
|
||||
]
|
||||
if ratings:
|
||||
queryset = queryset.filter(rating__in=ratings)
|
||||
|
||||
# Any copy with an allowed extension qualifies. ORDER BY RAND() is
|
||||
# fine for a personal library (same trade-off as the duplicates page).
|
||||
suffixes = "|".join(extension.lstrip(".") for extension in sorted(extensions))
|
||||
item = (
|
||||
queryset.filter(locations__rel_path__iregex=rf"\.({suffixes})$")
|
||||
.distinct()
|
||||
.order_by("?")
|
||||
.first()
|
||||
)
|
||||
if item is None:
|
||||
return Response(
|
||||
{"detail": "No image matches those filters."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
location = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in item.locations.all()
|
||||
if Path(candidate.rel_path).suffix.lower() in extensions
|
||||
),
|
||||
item.locations.first(),
|
||||
)
|
||||
url = services.signed_media_url(item, request.user, "raw", request=request)
|
||||
return Response(
|
||||
{
|
||||
"j_id": f"J-{item.id}",
|
||||
"md5": item.md5,
|
||||
"filename": Path(location.rel_path).name if location else item.md5,
|
||||
"extension": (
|
||||
Path(location.rel_path).suffix.lower().lstrip(".")
|
||||
if location
|
||||
else ""
|
||||
),
|
||||
"kind": "image",
|
||||
"rating": item.rating or "",
|
||||
"size": item.size,
|
||||
"e621_post_id": item.e621_post_id,
|
||||
"url": url,
|
||||
"download_url": f"{url}{'&' if '?' in url else '?'}download=1",
|
||||
"thumbnail_url": services.signed_media_url(
|
||||
item, request.user, "thumbnail", request=request
|
||||
),
|
||||
"fastfetch": fastfetch,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ClientDownloadView(APIView):
|
||||
"""Stream an e621 file straight to the browser (no library write)."""
|
||||
|
||||
@@ -562,7 +676,7 @@ class ClientDownloadView(APIView):
|
||||
"Content-Type", "application/octet-stream"
|
||||
)
|
||||
name = get_valid_filename(
|
||||
filename or Path(parsed.path).name or "download"
|
||||
filename or Path(urlparse(url).path).name or "download"
|
||||
)
|
||||
|
||||
def stream():
|
||||
|
||||
@@ -206,7 +206,9 @@ WATCHED_FOLDER = str(WATCHED_FOLDER)
|
||||
# e621 integration
|
||||
|
||||
E621_BASE_URL = os.getenv("E621_BASE_URL", "https://e621.net").rstrip("/")
|
||||
USER_AGENT = os.getenv("USER_AGENT", "J621/0.1 (by J621 on e621)")
|
||||
# e621 asks for "Application name/version (developer)". Browser clients cannot
|
||||
# set a User-Agent, so the SPA sends the same string in its `_client` parameter.
|
||||
USER_AGENT = os.getenv("USER_AGENT", f"J621/{GIT_COMMIT_HASH} (JakeBreath)")
|
||||
# Hosts the client-download proxy is allowed to stream from.
|
||||
E621_MEDIA_HOSTS = [
|
||||
host.strip()
|
||||
@@ -232,6 +234,12 @@ GUEST_BLACKLIST_TTL = int(os.getenv("GUEST_BLACKLIST_TTL", "3600"))
|
||||
# Similarity threshold for flagging staged uploads that match library items.
|
||||
VISUAL_MATCH_THRESHOLD = float(os.getenv("VISUAL_MATCH_THRESHOLD", "0.9"))
|
||||
|
||||
# Start the staged-upload pipeline when a file is staged (daemon thread in the
|
||||
# worker). Tests turn this off and drive the pipeline synchronously.
|
||||
UPLOAD_PIPELINE_AUTOSTART = os.getenv(
|
||||
"UPLOAD_PIPELINE_AUTOSTART", "true"
|
||||
).strip().lower() not in {"0", "false", "no", "off"}
|
||||
|
||||
# Ephemeral similarity-check uploads are deleted after this many minutes
|
||||
# (and always on startup).
|
||||
SIMILARITY_TTL_MINUTES = int(os.getenv("SIMILARITY_TTL_MINUTES", "30"))
|
||||
@@ -240,13 +248,23 @@ SIMILARITY_TTL_MINUTES = int(os.getenv("SIMILARITY_TTL_MINUTES", "30"))
|
||||
# workers and management commands (e.g. the mirrored guest blacklist).
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django.core.cache.backends.redis.RedisCache",
|
||||
"BACKEND": "apps.core.cache.ResilientRedisCache",
|
||||
"LOCATION": os.getenv("REDIS_URL", "redis://127.0.0.1:6380/1"),
|
||||
}
|
||||
}
|
||||
|
||||
# Django REST Framework
|
||||
|
||||
# Private / tailnet-only deployments can drop the general anon+user limits
|
||||
# entirely (THROTTLE_ENABLED=false). The scoped guards below (login, register,
|
||||
# e621 proxy) and the media endpoints' own protections stay active either way.
|
||||
THROTTLE_ENABLED = os.getenv("THROTTLE_ENABLED", "true").strip().lower() not in {
|
||||
"0",
|
||||
"false",
|
||||
"no",
|
||||
"off",
|
||||
}
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": [
|
||||
"rest_framework.authentication.TokenAuthentication",
|
||||
@@ -261,11 +279,18 @@ REST_FRAMEWORK = {
|
||||
],
|
||||
"DEFAULT_PAGINATION_CLASS": "config.pagination.StandardPagination",
|
||||
"PAGE_SIZE": 48,
|
||||
# Per-IP/per-user rate limits (counted in the shared Redis cache).
|
||||
"DEFAULT_THROTTLE_CLASSES": [
|
||||
"rest_framework.throttling.AnonRateThrottle",
|
||||
"rest_framework.throttling.UserRateThrottle",
|
||||
],
|
||||
# Per-IP/per-user rate limits (counted in the shared Redis cache). Signed
|
||||
# media URLs are deliberately excluded at the view level: <img>/<video>
|
||||
# tags fetch them without an Authorization header, so a library page would
|
||||
# otherwise burn the anonymous bucket and start returning JSON 429s.
|
||||
"DEFAULT_THROTTLE_CLASSES": (
|
||||
[
|
||||
"rest_framework.throttling.AnonRateThrottle",
|
||||
"rest_framework.throttling.UserRateThrottle",
|
||||
]
|
||||
if THROTTLE_ENABLED
|
||||
else []
|
||||
),
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
# Generous enough for the shell polling (status every 5s, stats every 2s).
|
||||
"anon": os.getenv("THROTTLE_ANON", "120/min"),
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
from apps.core.views import HealthView
|
||||
from apps.library.views import RandomItemView
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
@@ -15,6 +16,10 @@ urlpatterns = [
|
||||
# Liveness probe for uptime monitors (no /api prefix, no auth).
|
||||
path("health", HealthView.as_view(), name="health"),
|
||||
path("health/", HealthView.as_view()),
|
||||
# Short alias for shell greeting scripts (fish_greeting + fastfetch);
|
||||
# /api/random/ is the canonical path.
|
||||
path("random", RandomItemView.as_view(), name="random"),
|
||||
path("random/", RandomItemView.as_view()),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
|
||||
@@ -30,7 +30,10 @@ ALLOWED_HOSTS=j621.rainbow-herring.ts.net,localhost,127.0.0.1
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-origin access (needed for the separate frontend + backend deploys)
|
||||
# ---------------------------------------------------------------------------
|
||||
# The origin the SPA is served from, e.g. https://j621-frontend.<tailnet>.ts.net
|
||||
# The origin the SPA is served from for split deploys, e.g.
|
||||
# https://j621-frontend.<tailnet>.ts.net. gen_env.sh writes this plus the
|
||||
# desktop shell's app://j621 origin into the line below (and keeps existing
|
||||
# entries on --update).
|
||||
# CORS_ALLOWED_ORIGINS=
|
||||
# CSRF_TRUSTED_ORIGINS=
|
||||
|
||||
@@ -49,12 +52,22 @@ DB_ROOT_PASSWORD=j621root
|
||||
# GUNICORN_THREADS=4
|
||||
# GUNICORN_TIMEOUT=120
|
||||
|
||||
# Scheduler intervals in seconds (the "scheduler" service runs the periodic
|
||||
# management commands; see deploy/README.md)
|
||||
# J621_SYNC_EVERY=1800
|
||||
# J621_CLEAN_EVERY=3600
|
||||
# J621_BLACKLIST_EVERY=86400
|
||||
|
||||
# Rate limits (per IP anonymous, per account signed in)
|
||||
# THROTTLE_ANON=120/min
|
||||
# THROTTLE_USER=600/min
|
||||
# THROTTLE_LOGIN=5/min
|
||||
# THROTTLE_REGISTER=20/hour
|
||||
# THROTTLE_E621_PROXY=60/hour
|
||||
# Tailnet-only / private deployments can drop the general limits entirely.
|
||||
# Signed media URLs (<img>/<video>) and the login/register/proxy guards are
|
||||
# exempt from this switch either way.
|
||||
# THROTTLE_ENABLED=false
|
||||
|
||||
# e621 media hosts the backend may fetch from (downloads, proxies)
|
||||
# E621_MEDIA_HOSTS=static1.e621.net,static2.e621.net,static3.e621.net
|
||||
|
||||
@@ -28,8 +28,17 @@ RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY backend/ ./
|
||||
COPY deploy/backend-entrypoint.sh /usr/local/bin/j621-entrypoint
|
||||
COPY deploy/scheduler-entrypoint.sh /usr/local/bin/j621-scheduler
|
||||
|
||||
RUN chmod +x /usr/local/bin/j621-entrypoint \
|
||||
# Guard: fail the build if the context leaked secrets or runtime data
|
||||
# (.dockerignore excludes them — see the repository root).
|
||||
RUN test ! -e /app/.env \
|
||||
&& test ! -d /app/venv \
|
||||
&& test ! -d /app/media/library \
|
||||
&& test ! -e /app/db.sqlite3 \
|
||||
&& echo "build context clean"
|
||||
|
||||
RUN chmod +x /usr/local/bin/j621-entrypoint /usr/local/bin/j621-scheduler \
|
||||
&& mkdir -p /app/media /app/logs \
|
||||
&& python manage.py collectstatic --noinput
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-alpine AS build
|
||||
ARG GIT_HASH=dev
|
||||
ENV GIT_HASH=$GIT_HASH
|
||||
WORKDIR /app
|
||||
COPY frontend/package.json frontend/package-lock.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
@@ -5,7 +5,9 @@ sidecar. Nothing is published on the host's ports and no system nginx or
|
||||
reverse proxy is involved: the sidecar shares the nginx service's network
|
||||
namespace and Tailscale Serve/Funnel exposes it.
|
||||
|
||||
| Compose | Services | Funnel | Serve config |
|
||||
## Funnel set (public HTTPS)
|
||||
|
||||
| Compose | Services | Entry point | Serve config |
|
||||
| --- | --- | --- | --- |
|
||||
| `compose.yml` (default) | mariadb, redis, backend, frontend, nginx, tailscale | `https://<host>.<tailnet>.ts.net` → nginx → backend + SPA | `serve.default.json` |
|
||||
| `compose.frontend.yml` | frontend, nginx, tailscale | `https://<host>.<tailnet>.ts.net` → nginx → SPA only | `serve.frontend.json` |
|
||||
@@ -15,15 +17,64 @@ Funnel can only expose ports **443**, **8443** and **10000**, so the separate
|
||||
backend uses 8443. Change it in `serve.backend.json` (and `.env`) if you
|
||||
prefer 10000.
|
||||
|
||||
## Tailnet-only set (no Funnel)
|
||||
|
||||
The same three stacks without `AllowFunnel` in the serve config: the sidecar
|
||||
still registers the node and serves over HTTPS with a tailnet certificate,
|
||||
but only devices on your tailnet can reach it — nothing is exposed to the
|
||||
public internet.
|
||||
|
||||
| Compose | Serve config | Reachable at |
|
||||
| --- | --- | --- |
|
||||
| `compose.tailnet.yml` (both) | `serve.default.tailnet.json` | `https://<host>.<tailnet>.ts.net` |
|
||||
| `compose.tailnet.frontend.yml` | `serve.frontend.tailnet.json` | `https://<host>.<tailnet>.ts.net` |
|
||||
| `compose.tailnet.backend.yml` | `serve.backend.tailnet.json` | `https://<host>.<tailnet>.ts.net:8443` |
|
||||
|
||||
```bash
|
||||
docker compose -f compose.tailnet.yml up -d
|
||||
# or compose.tailnet.frontend.yml / compose.tailnet.backend.yml
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Project names are `…-tailnet` so both sets can be installed side by side.
|
||||
- Serve needs no tailnet policy change (Funnel requires the `funnel` node
|
||||
attribute in your ACLs), so this set works as soon as the sidecar joins.
|
||||
- Both sets use the same `deploy/data` directory (library, database, logs).
|
||||
Run one set per data directory — two MariaDB instances on one data dir would
|
||||
corrupt it. Giving the tailnet set its own `TS_HOSTNAME` (or running it on a
|
||||
second host) is the way to run both.
|
||||
- Switching a host from funnel to tailnet (or back) is just starting the other
|
||||
compose file with the same `.env`.
|
||||
|
||||
## Environment file
|
||||
|
||||
`gen_env.sh` builds `deploy/.env` from `.env.example`, generating `SECRET_KEY`
|
||||
and both database passwords with `openssl`:
|
||||
|
||||
```bash
|
||||
./gen_env.sh # asks for the Tailscale auth key + hostname(s)
|
||||
./gen_env.sh --no-prompt # secrets and defaults only; fill TS_AUTHKEY later
|
||||
./gen_env.sh --update # refresh hostnames/authkey, keep the existing secrets
|
||||
./gen_env.sh --force # regenerate everything, including SECRET_KEY
|
||||
```
|
||||
|
||||
It derives `TS_HOSTNAME` and `ALLOWED_HOSTS` from the tailnet hostname you
|
||||
give it, and can set `CORS_ALLOWED_ORIGINS`/`CSRF_TRUSTED_ORIGINS` when you
|
||||
provide the frontend's hostname for a split deployment. `--update` is the safe
|
||||
way to add hostnames later; `--force` rotates SECRET_KEY, which invalidates
|
||||
signed media URLs and stored e621 API keys. The file is written with mode 600
|
||||
and is git-ignored.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
cp .env.example .env # fill in TS_AUTHKEY, SECRET_KEY, ALLOWED_HOSTS, ...
|
||||
docker compose up -d # default: everything on one host
|
||||
./gen_env.sh # or: cp .env.example .env and edit it yourself
|
||||
docker compose up -d # default: everything on one host, public funnel
|
||||
# or
|
||||
docker compose -f compose.frontend.yml up -d
|
||||
docker compose -f compose.backend.yml up -d
|
||||
# tailnet-only (no public funnel): compose.tailnet*.yml, see below
|
||||
```
|
||||
|
||||
The images are pulled from the Gitea registry; append `--build` (or run the
|
||||
@@ -41,6 +92,10 @@ The SPA asks where the backend is (`/setup`) in production builds:
|
||||
backend's funnel URL, e.g. `https://j621-backend.<tailnet>.ts.net:8443`,
|
||||
and give the backend `CORS_ALLOWED_ORIGINS=https://<frontend-host>.<tailnet>.ts.net`
|
||||
(plus `CSRF_TRUSTED_ORIGINS` for the admin).
|
||||
- **Desktop app** — the Electron shell (`desktop/`) is served from the
|
||||
`app://j621` origin, which `gen_env.sh` includes in `CORS_ALLOWED_ORIGINS`
|
||||
automatically (add it by hand if you wrote `.env` yourself). The shell's
|
||||
setup screen prints the exact origin when the connection test fails.
|
||||
- Without a backend at all, choose **"Continue without a backend"** to run in
|
||||
local mode (e621 browsing only).
|
||||
|
||||
@@ -63,6 +118,15 @@ ffmpeg for video thumbnails. The `GIT_HASH` build arg is baked into the
|
||||
backend image so the shell's version pill shows the commit (images have no
|
||||
`.git` directory); the push scripts pass it automatically.
|
||||
|
||||
Both build from the repository root, which is filtered by `.dockerignore`:
|
||||
`backend/venv`, `backend/media`, `backend/logs`, `backend/staticfiles`,
|
||||
`backend/.env`, `frontend/node_modules`, `frontend/dist` and the deploy
|
||||
runtime state never enter the images — the database, media and logs come
|
||||
from the compose volumes and `deploy/.env` at runtime. The backend build
|
||||
also asserts that (`.env`, `venv`, `media/library`, `db.sqlite3` absent), so
|
||||
a missing ignore file fails the build instead of shipping secrets. Rebuild
|
||||
(and `docker image prune`) if you built before that guard existed.
|
||||
|
||||
Push multi-arch images to the Gitea registry:
|
||||
|
||||
```bash
|
||||
@@ -72,6 +136,58 @@ Push multi-arch images to the Gitea registry:
|
||||
They tag `:latest` and `:<commit-sha>` and expect `docker login
|
||||
gitea.rainbow-herring.ts.net` to succeed.
|
||||
|
||||
Build the desktop installers without publishing anything:
|
||||
|
||||
```bash
|
||||
./build_desktop.sh # Arch + Debian + Windows -> desktop/release/
|
||||
./build_desktop.sh --linux # Arch + Debian only
|
||||
./build_desktop.sh --win # Windows installer only
|
||||
```
|
||||
|
||||
Hand the files out or attach them to a Gitea release manually — the script
|
||||
prints sizes and SHA-256 sums for the release notes. The release assets are
|
||||
also the desktop update feed; the app resolves the newest `desktop-v*`
|
||||
release on Gitea at check time (see `desktop/README.md`).
|
||||
|
||||
The frontend's `/desktop/` feed is optional now — kept for manual downloads
|
||||
and for installs older than 0.1.2. To publish it:
|
||||
|
||||
```bash
|
||||
./push_desktop.sh # build Linux packages + copy the feed to jakerasp
|
||||
./push_desktop.sh --win # also cross-build the NSIS installer (wine)
|
||||
./push_desktop.sh --local # publish locally only, skip the remote copy
|
||||
./push_desktop.sh --host other:/path/to/J621
|
||||
```
|
||||
|
||||
The remote copy defaults to `jakerasp:/home/jake/servers/J621`, or
|
||||
`$J621_DESKTOP_FEED_HOST` when set. Artifacts land in `deploy/data/desktop/`,
|
||||
which the frontend nginx mounts read-only and serves at `/desktop/`. The
|
||||
remote copy uses rsync when both ends have it, tar over ssh when the server
|
||||
does not. Backend-only composes have no frontend, so no website feed.
|
||||
|
||||
The manual **CD** workflow (Actions tab) builds the desktop packages on the
|
||||
runner and attaches them plus the update metadata to the Gitea release
|
||||
`desktop-v<version>`; that is what the desktop updater reads. The website feed
|
||||
is not touched by CI (runtime state on the deploy host, no SSH key there); use
|
||||
`push_desktop.sh` when it needs refreshing for old installs.
|
||||
|
||||
## Scheduled jobs
|
||||
|
||||
Compose files with a backend also run a **`scheduler`** service — the same
|
||||
backend image with a different entrypoint, so no host cron is involved:
|
||||
|
||||
| Job | Default interval | Env override |
|
||||
| --- | --- | --- |
|
||||
| `sync_followed_tags` + `sync_followed_pools` | every 30 minutes | `J621_SYNC_EVERY` |
|
||||
| `cleanup_similarity` | hourly | `J621_CLEAN_EVERY` |
|
||||
| `refresh_guest_blacklist` | daily | `J621_BLACKLIST_EVERY` |
|
||||
|
||||
It waits for the database and migrations before its first run, runs every job
|
||||
once on start, then keeps to the intervals (failures are logged and retried
|
||||
next round). Output goes to `docker compose logs scheduler`. Intervals are
|
||||
seconds, set in `deploy/.env`. The frontend-only composes have no backend, so
|
||||
no scheduler.
|
||||
|
||||
## Tests
|
||||
|
||||
The security/permission suite lives in `backend/apps/core/tests/`:
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
# Build the J621 desktop packages into desktop/release/ — Arch (.pkg.tar.zst),
|
||||
# Debian (.deb) and the Windows NSIS installer. Nothing is published: install
|
||||
# the package locally, hand the files out, or attach them to a Gitea release
|
||||
# manually. Use push_desktop.sh when the in-app update feed should get them.
|
||||
#
|
||||
# Usage: ./build_desktop.sh [--linux | --win | --all]
|
||||
# --linux Arch + Debian packages only
|
||||
# --win Windows installer only (cross-built with wine)
|
||||
# --all everything (default)
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
TARGET="${1:---all}"
|
||||
case "$TARGET" in
|
||||
--linux) TARGET=linux ;;
|
||||
--win) TARGET=win ;;
|
||||
--all) TARGET=all ;;
|
||||
*)
|
||||
echo "usage: $0 [--linux|--win|--all]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
VERSION="$(node -p "require('./desktop/package.json').version")"
|
||||
|
||||
if [ ! -d desktop/node_modules ]; then
|
||||
echo "==> Installing desktop dependencies ..."
|
||||
npm --prefix desktop ci --no-audit --no-fund
|
||||
fi
|
||||
|
||||
if [ "$TARGET" != "linux" ] && ! command -v wine >/dev/null 2>&1; then
|
||||
echo "==> wine is not installed; the Windows installer needs it." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# release/ should only ever hold the current version: old installers are
|
||||
# rebuilt from git when needed, and the unpacked trees are regenerated.
|
||||
shopt -s nullglob
|
||||
stale=(desktop/release/*.deb desktop/release/*.pkg.tar.zst desktop/release/"J621 Setup "*.exe
|
||||
desktop/release/*.blockmap desktop/release/latest*.yml)
|
||||
for file in "${stale[@]}"; do
|
||||
case "$(basename "$file")" in
|
||||
*"$VERSION"*) ;;
|
||||
*) echo " removing $(basename "$file")"; rm -f "$file" ;;
|
||||
esac
|
||||
done
|
||||
rm -rf desktop/release/linux-unpacked desktop/release/win-unpacked
|
||||
|
||||
case "$TARGET" in
|
||||
linux) npm --prefix desktop run dist:linux ;;
|
||||
win) npm --prefix desktop run dist:win ;;
|
||||
all) npm --prefix desktop run dist:all ;;
|
||||
esac
|
||||
|
||||
case "$TARGET" in
|
||||
linux) ARTIFACTS=(-name "*${VERSION}*.deb" -o -name "*${VERSION}*.pkg.tar.zst") ;;
|
||||
win) ARTIFACTS=(-name "*${VERSION}*.exe") ;;
|
||||
all) ARTIFACTS=(-name "*${VERSION}*.deb" -o -name "*${VERSION}*.pkg.tar.zst" -o -name "*${VERSION}*.exe") ;;
|
||||
esac
|
||||
|
||||
echo
|
||||
echo "==> Artifacts in desktop/release/:"
|
||||
find desktop/release -maxdepth 1 -type f \( "${ARTIFACTS[@]}" \) \
|
||||
-printf '%s\t%p\n' |
|
||||
sort -rn |
|
||||
while IFS=$'\t' read -r size file; do
|
||||
printf ' %8s %s\n' "$(numfmt --to=iec "$size")" "$file"
|
||||
done
|
||||
|
||||
echo
|
||||
echo "==> SHA-256 (for release notes):"
|
||||
find desktop/release -maxdepth 1 -type f \( "${ARTIFACTS[@]}" \) \
|
||||
-exec sha256sum {} + | sed 's/^/ /'
|
||||
|
||||
echo
|
||||
echo "==> J621 desktop $VERSION built."
|
||||
echo " Install here: sudo pacman -U desktop/release/j621-desktop-*.pkg.tar.zst"
|
||||
echo " Gitea release tag: desktop-v$VERSION"
|
||||
@@ -68,6 +68,33 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
# Periodic maintenance inside the deployment (no host cron): follow syncs,
|
||||
# similarity cleanup and the guest blacklist refresh.
|
||||
scheduler:
|
||||
image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-backend:${J621_TAG:-latest}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/J621-Backend
|
||||
args:
|
||||
GIT_HASH: ${GIT_HASH:-unknown}
|
||||
restart: unless-stopped
|
||||
entrypoint: ["j621-scheduler"]
|
||||
env_file: [./.env]
|
||||
environment:
|
||||
DB_HOST: mariadb
|
||||
DB_PORT: "3306"
|
||||
REDIS_URL: redis://redis:6379/1
|
||||
SYNC_EVERY: ${J621_SYNC_EVERY:-1800}
|
||||
CLEAN_EVERY: ${J621_CLEAN_EVERY:-3600}
|
||||
BLACKLIST_EVERY: ${J621_BLACKLIST_EVERY:-86400}
|
||||
volumes:
|
||||
- ./data/media:/app/media
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
nginx:
|
||||
image: nginx:1.29-alpine
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -20,6 +20,9 @@ services:
|
||||
context: ..
|
||||
dockerfile: deploy/J621-Frontend
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Desktop update feed (deploy/push_desktop.sh): latest*.yml + installers.
|
||||
- ./data/desktop:/usr/share/nginx/html/desktop:ro
|
||||
|
||||
nginx:
|
||||
image: nginx:1.29-alpine
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# J621 — backend-only deployment: API + database + nginx proxy + Tailscale.
|
||||
#
|
||||
# cd deploy && cp .env.example .env # TS_AUTHKEY, SECRET_KEY, hosts, CORS
|
||||
# docker compose -f compose.tailnet.backend.yml up -d
|
||||
#
|
||||
# Tailnet-only: https://<TS_HOSTNAME>.<tailnet>.ts.net:8443 reaches the
|
||||
# nginx service from your tailnet, which routes /api, /admin, /static and /health to
|
||||
# Django. "/" answers a small JSON hint because no frontend is attached.
|
||||
#
|
||||
# Because the frontend lives elsewhere it is cross-origin — set in .env:
|
||||
# CORS_ALLOWED_ORIGINS=https://<frontend-host>.<tailnet>.ts.net
|
||||
# CSRF_TRUSTED_ORIGINS=... (same value; only needed for the admin)
|
||||
# and add this host to ALLOWED_HOSTS (comma separated list).
|
||||
|
||||
name: j621-backend-deploy-tailnet
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11.4
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-j621root}
|
||||
MARIADB_DATABASE: ${DB_NAME:-j621}
|
||||
MARIADB_USER: ${DB_USER:-j621}
|
||||
MARIADB_PASSWORD: ${DB_PASSWORD:-j621}
|
||||
volumes:
|
||||
- ./data/mariadb:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
backend:
|
||||
image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-backend:${J621_TAG:-latest}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/J621-Backend
|
||||
# Bake the commit into the image so the shell's version pill shows it;
|
||||
# the push scripts pass --build-arg themselves.
|
||||
args:
|
||||
GIT_HASH: ${GIT_HASH:-unknown}
|
||||
restart: unless-stopped
|
||||
env_file: [./.env]
|
||||
environment:
|
||||
DB_HOST: mariadb
|
||||
DB_PORT: "3306"
|
||||
REDIS_URL: redis://redis:6379/1
|
||||
TRUST_PROXY_HEADERS: "true"
|
||||
volumes:
|
||||
- ./data/media:/app/media
|
||||
- ./data/logs:/app/logs
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
# Periodic maintenance inside the deployment (no host cron): follow syncs,
|
||||
# similarity cleanup and the guest blacklist refresh.
|
||||
scheduler:
|
||||
image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-backend:${J621_TAG:-latest}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/J621-Backend
|
||||
args:
|
||||
GIT_HASH: ${GIT_HASH:-unknown}
|
||||
restart: unless-stopped
|
||||
entrypoint: ["j621-scheduler"]
|
||||
env_file: [./.env]
|
||||
environment:
|
||||
DB_HOST: mariadb
|
||||
DB_PORT: "3306"
|
||||
REDIS_URL: redis://redis:6379/1
|
||||
SYNC_EVERY: ${J621_SYNC_EVERY:-1800}
|
||||
CLEAN_EVERY: ${J621_CLEAN_EVERY:-3600}
|
||||
BLACKLIST_EVERY: ${J621_BLACKLIST_EVERY:-86400}
|
||||
volumes:
|
||||
- ./data/media:/app/media
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
nginx:
|
||||
image: nginx:1.29-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./nginx-proxy.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/nginx-health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
tailscale:
|
||||
image: tailscale/tailscale:latest
|
||||
restart: unless-stopped
|
||||
# Shares the nginx service's network namespace: 127.0.0.1:80 is the proxy.
|
||||
network_mode: "service:nginx"
|
||||
environment:
|
||||
TS_AUTHKEY: ${TS_AUTHKEY:?Set TS_AUTHKEY in deploy/.env}
|
||||
TS_HOSTNAME: ${TS_HOSTNAME:-j621-backend}
|
||||
TS_AUTH_ONCE: "true"
|
||||
TS_STATE_DIR: /var/lib/tailscale
|
||||
TS_SERVE_CONFIG: /config/serve.json
|
||||
volumes:
|
||||
- ./tailscale-state:/var/lib/tailscale
|
||||
- ./serve.backend.tailnet.json:/config/serve.json:ro
|
||||
- /etc/ssl/certs:/etc/ssl/certs:ro
|
||||
depends_on:
|
||||
nginx:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "tailscale", "status"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
@@ -0,0 +1,66 @@
|
||||
# J621 — frontend-only deployment: SPA + nginx proxy + Tailscale sidecar.
|
||||
#
|
||||
# cd deploy && cp .env.example .env # TS_AUTHKEY at minimum
|
||||
# docker compose -f compose.tailnet.frontend.yml up -d
|
||||
#
|
||||
# Tailnet-only: https://<TS_HOSTNAME>.<tailnet>.ts.net (443) serves the SPA
|
||||
# for devices on your tailnet; nothing is exposed publicly.
|
||||
# On first start the SPA asks for a backend (/setup): point it at a
|
||||
# backend-only deployment (e.g. https://j621-backend.<tailnet>.ts.net:8443),
|
||||
# leave it blank to serve one yourself, or stay in local mode.
|
||||
#
|
||||
# There is no backend in this compose, so /api answers 502 here until the SPA
|
||||
# is configured to call one elsewhere.
|
||||
|
||||
name: j621-frontend-deploy-tailnet
|
||||
|
||||
services:
|
||||
frontend:
|
||||
image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-frontend:${J621_TAG:-latest}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/J621-Frontend
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Desktop update feed (deploy/push_desktop.sh): latest*.yml + installers.
|
||||
- ./data/desktop:/usr/share/nginx/html/desktop:ro
|
||||
|
||||
nginx:
|
||||
image: nginx:1.29-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./nginx-proxy.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
frontend:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/nginx-health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
tailscale:
|
||||
image: tailscale/tailscale:latest
|
||||
restart: unless-stopped
|
||||
# Shares the nginx service's network namespace: 127.0.0.1:80 is the proxy.
|
||||
network_mode: "service:nginx"
|
||||
environment:
|
||||
TS_AUTHKEY: ${TS_AUTHKEY:?Set TS_AUTHKEY in deploy/.env}
|
||||
TS_HOSTNAME: ${TS_HOSTNAME:-j621-frontend}
|
||||
TS_AUTH_ONCE: "true"
|
||||
TS_STATE_DIR: /var/lib/tailscale
|
||||
TS_SERVE_CONFIG: /config/serve.json
|
||||
volumes:
|
||||
- ./tailscale-state:/var/lib/tailscale
|
||||
- ./serve.frontend.tailnet.json:/config/serve.json:ro
|
||||
- /etc/ssl/certs:/etc/ssl/certs:ro
|
||||
depends_on:
|
||||
nginx:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "tailscale", "status"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
@@ -0,0 +1,147 @@
|
||||
# J621 — default deployment: frontend + backend + database on one Tailscale
|
||||
# host, behind the nginx proxy service (no host nginx, nothing published on
|
||||
# the host's ports).
|
||||
#
|
||||
# cd deploy && cp .env.example .env # fill in TS_AUTHKEY, SECRET_KEY, ...
|
||||
# docker compose up -d # or: docker compose -f compose.yml up -d
|
||||
#
|
||||
# Tailnet-only: no Funnel, so the service is reachable from your tailnet
|
||||
# (https://<TS_HOSTNAME>.<tailnet>.ts.net) but not from the public internet.
|
||||
# The nginx service routes /api, /admin, /static and /health to the
|
||||
# backend and everything else to the SPA. Same origin, so no CORS needed.
|
||||
|
||||
name: j621-deploy-tailnet
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11.4
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-j621root}
|
||||
MARIADB_DATABASE: ${DB_NAME:-j621}
|
||||
MARIADB_USER: ${DB_USER:-j621}
|
||||
MARIADB_PASSWORD: ${DB_PASSWORD:-j621}
|
||||
volumes:
|
||||
- ./data/mariadb:/var/lib/mysql
|
||||
healthcheck:
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- ./data/redis:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
backend:
|
||||
image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-backend:${J621_TAG:-latest}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/J621-Backend
|
||||
# Bake the commit into the image so the shell's version pill shows it;
|
||||
# the push scripts pass --build-arg themselves.
|
||||
args:
|
||||
GIT_HASH: ${GIT_HASH:-unknown}
|
||||
restart: unless-stopped
|
||||
env_file: [./.env]
|
||||
environment:
|
||||
DB_HOST: mariadb
|
||||
DB_PORT: "3306"
|
||||
REDIS_URL: redis://redis:6379/1
|
||||
# The tailnet funnel terminates TLS and forwards the original host/proto.
|
||||
TRUST_PROXY_HEADERS: "true"
|
||||
volumes:
|
||||
- ./data/media:/app/media
|
||||
- ./data/logs:/app/logs
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
# Periodic maintenance inside the deployment (no host cron): follow syncs,
|
||||
# similarity cleanup and the guest blacklist refresh.
|
||||
scheduler:
|
||||
image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-backend:${J621_TAG:-latest}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/J621-Backend
|
||||
args:
|
||||
GIT_HASH: ${GIT_HASH:-unknown}
|
||||
restart: unless-stopped
|
||||
entrypoint: ["j621-scheduler"]
|
||||
env_file: [./.env]
|
||||
environment:
|
||||
DB_HOST: mariadb
|
||||
DB_PORT: "3306"
|
||||
REDIS_URL: redis://redis:6379/1
|
||||
SYNC_EVERY: ${J621_SYNC_EVERY:-1800}
|
||||
CLEAN_EVERY: ${J621_CLEAN_EVERY:-3600}
|
||||
BLACKLIST_EVERY: ${J621_BLACKLIST_EVERY:-86400}
|
||||
volumes:
|
||||
- ./data/media:/app/media
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-frontend:${J621_TAG:-latest}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/J621-Frontend
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Desktop update feed (deploy/push_desktop.sh): latest*.yml + installers.
|
||||
- ./data/desktop:/usr/share/nginx/html/desktop:ro
|
||||
|
||||
nginx:
|
||||
image: nginx:1.29-alpine
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./nginx-proxy.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
depends_on:
|
||||
backend:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1/nginx-health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
tailscale:
|
||||
image: tailscale/tailscale:latest
|
||||
restart: unless-stopped
|
||||
# Shares the nginx service's network namespace: 127.0.0.1:80 is the proxy.
|
||||
network_mode: "service:nginx"
|
||||
environment:
|
||||
TS_AUTHKEY: ${TS_AUTHKEY:?Set TS_AUTHKEY in deploy/.env}
|
||||
TS_HOSTNAME: ${TS_HOSTNAME:-j621}
|
||||
TS_AUTH_ONCE: "true"
|
||||
TS_STATE_DIR: /var/lib/tailscale
|
||||
TS_SERVE_CONFIG: /config/serve.json
|
||||
volumes:
|
||||
- ./tailscale-state:/var/lib/tailscale
|
||||
- ./serve.default.tailnet.json:/config/serve.json:ro
|
||||
- /etc/ssl/certs:/etc/ssl/certs:ro
|
||||
depends_on:
|
||||
nginx:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "tailscale", "status"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
@@ -66,12 +66,42 @@ services:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
# Periodic maintenance inside the deployment (no host cron): follow syncs,
|
||||
# similarity cleanup and the guest blacklist refresh.
|
||||
scheduler:
|
||||
image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-backend:${J621_TAG:-latest}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/J621-Backend
|
||||
args:
|
||||
GIT_HASH: ${GIT_HASH:-unknown}
|
||||
restart: unless-stopped
|
||||
entrypoint: ["j621-scheduler"]
|
||||
env_file: [./.env]
|
||||
environment:
|
||||
DB_HOST: mariadb
|
||||
DB_PORT: "3306"
|
||||
REDIS_URL: redis://redis:6379/1
|
||||
SYNC_EVERY: ${J621_SYNC_EVERY:-1800}
|
||||
CLEAN_EVERY: ${J621_CLEAN_EVERY:-3600}
|
||||
BLACKLIST_EVERY: ${J621_BLACKLIST_EVERY:-86400}
|
||||
volumes:
|
||||
- ./data/media:/app/media
|
||||
depends_on:
|
||||
mariadb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
frontend:
|
||||
image: ${J621_REGISTRY:-gitea.rainbow-herring.ts.net/jakebreath}/j621-frontend:${J621_TAG:-latest}
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/J621-Frontend
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
# Desktop update feed (deploy/push_desktop.sh): latest*.yml + installers.
|
||||
- ./data/desktop:/usr/share/nginx/html/desktop:ro
|
||||
|
||||
nginx:
|
||||
image: nginx:1.29-alpine
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/bin/bash
|
||||
# Generate deploy/.env from .env.example with fresh secrets.
|
||||
#
|
||||
# ./gen_env.sh # interactive: asks for the auth key/hostnames
|
||||
# ./gen_env.sh --no-prompt # secrets + defaults only
|
||||
# ./gen_env.sh --update # refresh hostnames/authkey, KEEP existing secrets
|
||||
# ./gen_env.sh --force # regenerate everything (new SECRET_KEY!)
|
||||
#
|
||||
# SECRET_KEY and the database passwords come from openssl. Rotating
|
||||
# SECRET_KEY invalidates signed media URLs and stored e621 API keys, which is
|
||||
# why --update keeps it.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
FORCE=0
|
||||
UPDATE=0
|
||||
NO_PROMPT=0
|
||||
TS_AUTHKEY=""
|
||||
FQDN=""
|
||||
FRONTEND=""
|
||||
DEFAULT_FQDN="j621.rainbow-herring.ts.net"
|
||||
|
||||
usage() {
|
||||
sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--force) FORCE=1 ;;
|
||||
--update) UPDATE=1 ;;
|
||||
--no-prompt) NO_PROMPT=1 ;;
|
||||
--ts-authkey) TS_AUTHKEY="${2:?missing value}"; shift ;;
|
||||
--hostname) FQDN="${2:?missing value}"; shift ;;
|
||||
--frontend) FRONTEND="${2:?missing value}"; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
command -v openssl >/dev/null || { echo "openssl is required." >&2; exit 1; }
|
||||
command -v python3 >/dev/null || { echo "python3 is required." >&2; exit 1; }
|
||||
[ -f .env.example ] || { echo "Run me from the deploy/ directory." >&2; exit 1; }
|
||||
|
||||
if [ -f .env ] && [ "$FORCE" -eq 0 ] && [ "$UPDATE" -eq 0 ]; then
|
||||
echo "deploy/.env already exists."
|
||||
echo " --update keeps the existing secrets and just refreshes the rest"
|
||||
echo " --force regenerates everything, including SECRET_KEY and DB passwords"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
existing() { grep -E "^$1=" .env 2>/dev/null | head -1 | cut -d= -f2- || true; }
|
||||
|
||||
gen_key() { openssl rand -base64 48 | tr -d '\n'; }
|
||||
gen_password() { openssl rand -hex 24; }
|
||||
|
||||
if [ "$UPDATE" -eq 1 ] && [ -f .env ]; then
|
||||
SECRET_KEY="$(existing SECRET_KEY)"
|
||||
DB_PASSWORD="$(existing DB_PASSWORD)"
|
||||
DB_ROOT_PASSWORD="$(existing DB_ROOT_PASSWORD)"
|
||||
TS_AUTHKEY="${TS_AUTHKEY:-$(existing TS_AUTHKEY)}"
|
||||
# TS_HOSTNAME is the short label; the full FQDN lives in ALLOWED_HOSTS.
|
||||
EXISTING_HOSTS="$(existing ALLOWED_HOSTS)"
|
||||
FQDN="${FQDN:-${EXISTING_HOSTS%%,*}}"
|
||||
fi
|
||||
|
||||
# Fill whatever is still missing.
|
||||
SECRET_KEY="${SECRET_KEY:-$(gen_key)}"
|
||||
DB_PASSWORD="${DB_PASSWORD:-$(gen_password)}"
|
||||
DB_ROOT_PASSWORD="${DB_ROOT_PASSWORD:-$(gen_password)}"
|
||||
|
||||
if [ "$NO_PROMPT" -eq 0 ]; then
|
||||
if [ -z "$TS_AUTHKEY" ]; then
|
||||
read -rp "Tailscale auth key (tskey-..., Enter to fill in later): " TS_AUTHKEY
|
||||
fi
|
||||
if [ -z "$FQDN" ]; then
|
||||
read -rp "Tailnet hostname of this deployment [$DEFAULT_FQDN]: " FQDN
|
||||
fi
|
||||
FQDN="${FQDN:-$DEFAULT_FQDN}"
|
||||
if [ -z "$FRONTEND" ]; then
|
||||
read -rp "Frontend hostname for the split deploys (optional, Enter to skip): " FRONTEND
|
||||
fi
|
||||
fi
|
||||
FQDN="${FQDN:-$DEFAULT_FQDN}"
|
||||
|
||||
# Derive the rest from the tailnet hostname.
|
||||
TS_HOSTNAME="${FQDN%%.*}"
|
||||
ALLOWED_HOSTS="$FQDN,localhost,127.0.0.1"
|
||||
|
||||
# Cross-origin access: the optional split-deploy frontend plus the desktop
|
||||
# shell, which is always a different origin from the backend. On --update the
|
||||
# existing list is kept, so hand-added origins survive.
|
||||
CORS_ALLOWED_ORIGINS=""
|
||||
add_origin() {
|
||||
[ -n "${1:-}" ] || return 0
|
||||
case ",$CORS_ALLOWED_ORIGINS," in
|
||||
*",$1,"*) ;;
|
||||
*) CORS_ALLOWED_ORIGINS="${CORS_ALLOWED_ORIGINS:+$CORS_ALLOWED_ORIGINS,}$1" ;;
|
||||
esac
|
||||
}
|
||||
if [ "$UPDATE" -eq 1 ]; then
|
||||
while IFS= read -r origin; do
|
||||
add_origin "$origin"
|
||||
done < <(existing CORS_ALLOWED_ORIGINS | tr ',' '\n')
|
||||
fi
|
||||
[ -n "$FRONTEND" ] && add_origin "https://$FRONTEND"
|
||||
add_origin "app://j621"
|
||||
|
||||
CSRF_TRUSTED_ORIGINS="${FRONTEND:+https://$FRONTEND}"
|
||||
|
||||
J621_SECRET_KEY="$SECRET_KEY" \
|
||||
J621_DB_PASSWORD="$DB_PASSWORD" \
|
||||
J621_DB_ROOT_PASSWORD="$DB_ROOT_PASSWORD" \
|
||||
J621_TS_AUTHKEY="$TS_AUTHKEY" \
|
||||
J621_TS_HOSTNAME="$TS_HOSTNAME" \
|
||||
J621_ALLOWED_HOSTS="$ALLOWED_HOSTS" \
|
||||
J621_CORS_ALLOWED_ORIGINS="$CORS_ALLOWED_ORIGINS" \
|
||||
J621_CSRF_TRUSTED_ORIGINS="$CSRF_TRUSTED_ORIGINS" \
|
||||
python3 - <<'PY'
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
text = Path(".env.example").read_text()
|
||||
|
||||
def apply(name):
|
||||
value = os.environ.get(f"J621_{name}")
|
||||
if not value:
|
||||
return text
|
||||
line = f"{name}={value}"
|
||||
pattern = re.compile(rf"^(?:# )?{re.escape(name)}=.*$", re.M)
|
||||
if pattern.search(text):
|
||||
return pattern.sub(line, text, count=1)
|
||||
return text + f"\n{line}\n"
|
||||
|
||||
for name in (
|
||||
"SECRET_KEY",
|
||||
"TS_AUTHKEY",
|
||||
"TS_HOSTNAME",
|
||||
"ALLOWED_HOSTS",
|
||||
"CORS_ALLOWED_ORIGINS",
|
||||
"CSRF_TRUSTED_ORIGINS",
|
||||
"DB_PASSWORD",
|
||||
"DB_ROOT_PASSWORD",
|
||||
):
|
||||
text = apply(name)
|
||||
|
||||
text = re.sub(r"^DEBUG=.*$", "DEBUG=False", text, count=1, flags=re.M)
|
||||
Path(".env").write_text(text)
|
||||
PY
|
||||
|
||||
chmod 600 .env
|
||||
|
||||
echo
|
||||
echo "Wrote deploy/.env (mode 600):"
|
||||
echo " SECRET_KEY $([ "$UPDATE" -eq 1 ] && echo 'kept from the existing file' || echo 'generated with openssl')"
|
||||
echo " DB passwords $([ "$UPDATE" -eq 1 ] && echo 'kept from the existing file' || echo 'generated with openssl')"
|
||||
echo " TS_HOSTNAME $TS_HOSTNAME"
|
||||
echo " ALLOWED_HOSTS $ALLOWED_HOSTS"
|
||||
[ -n "$CORS_ALLOWED_ORIGINS" ] && echo " cross-origin $CORS_ALLOWED_ORIGINS"
|
||||
[ -z "$TS_AUTHKEY" ] && echo " TS_AUTHKEY still empty - paste your Tailscale auth key before starting"
|
||||
echo
|
||||
echo "Next: docker compose -f compose.yml up -d (or a compose.tailnet*.yml variant)"
|
||||
@@ -17,6 +17,13 @@ map $http_x_forwarded_proto $j621_forwarded_proto {
|
||||
"" $scheme;
|
||||
}
|
||||
|
||||
# /random is both the SPA route and the shell-greeting shortcut: browsers
|
||||
# (Accept: text/html) get the SPA, scripts (curl/wget/fetch) get the API JSON.
|
||||
map $http_accept $j621_random_target {
|
||||
default @j621_random_api;
|
||||
~*text/html @j621_random_spa;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
@@ -26,6 +33,33 @@ server {
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
location ~ ^/random/?$ {
|
||||
error_page 418 = $j621_random_target;
|
||||
return 418;
|
||||
}
|
||||
|
||||
location @j621_random_api {
|
||||
set $j621_backend http://backend:8000;
|
||||
proxy_pass $j621_backend$request_uri;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $j621_forwarded_proto;
|
||||
}
|
||||
|
||||
location @j621_random_spa {
|
||||
set $j621_frontend http://frontend:80;
|
||||
proxy_pass $j621_frontend$request_uri;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $j621_forwarded_proto;
|
||||
}
|
||||
|
||||
location ~ ^/(api|admin|static|health)(/|$) {
|
||||
set $j621_backend http://backend:8000;
|
||||
proxy_pass $j621_backend$request_uri;
|
||||
|
||||
@@ -10,10 +10,16 @@ REGISTRY="gitea.rainbow-herring.ts.net/jakebreath/j621-backend"
|
||||
REGISTRY_HOST="$(printf '%s' "$REGISTRY" | cut -d/ -f1)"
|
||||
SHA="${1:-$(git rev-parse --short HEAD)}"
|
||||
BUILDER=multiarch
|
||||
PLATFORMS="linux/amd64,linux/arm64"
|
||||
PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}"
|
||||
|
||||
echo "==> Logging in to $REGISTRY_HOST ..."
|
||||
docker login "$REGISTRY_HOST"
|
||||
if [ -n "${REGISTRY_USER:-}" ] && [ -n "${REGISTRY_TOKEN:-}" ]; then
|
||||
# Non-interactive login for CI (workflow passes GITHUB_TOKEN).
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" \
|
||||
-u "$REGISTRY_USER" --password-stdin
|
||||
else
|
||||
docker login "$REGISTRY_HOST"
|
||||
fi
|
||||
|
||||
if ! docker buildx inspect "$BUILDER" >/dev/null 2>&1; then
|
||||
echo "==> Creating buildx builder '$BUILDER' ..."
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/bin/bash
|
||||
# Build the J621 desktop packages, optionally publish them to the website
|
||||
# feed.
|
||||
#
|
||||
# Desktop updates no longer depend on this: the app resolves the newest
|
||||
# `desktop-v*` release on Gitea at check time (see desktop/README.md). This
|
||||
# script builds the packages and can copy them to deploy/data/desktop, which
|
||||
# the frontend nginx mounts read-only and serves at /desktop/ for manual
|
||||
# downloads and for pre-0.1.2 installs.
|
||||
#
|
||||
# Usage: ./push_desktop.sh [--win] [--no-build] [--local] [--host user@server:/path]
|
||||
# --win also cross-build the Windows NSIS installer (needs wine)
|
||||
# --no-build publish what is already in desktop/release/
|
||||
# --local only publish locally, skip the remote copy
|
||||
# --host override the remote deploy checkout
|
||||
#
|
||||
# The remote copy defaults to $J621_DESKTOP_FEED_HOST, or
|
||||
# jakerasp:/home/jake/servers/J621 when that is not set.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
DEFAULT_REMOTE="jakerasp:/home/jake/servers/J621"
|
||||
BUILD=1
|
||||
WIN=0
|
||||
REMOTE="${J621_DESKTOP_FEED_HOST:-$DEFAULT_REMOTE}"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--win) WIN=1 ;;
|
||||
--no-build) BUILD=0 ;;
|
||||
--local) REMOTE="" ;;
|
||||
--host) REMOTE="${2:?--host needs user@server:/path}"; shift ;;
|
||||
*)
|
||||
echo "usage: $0 [--win] [--no-build] [--local] [--host user@server:/path]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [ -n "$REMOTE" ] && [ "$REMOTE" = "${REMOTE#*:}" ]; then
|
||||
echo "--host needs user@server:/path (got '$REMOTE')" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ "$BUILD" = 1 ]; then
|
||||
echo "==> Building Linux packages (deb + pacman) ..."
|
||||
npm --prefix desktop run dist:linux
|
||||
if [ "$WIN" = 1 ]; then
|
||||
echo "==> Cross-building the Windows installer (wine) ..."
|
||||
npm --prefix desktop run dist:win
|
||||
fi
|
||||
fi
|
||||
|
||||
FEED=deploy/data/desktop
|
||||
mkdir -p "$FEED"
|
||||
|
||||
shopt -s nullglob
|
||||
VERSION="$(node -p "require('./desktop/package.json').version")"
|
||||
deb=(desktop/release/*"$VERSION"*.deb)
|
||||
zst=(desktop/release/*"$VERSION"*.pkg.tar.zst)
|
||||
linux_meta=(desktop/release/latest-linux.yml)
|
||||
win_exe=("desktop/release/J621 Setup ${VERSION}"*.exe)
|
||||
win_meta=(desktop/release/latest.yml)
|
||||
blockmaps=(desktop/release/*"$VERSION"*.exe.blockmap)
|
||||
|
||||
if [ "${#deb[@]}" -eq 0 ] && [ "${#zst[@]}" -eq 0 ]; then
|
||||
echo "No $VERSION artifacts in desktop/release/ — run without --no-build first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Replace the previous release's metadata and drop older installers from the
|
||||
# feed: latest*.yml only ever points at the current version.
|
||||
rm -f "$FEED"/latest-linux.yml "$FEED"/latest.yml
|
||||
old=("$FEED"/*.deb "$FEED"/*.pkg.tar.zst "$FEED"/"J621 Setup "*.exe "$FEED"/*.exe.blockmap)
|
||||
for file in "${old[@]}"; do
|
||||
case "$(basename "$file")" in
|
||||
*"$VERSION"*) ;;
|
||||
*) echo " pruning $(basename "$file")"; rm -f "$file" ;;
|
||||
esac
|
||||
done
|
||||
cp -f "${deb[@]}" "${zst[@]}" "${linux_meta[@]}" "$FEED"/ 2>/dev/null || true
|
||||
if [ "${#win_exe[@]}" -gt 0 ]; then
|
||||
cp -f "${win_exe[@]}" "${win_meta[@]}" "${blockmaps[@]}" "$FEED"/ 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "==> Published to $FEED:"
|
||||
ls -1sh "$FEED" | sed 's/^/ /'
|
||||
|
||||
if [ -n "$REMOTE" ]; then
|
||||
REMOTE_TARGET="${REMOTE%%:*}"
|
||||
REMOTE_ROOT="${REMOTE#*:}"
|
||||
REMOTE_FEED="${REMOTE_ROOT%/}/deploy/data/desktop"
|
||||
|
||||
echo
|
||||
echo "==> Copying the feed to $REMOTE_TARGET:$REMOTE_FEED ..."
|
||||
if ! ssh "$REMOTE_TARGET" "mkdir -p '$REMOTE_FEED'"; then
|
||||
echo "ssh to $REMOTE_TARGET failed; nothing was copied." >&2
|
||||
exit 1
|
||||
fi
|
||||
# The remote feed keeps only the current version, like the local one.
|
||||
if ! ssh "$REMOTE_TARGET" \
|
||||
"find '$REMOTE_FEED' -maxdepth 1 -type f \\( -name '*.deb' -o -name '*.pkg.tar.zst' -o -name '*.exe' -o -name '*.exe.blockmap' \\) ! -name '*$VERSION*' -exec rm -f {} +"; then
|
||||
echo "==> Could not prune old installers on $REMOTE_TARGET (continuing)." >&2
|
||||
fi
|
||||
|
||||
transferred=0
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
if rsync -a --info=progress2 "$FEED"/ "$REMOTE_TARGET:$REMOTE_FEED/"; then
|
||||
transferred=1
|
||||
else
|
||||
echo "==> rsync did not work (missing on the server?); trying tar over ssh ..."
|
||||
fi
|
||||
fi
|
||||
if [ "$transferred" -eq 0 ]; then
|
||||
if ! tar -C "$FEED" -cf - . |
|
||||
ssh "$REMOTE_TARGET" "tar -C '$REMOTE_FEED' -xf -"; then
|
||||
echo "Copy to $REMOTE_TARGET:$REMOTE_FEED failed." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "==> Feed live at $REMOTE_TARGET:$REMOTE_FEED (no restart needed)."
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Served read-only by the frontend nginx at /desktop/ (no restart needed):"
|
||||
echo " https://<frontend-host>/desktop/latest-linux.yml"
|
||||
@@ -10,10 +10,16 @@ REGISTRY="gitea.rainbow-herring.ts.net/jakebreath/j621-frontend"
|
||||
REGISTRY_HOST="$(printf '%s' "$REGISTRY" | cut -d/ -f1)"
|
||||
SHA="${1:-$(git rev-parse --short HEAD)}"
|
||||
BUILDER=multiarch
|
||||
PLATFORMS="linux/amd64,linux/arm64"
|
||||
PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}"
|
||||
|
||||
echo "==> Logging in to $REGISTRY_HOST ..."
|
||||
docker login "$REGISTRY_HOST"
|
||||
if [ -n "${REGISTRY_USER:-}" ] && [ -n "${REGISTRY_TOKEN:-}" ]; then
|
||||
# Non-interactive login for CI (workflow passes GITHUB_TOKEN).
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login "$REGISTRY_HOST" \
|
||||
-u "$REGISTRY_USER" --password-stdin
|
||||
else
|
||||
docker login "$REGISTRY_HOST"
|
||||
fi
|
||||
|
||||
if ! docker buildx inspect "$BUILDER" >/dev/null 2>&1; then
|
||||
echo "==> Creating buildx builder '$BUILDER' ..."
|
||||
@@ -26,6 +32,7 @@ fi
|
||||
echo "==> Building + pushing $PLATFORMS -> $REGISTRY:{latest,$SHA} ..."
|
||||
docker buildx build --builder "$BUILDER" --push \
|
||||
--platform "$PLATFORMS" \
|
||||
--build-arg "GIT_HASH=$SHA" \
|
||||
-f deploy/J621-Frontend \
|
||||
-t "$REGISTRY:latest" \
|
||||
-t "$REGISTRY:$SHA" \
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/bin/sh
|
||||
# Periodic maintenance for a J621 deployment (runs in the "scheduler"
|
||||
# service; no host cron involved).
|
||||
#
|
||||
# sync_followed_tags + sync_followed_pools every SYNC_EVERY seconds (30 min)
|
||||
# cleanup_similarity every CLEAN_EVERY seconds (1 h)
|
||||
# refresh_guest_blacklist every BLACKLIST_EVERY (24 h)
|
||||
#
|
||||
# Waits for the database and migrations to be ready, runs everything once,
|
||||
# then keeps checking. A failing command is logged and retried next interval,
|
||||
# so a temporary e621 outage is not fatal.
|
||||
set -u
|
||||
|
||||
SYNC_EVERY="${SYNC_EVERY:-1800}"
|
||||
CLEAN_EVERY="${CLEAN_EVERY:-3600}"
|
||||
BLACKLIST_EVERY="${BLACKLIST_EVERY:-86400}"
|
||||
|
||||
log() { echo "[scheduler] $(date -Iseconds) $*"; }
|
||||
|
||||
run() {
|
||||
log "running: $*"
|
||||
if "$@"; then
|
||||
log "done: $*"
|
||||
else
|
||||
log "FAILED (retrying at the next interval): $*"
|
||||
fi
|
||||
}
|
||||
|
||||
command -v python >/dev/null || { log "python not found"; exit 1; }
|
||||
|
||||
log "waiting for the database and migrations..."
|
||||
until python manage.py migrate --check >/dev/null 2>&1; do
|
||||
sleep 10
|
||||
done
|
||||
log "database ready; intervals: sync=${SYNC_EVERY}s cleanup=${CLEAN_EVERY}s blacklist=${BLACKLIST_EVERY}s"
|
||||
|
||||
now=$(date +%s)
|
||||
last_sync=$((now - SYNC_EVERY))
|
||||
last_clean=$((now - CLEAN_EVERY))
|
||||
last_blacklist=$((now - BLACKLIST_EVERY))
|
||||
|
||||
while true; do
|
||||
now=$(date +%s)
|
||||
|
||||
if [ $((now - last_sync)) -ge "$SYNC_EVERY" ]; then
|
||||
last_sync=$now
|
||||
run python manage.py sync_followed_tags
|
||||
run python manage.py sync_followed_pools
|
||||
fi
|
||||
|
||||
if [ $((now - last_clean)) -ge "$CLEAN_EVERY" ]; then
|
||||
last_clean=$now
|
||||
run python manage.py cleanup_similarity
|
||||
fi
|
||||
|
||||
if [ $((now - last_blacklist)) -ge "$BLACKLIST_EVERY" ]; then
|
||||
last_blacklist=$now
|
||||
run python manage.py refresh_guest_blacklist
|
||||
fi
|
||||
|
||||
sleep 30
|
||||
done
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"TCP": {
|
||||
"8443": {
|
||||
"HTTPS": true
|
||||
}
|
||||
},
|
||||
"Web": {
|
||||
"${TS_CERT_DOMAIN}:8443": {
|
||||
"Handlers": {
|
||||
"/": {
|
||||
"Proxy": "http://127.0.0.1:80"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"TCP": {
|
||||
"443": {
|
||||
"HTTPS": true
|
||||
}
|
||||
},
|
||||
"Web": {
|
||||
"${TS_CERT_DOMAIN}:443": {
|
||||
"Handlers": {
|
||||
"/": {
|
||||
"Proxy": "http://127.0.0.1:80"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"TCP": {
|
||||
"443": {
|
||||
"HTTPS": true
|
||||
}
|
||||
},
|
||||
"Web": {
|
||||
"${TS_CERT_DOMAIN}:443": {
|
||||
"Handlers": {
|
||||
"/": {
|
||||
"Proxy": "http://127.0.0.1:80"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
release/
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,119 @@
|
||||
# J621 desktop (Electron)
|
||||
|
||||
An Electron shell around the web build. The main process serves the SPA from
|
||||
the privileged `app://j621` scheme — a real origin, so `localStorage`, OPFS,
|
||||
Web Workers and WebCodecs all behave exactly like they do in Chrome — and the
|
||||
SPA keeps its normal first-run flow: on launch it asks where the backend is
|
||||
(or runs in local mode with no backend at all).
|
||||
|
||||
The renderer code is not forked: the shell loads `frontend/dist` as built by
|
||||
`npm run build` in `frontend/`.
|
||||
|
||||
Licensed under the Jake Labs Non-Commercial Software Licence — see
|
||||
`../LICENSE`.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
src/main.ts window, app:// protocol handler, menu, external links
|
||||
src/preload.ts window.j621Desktop bridge
|
||||
electron-builder.yml deb + pacman + nsis packaging
|
||||
```
|
||||
|
||||
The main process attaches a `Referer` to requests for e621 hosts: Chromium
|
||||
sends no referrer from a custom-scheme page, and e621's CDN answers
|
||||
cross-site image loads without one with a 403 (which Chromium then blocks as
|
||||
ORB). API calls work either way.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# terminal 1: the SPA with its /api proxy (needs the backend for library pages)
|
||||
cd frontend && npm run dev
|
||||
|
||||
# terminal 2
|
||||
cd desktop && npm run dev
|
||||
```
|
||||
|
||||
`npm run dev` builds the main process and points the window at
|
||||
`http://localhost:5173` (`J621_DEV_SERVER`). Without the dev server,
|
||||
`npm start` builds both halves and serves the bundled SPA over `app://j621`.
|
||||
|
||||
## Builds
|
||||
|
||||
```bash
|
||||
npm run dist:linux # release/j621-desktop_0.1.0_amd64.deb + j621-desktop-0.1.0.pkg.tar.zst
|
||||
npm run dist:win # release/J621 Setup 0.1.0.exe (cross-built with wine; untested)
|
||||
npm run dist:all
|
||||
```
|
||||
|
||||
`deploy/build_desktop.sh` wraps the same commands, installs dependencies on
|
||||
first run and prints sizes plus SHA-256 sums for release notes. Nothing is
|
||||
published by it; the CD workflow attaches the artifacts to the Gitea release,
|
||||
which is also the update feed.
|
||||
|
||||
The Arch package can be installed and removed with pacman:
|
||||
|
||||
```bash
|
||||
sudo pacman -U release/j621-desktop-*.pkg.tar.zst
|
||||
sudo pacman -R j621-desktop
|
||||
```
|
||||
|
||||
It installs to `/opt/J621` with a `/usr/bin/j621-desktop` symlink and a
|
||||
`io.j621.desktop` launcher entry (`StartupWMClass` matches the shell's
|
||||
`desktopName`). The Windows installer is a per-user NSIS build, so no admin
|
||||
rights are needed to install or update it — but it is unsigned, so SmartScreen
|
||||
will warn, and it has not been smoke-tested on real Windows.
|
||||
|
||||
## Updates
|
||||
|
||||
The app checks only when asked (**J621 → Check for updates…** in the menu):
|
||||
Linux packages install through pacman/dpkg, which needs administrator rights,
|
||||
and the Windows build is unsigned, so nothing installs silently.
|
||||
|
||||
The check resolves the feed itself: it asks the Gitea API for the newest
|
||||
non-draft `desktop-v*` release (`J621_UPDATE_REPO`, default
|
||||
`https://gitea.rainbow-herring.ts.net/jakebreath/j621` — lowercase on purpose,
|
||||
the API path is case-sensitive), picks the `latest-linux.yml` / `latest.yml`
|
||||
asset for the platform and uses that release as an electron-updater generic
|
||||
feed. The baked `publish.url` in `electron-builder.yml` is metadata only.
|
||||
`J621_UPDATE_URL` overrides the whole lookup (the smoke test uses this).
|
||||
|
||||
Publishing a release:
|
||||
|
||||
1. Bump `version` in `desktop/package.json` — that is what the updater compares.
|
||||
2. Run the manual CD workflow, which builds the packages and attaches them
|
||||
plus both channel files to the Gitea release `desktop-v<version>`.
|
||||
`./deploy/push_desktop.sh --win` does the same build locally (and can also
|
||||
copy the files to the website feed, which is optional now).
|
||||
3. Existing installs find the new version on their next manual check.
|
||||
|
||||
Note for the 0.1.1 → 0.1.2 step: 0.1.1 only knows the old `/desktop/` feed, so
|
||||
publish 0.1.2 there once (`./deploy/push_desktop.sh --no-build`, or install it
|
||||
manually). From 0.1.2 on, updates come from Gitea.
|
||||
|
||||
`package-type` in the app resources tells electron-updater whether to run
|
||||
`pacman -U` or `dpkg -i` (both via pkexec/sudo); the per-user NSIS install
|
||||
updates without elevation.
|
||||
|
||||
## Smoke test
|
||||
|
||||
`npm run smoke` builds everything, runs Electron headless through `xvfb-run`
|
||||
and checks that the bundled SPA loads over `app://j621`: the SPA fallback, an
|
||||
asset fetch, `localStorage`, OPFS, WebCodecs and the preload bridge. It exits
|
||||
non-zero on failure. With `J621_UPDATE_URL` set it also checks that the feed
|
||||
reports the expected version. Pointing `J621_DEV_SERVER` at the Vite dev
|
||||
server checks the development path instead of the bundled one.
|
||||
|
||||
## Backend CORS
|
||||
|
||||
A desktop app is cross-origin to the backend, exactly like the frontend-only
|
||||
Docker deployment. Add the shell's origin to the backend:
|
||||
|
||||
```
|
||||
CORS_ALLOWED_ORIGINS=app://j621
|
||||
```
|
||||
|
||||
The setup screen shows this hint (with the actual origin) when the connection
|
||||
test fails. Without a backend, choose "Continue without a backend" — the
|
||||
e621-facing pages work the same as in the browser.
|
||||
|
After Width: | Height: | Size: 361 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 302 B |
|
After Width: | Height: | Size: 405 B |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 527 B |
|
After Width: | Height: | Size: 758 B |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 941 B |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,71 @@
|
||||
appId: io.j621.desktop
|
||||
productName: J621
|
||||
copyright: Copyright (c) 2026 JakeBreath — Jake Labs Non-Commercial Software Licence
|
||||
|
||||
# Update feed: desktop/src/main.ts resolves the newest desktop-v* release on
|
||||
# Gitea at check time (J621_UPDATE_REPO). This block only tells electron-builder
|
||||
# to emit latest.yml/latest-linux.yml next to the installers; J621_UPDATE_URL
|
||||
# overrides the feed for a fork or a test.
|
||||
publish:
|
||||
provider: generic
|
||||
url: https://gitea.rainbow-herring.ts.net/JakeBreath/J621/releases
|
||||
|
||||
directories:
|
||||
output: release
|
||||
buildResources: build
|
||||
|
||||
# The Electron main process and preload live in dist/ (tsc output). The SPA is
|
||||
# copied from the web build as an extra resource and served over app://j621.
|
||||
files:
|
||||
- dist/**
|
||||
- package.json
|
||||
|
||||
extraResources:
|
||||
- from: ../frontend/dist
|
||||
to: dist
|
||||
filter:
|
||||
- "**/*"
|
||||
|
||||
linux:
|
||||
target:
|
||||
- deb
|
||||
- pacman
|
||||
category: AudioVideo
|
||||
synopsis: Desktop client for the J621 self-hosted media archive
|
||||
description: >-
|
||||
J621 is a self-hosted browser for an e621-linked media library. This
|
||||
desktop build talks to a J621 backend over the network, the same way the
|
||||
web app does.
|
||||
icon: build/icons
|
||||
syncDesktopName: true
|
||||
|
||||
pacman:
|
||||
compression: zstd
|
||||
artifactName: ${name}-${version}.pkg.tar.zst
|
||||
# Electron's shared-library requirements on Arch (the electron-builder
|
||||
# defaults still list an ancient Electron 2 dependency set).
|
||||
depends:
|
||||
- gtk3
|
||||
- nss
|
||||
- libxss
|
||||
- libxtst
|
||||
- xdg-utils
|
||||
- at-spi2-core
|
||||
- libsecret
|
||||
- libnotify
|
||||
- alsa-lib
|
||||
- libcups
|
||||
- libdrm
|
||||
- mesa
|
||||
- libxkbcommon
|
||||
|
||||
win:
|
||||
target:
|
||||
- nsis
|
||||
icon: build/icon.ico
|
||||
|
||||
nsis:
|
||||
oneClick: false
|
||||
perMachine: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
shortcutName: J621
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "j621-desktop",
|
||||
"productName": "J621",
|
||||
"version": "0.1.2",
|
||||
"private": true,
|
||||
"description": "Desktop shell for the J621 self-hosted media archive",
|
||||
"author": {
|
||||
"name": "JakeBreath",
|
||||
"email": "tolozacdcmo@gmail.com"
|
||||
},
|
||||
"homepage": "https://gitea.rainbow-herring.ts.net/JakeBreath/J621",
|
||||
"license": "LicenseRef-Jake-Labs-Non-Commercial",
|
||||
"desktopName": "io.j621.desktop",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"build:main": "tsc -p tsconfig.json",
|
||||
"build:renderer": "npm --prefix ../frontend run build",
|
||||
"build": "npm run build:main && npm run build:renderer",
|
||||
"dev": "npm run build:main && J621_DEV_SERVER=http://localhost:5173 electron .",
|
||||
"start": "npm run build && electron .",
|
||||
"smoke": "npm run build && xvfb-run -a electron . --j621-smoke",
|
||||
"dist:linux": "npm run build && electron-builder --linux",
|
||||
"dist:win": "npm run build && electron-builder --win",
|
||||
"dist:all": "npm run build && electron-builder --linux --win"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.6",
|
||||
"electron": "^44.4.3",
|
||||
"electron-builder": "^26.15.3",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"electron-updater": "^6.8.9"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,681 @@
|
||||
/**
|
||||
* Electron shell for the J621 SPA.
|
||||
*
|
||||
* The renderer is the normal web build (`frontend/dist`), served from the
|
||||
* privileged `app://j621` scheme so it is a real secure origin: localStorage,
|
||||
* OPFS, Web Workers, WebCodecs and `history.pushState` all behave exactly
|
||||
* like they do in Chrome. In development `J621_DEV_SERVER` points the window
|
||||
* at the Vite dev server instead, which keeps HMR and the `/api` proxy.
|
||||
*
|
||||
* The shell is deliberately thin: the SPA still asks for its backend URL on
|
||||
* first launch (or runs in local e621-only mode) and talks to it over HTTP
|
||||
* the same way it does in a browser. The only desktop-specific bits are the
|
||||
* origin, external-link handling and (later) updates.
|
||||
*/
|
||||
import { app, BrowserWindow, dialog, ipcMain, Menu, protocol, session, shell } from "electron";
|
||||
import { autoUpdater } from "electron-updater";
|
||||
import { readFileSync, rmSync } from "node:fs";
|
||||
import { readFile, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const SCHEME = "app";
|
||||
const HOST = "j621";
|
||||
const APP_ORIGIN = `${SCHEME}://${HOST}`;
|
||||
const DEV_SERVER = (process.env.J621_DEV_SERVER ?? "").replace(/\/+$/, "");
|
||||
const SMOKE = process.argv.includes("--j621-smoke");
|
||||
|
||||
if (SMOKE) {
|
||||
// A throwaway profile keeps the checks deterministic (always the first-run
|
||||
// setup screen) and never touches real state or leaves a stale
|
||||
// single-instance lock behind.
|
||||
const smokeDir = path.join(app.getPath("temp"), "j621-smoke");
|
||||
rmSync(smokeDir, { recursive: true, force: true });
|
||||
app.setPath("userData", smokeDir);
|
||||
}
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: SCHEME,
|
||||
privileges: {
|
||||
standard: true,
|
||||
secure: true,
|
||||
supportFetchAPI: true,
|
||||
corsEnabled: true,
|
||||
stream: true,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
".ico": "image/x-icon",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".wasm": "application/wasm",
|
||||
".mp4": "video/mp4",
|
||||
".webm": "video/webm",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
};
|
||||
|
||||
/** Where `vite build` output lives, packaged or not. */
|
||||
function rendererRoot(): string {
|
||||
return app.isPackaged
|
||||
? path.join(process.resourcesPath, "dist")
|
||||
: path.resolve(__dirname, "..", "..", "frontend", "dist");
|
||||
}
|
||||
|
||||
async function existingFile(candidate: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(candidate)).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fileResponse(file: string): Promise<Response> {
|
||||
const body = await readFile(file);
|
||||
const type =
|
||||
MIME_TYPES[path.extname(file).toLowerCase()] ?? "application/octet-stream";
|
||||
return new Response(body, {
|
||||
headers: { "content-type": type, "cache-control": "no-cache" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Paths the nginx proxy sends to Django; there is no backend behind app://. */
|
||||
const BACKEND_PREFIXES = ["/api/", "/admin/", "/static/", "/health"];
|
||||
|
||||
/**
|
||||
* e621's CDN refuses cross-site image loads that carry no Referer
|
||||
* (`Sec-Fetch-Site: cross-site` with an empty referrer returns 403), and
|
||||
* Chromium never sends one for pages on a custom scheme like app://j621.
|
||||
* Attach a normal e621 referrer to its hosts so images load; API calls are
|
||||
* unaffected.
|
||||
*/
|
||||
function installRefererFix(targetSession: Electron.Session): void {
|
||||
targetSession.webRequest.onBeforeSendHeaders(
|
||||
{ urls: ["*://e621.net/*", "*://*.e621.net/*"] },
|
||||
(details, callback) => {
|
||||
const headers = details.requestHeaders;
|
||||
if (!headers.Referer && !headers.referer) {
|
||||
headers.Referer = "https://e621.net/";
|
||||
}
|
||||
callback({ requestHeaders: headers });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handleAppRequest(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
if (url.host !== HOST) return new Response("Not found", { status: 404 });
|
||||
|
||||
const root = rendererRoot();
|
||||
let pathname = decodeURIComponent(url.pathname);
|
||||
if (!pathname || pathname === "/") pathname = "/index.html";
|
||||
|
||||
// Never answer backend paths with the SPA: that made the setup screen's
|
||||
// connection test "succeed" against the shell's own origin.
|
||||
if (
|
||||
BACKEND_PREFIXES.some(
|
||||
(prefix) => pathname === prefix.replace(/\/$/, "") || pathname.startsWith(prefix),
|
||||
)
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
detail: "No backend is attached to the desktop app; set one in /setup.",
|
||||
}),
|
||||
{ status: 404, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
|
||||
const target = path.resolve(root, "." + pathname);
|
||||
if (target !== root && !target.startsWith(root + path.sep)) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
if (await existingFile(target)) return await fileResponse(target);
|
||||
// Missing assets keep their 404; anything else is a client route and
|
||||
// falls back to the SPA entry point (BrowserRouter).
|
||||
if (path.extname(pathname)) return new Response("Not found", { status: 404 });
|
||||
return await fileResponse(path.join(root, "index.html"));
|
||||
} catch (error) {
|
||||
const hint = app.isPackaged
|
||||
? "The bundled frontend is missing from the application resources."
|
||||
: "Build the frontend first: npm --prefix ../frontend run build";
|
||||
console.error("[app://]", error);
|
||||
return new Response(`${hint}\n`, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
interface WindowState {
|
||||
x?: number;
|
||||
y?: number;
|
||||
width: number;
|
||||
height: number;
|
||||
maximized: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_WINDOW_STATE: WindowState = {
|
||||
width: 1440,
|
||||
height: 900,
|
||||
maximized: false,
|
||||
};
|
||||
|
||||
function stateFile(): string {
|
||||
return path.join(app.getPath("userData"), "window-state.json");
|
||||
}
|
||||
|
||||
function readWindowState(): WindowState {
|
||||
try {
|
||||
const parsed = JSON.parse(
|
||||
readFileSync(stateFile(), "utf8"),
|
||||
) as Partial<WindowState>;
|
||||
return { ...DEFAULT_WINDOW_STATE, ...parsed };
|
||||
} catch {
|
||||
return DEFAULT_WINDOW_STATE;
|
||||
}
|
||||
}
|
||||
|
||||
function saveWindowState(win: BrowserWindow): void {
|
||||
if (win.isDestroyed()) return;
|
||||
const { x, y, width, height } = win.getNormalBounds();
|
||||
const state: WindowState = {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
maximized: win.isMaximized(),
|
||||
};
|
||||
void writeFile(stateFile(), JSON.stringify(state)).catch(() => {
|
||||
// Window geometry is a nice-to-have; never fail a quit over it.
|
||||
});
|
||||
}
|
||||
|
||||
function openExternal(rawUrl: string): void {
|
||||
if (/^https?:/i.test(rawUrl)) void shell.openExternal(rawUrl);
|
||||
}
|
||||
|
||||
/** File extensions the SPA navigates to when it wants a save dialog. */
|
||||
const DOWNLOAD_EXTENSIONS = new Set([
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".jpe",
|
||||
".png",
|
||||
".gif",
|
||||
".webp",
|
||||
".avif",
|
||||
".mp4",
|
||||
".webm",
|
||||
".mov",
|
||||
".swf",
|
||||
".zip",
|
||||
".pdf",
|
||||
".bin",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Downloads in the SPA are same-window navigations (`location.href =
|
||||
* url?download=1`, `<a href=...>`), and Chromium cancels those when
|
||||
* `will-navigate` is prevented. Recognise them so they become real
|
||||
* downloads instead of being opened in the browser.
|
||||
*/
|
||||
function isDownloadNavigation(url: URL): boolean {
|
||||
if (/(?:^|&)download=1(?:&|$)/.test(url.search.slice(1))) return true;
|
||||
return DOWNLOAD_EXTENSIONS.has(path.extname(url.pathname).toLowerCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates are manual by design: Linux packages install through pacman/dpkg
|
||||
* (pkexec/sudo) and the Windows build is unsigned, so the app asks before
|
||||
* downloading and again before installing. The feed is resolved at check time
|
||||
* from the newest `desktop-v*` release on Gitea (`J621_UPDATE_REPO`);
|
||||
* `J621_UPDATE_URL` overrides it for forks and the smoke test.
|
||||
*/
|
||||
type UpdateEvent =
|
||||
| { state: "available"; version: string }
|
||||
| { state: "not-available" }
|
||||
| { state: "downloaded"; version: string }
|
||||
| { state: "error"; message: string };
|
||||
|
||||
let updateReporter: ((event: UpdateEvent) => void) | null = null;
|
||||
let updateCheckRunning = false;
|
||||
|
||||
const UPDATE_REPO =
|
||||
process.env.J621_UPDATE_REPO?.trim() ||
|
||||
"https://gitea.rainbow-herring.ts.net/jakebreath/j621";
|
||||
|
||||
interface ReleaseAsset {
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
}
|
||||
|
||||
interface Release {
|
||||
tag_name: string;
|
||||
draft: boolean;
|
||||
prerelease: boolean;
|
||||
assets?: ReleaseAsset[];
|
||||
}
|
||||
|
||||
function releaseVersion(tag: string): [number, number, number] | null {
|
||||
const match = /^desktop-v(\d+)\.(\d+)\.(\d+)$/.exec(tag);
|
||||
if (!match) return null;
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
function compareVersions(
|
||||
a: [number, number, number],
|
||||
b: [number, number, number],
|
||||
): number {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (a[index] !== b[index]) return a[index] - b[index];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the generic feed base electron-updater should use.
|
||||
*
|
||||
* Gitea's API path is case-sensitive (owner/repo must match the login), while
|
||||
* the asset URLs it returns are canonical, so the base is derived from the
|
||||
* platform's metadata asset (`latest-linux.yml` / `latest.yml`).
|
||||
*/
|
||||
async function resolveReleaseFeed(): Promise<string> {
|
||||
const override = process.env.J621_UPDATE_URL?.trim();
|
||||
if (override) return override;
|
||||
const match = /^(https?:\/\/[^/]+)\/([^/]+)\/([^/]+?)\/?$/.exec(UPDATE_REPO);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`J621_UPDATE_REPO must be <origin>/<owner>/<repo> (got ${UPDATE_REPO}).`,
|
||||
);
|
||||
}
|
||||
const [, origin, owner, repo] = match;
|
||||
const response = await fetch(
|
||||
`${origin}/api/v1/repos/${owner}/${repo}/releases?limit=50`,
|
||||
{ headers: { Accept: "application/json" } },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Release lookup on Gitea failed (HTTP ${response.status}).`);
|
||||
}
|
||||
const releases = (await response.json()) as Release[];
|
||||
const assetName =
|
||||
process.platform === "win32" ? "latest.yml" : "latest-linux.yml";
|
||||
let best: { version: [number, number, number]; asset: ReleaseAsset } | null =
|
||||
null;
|
||||
for (const release of releases) {
|
||||
if (release.draft || release.prerelease) continue;
|
||||
const version = releaseVersion(release.tag_name);
|
||||
if (!version) continue;
|
||||
const asset = release.assets?.find((entry) => entry.name === assetName);
|
||||
if (!asset) continue;
|
||||
if (!best || compareVersions(version, best.version) > 0) {
|
||||
best = { version, asset };
|
||||
}
|
||||
}
|
||||
if (!best) {
|
||||
throw new Error(`No ${assetName} asset found in ${UPDATE_REPO} releases.`);
|
||||
}
|
||||
return best.asset.browser_download_url.replace(/\/[^/]*$/, "");
|
||||
}
|
||||
|
||||
function setUpUpdates(win: BrowserWindow): void {
|
||||
if (!app.isPackaged) autoUpdater.forceDevUpdateConfig = true;
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
|
||||
autoUpdater.on("error", (error) => {
|
||||
updateReporter?.({ state: "error", message: error.message });
|
||||
});
|
||||
|
||||
autoUpdater.on("update-not-available", () => {
|
||||
if (SMOKE) return updateReporter?.({ state: "not-available" });
|
||||
void dialog.showMessageBox(win, {
|
||||
type: "info",
|
||||
title: "Updates",
|
||||
message: `J621 ${app.getVersion()} is up to date.`,
|
||||
});
|
||||
});
|
||||
|
||||
autoUpdater.on("update-available", (info) => {
|
||||
if (SMOKE) {
|
||||
return updateReporter?.({ state: "available", version: info.version });
|
||||
}
|
||||
void (async () => {
|
||||
const { response } = await dialog.showMessageBox(win, {
|
||||
type: "info",
|
||||
title: "Updates",
|
||||
message: `J621 ${info.version} is available.`,
|
||||
detail:
|
||||
"Download it now? Installing it later needs administrator rights " +
|
||||
"on Linux (pacman/dpkg); the Windows installer runs without them.",
|
||||
buttons: ["Download", "Later"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
if (response !== 0) return;
|
||||
try {
|
||||
await autoUpdater.downloadUpdate();
|
||||
} catch (error) {
|
||||
await dialog.showMessageBox(win, {
|
||||
type: "error",
|
||||
title: "Update failed",
|
||||
message: "The update could not be downloaded.",
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
autoUpdater.on("download-progress", (progress) => {
|
||||
win.setProgressBar(Math.min(progress.percent / 100, 1));
|
||||
});
|
||||
|
||||
autoUpdater.on("update-downloaded", (info) => {
|
||||
win.setProgressBar(-1);
|
||||
if (SMOKE) {
|
||||
return updateReporter?.({ state: "downloaded", version: info.version });
|
||||
}
|
||||
void (async () => {
|
||||
const { response } = await dialog.showMessageBox(win, {
|
||||
type: "info",
|
||||
title: "Updates",
|
||||
message: `J621 ${info.version} is ready to install.`,
|
||||
detail:
|
||||
process.platform === "linux"
|
||||
? "Installing asks for administrator rights and then restarts J621."
|
||||
: "J621 will restart to finish installing.",
|
||||
buttons: ["Restart and install", "Later"],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
});
|
||||
if (response === 0) {
|
||||
autoUpdater.quitAndInstall(false, true);
|
||||
} else {
|
||||
// Linux packages elevate, so never install silently on quit there.
|
||||
autoUpdater.autoInstallOnAppQuit = process.platform !== "linux";
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
async function checkForUpdates(win: BrowserWindow): Promise<void> {
|
||||
if (updateCheckRunning) return;
|
||||
updateCheckRunning = true;
|
||||
try {
|
||||
const feed = await resolveReleaseFeed();
|
||||
autoUpdater.setFeedURL({ provider: "generic", url: feed });
|
||||
await autoUpdater.checkForUpdates();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
updateReporter?.({ state: "error", message });
|
||||
if (!SMOKE) {
|
||||
await dialog.showMessageBox(win, {
|
||||
type: "error",
|
||||
title: "Updates",
|
||||
message: "Could not check for updates.",
|
||||
detail: message,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
updateCheckRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
function buildMenu(win: BrowserWindow): void {
|
||||
const template: Electron.MenuItemConstructorOptions[] = [
|
||||
{
|
||||
label: "J621",
|
||||
submenu: [
|
||||
{
|
||||
label: "Backend setup…",
|
||||
click: () => void win.loadURL(`${APP_ORIGIN}/setup`),
|
||||
},
|
||||
{
|
||||
label: "Open backend in browser",
|
||||
click: () => win.webContents.send("j621:open-backend"),
|
||||
},
|
||||
{
|
||||
label: "Check for updates…",
|
||||
click: () => void checkForUpdates(win),
|
||||
},
|
||||
{ type: "separator" },
|
||||
{ role: "quit" },
|
||||
],
|
||||
},
|
||||
{ role: "editMenu" },
|
||||
{
|
||||
label: "View",
|
||||
submenu: [
|
||||
{ role: "reload" },
|
||||
{ role: "forceReload" },
|
||||
{ role: "toggleDevTools" },
|
||||
{ type: "separator" },
|
||||
{ role: "resetZoom" },
|
||||
{ role: "zoomIn" },
|
||||
{ role: "zoomOut" },
|
||||
{ type: "separator" },
|
||||
{ role: "togglefullscreen" },
|
||||
],
|
||||
},
|
||||
];
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
|
||||
}
|
||||
|
||||
function createWindow(): BrowserWindow {
|
||||
const state = readWindowState();
|
||||
const win = new BrowserWindow({
|
||||
x: state.x,
|
||||
y: state.y,
|
||||
width: state.width,
|
||||
height: state.height,
|
||||
minWidth: 960,
|
||||
minHeight: 600,
|
||||
backgroundColor: "#11111b",
|
||||
show: false,
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
spellcheck: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (state.maximized) win.maximize();
|
||||
win.once("ready-to-show", () => win.show());
|
||||
win.on("close", () => saveWindowState(win));
|
||||
win.on("closed", () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
// Links to the web open in the user's browser, never in this window.
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
openExternal(url);
|
||||
return { action: "deny" };
|
||||
});
|
||||
win.webContents.on("will-navigate", (event, url) => {
|
||||
const allowed = DEV_SERVER
|
||||
? url.startsWith(DEV_SERVER)
|
||||
: url.startsWith(APP_ORIGIN);
|
||||
if (allowed) return;
|
||||
event.preventDefault();
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (/^https?:$/.test(parsed.protocol) && isDownloadNavigation(parsed)) {
|
||||
win.webContents.downloadURL(url);
|
||||
return;
|
||||
}
|
||||
openExternal(url);
|
||||
});
|
||||
|
||||
buildMenu(win);
|
||||
setUpUpdates(win);
|
||||
void win.loadURL(DEV_SERVER || `${APP_ORIGIN}/`);
|
||||
if (SMOKE) runSmokeTest(win);
|
||||
return win;
|
||||
}
|
||||
|
||||
/**
|
||||
* `npm run smoke`: load the bundled SPA and check the things that are only
|
||||
* true under the custom scheme. Exits non-zero on the first broken promise.
|
||||
*/
|
||||
function runSmokeTest(win: BrowserWindow): void {
|
||||
win.webContents.once("did-finish-load", () => {
|
||||
void (async () => {
|
||||
try {
|
||||
const result = (await win.webContents.executeJavaScript(`(async () => {
|
||||
const waitFor = async (probe, timeout = 8000) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
if (probe()) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const asset = await fetch("/favicon.svg");
|
||||
const route = await fetch("/gallery/some/deep/route");
|
||||
const routeBody = await route.text();
|
||||
const health = await fetch("/health");
|
||||
localStorage.setItem("j621.smoke", "ok");
|
||||
history.pushState({}, "", "/gallery");
|
||||
return {
|
||||
origin: location.origin,
|
||||
title: document.title,
|
||||
assetOk: asset.ok && (await asset.text()).includes("<svg"),
|
||||
routeOk: route.ok && routeBody.includes('id="root"'),
|
||||
// /health must not fall back to the SPA (false "connected" test).
|
||||
healthOk: health.status === 404,
|
||||
// Testing an empty backend URL on the desktop must say so instead
|
||||
// of "Connected" against the shell's own origin.
|
||||
emptyTestOk: await (async () => {
|
||||
const button = [...document.querySelectorAll("button")].find((b) =>
|
||||
b.textContent.includes("Test connection"),
|
||||
);
|
||||
if (!button) return false;
|
||||
button.click();
|
||||
return waitFor(() => document.body.innerText.includes("no backend of its own"));
|
||||
})(),
|
||||
mounted: await waitFor(() => (document.querySelector("#root")?.childElementCount ?? 0) > 0),
|
||||
setupOk: document.body.innerText.includes("Where is your backend?"),
|
||||
storageOk: localStorage.getItem("j621.smoke") === "ok",
|
||||
historyOk: location.pathname === "/gallery",
|
||||
opfsOk: typeof navigator.storage?.getDirectory === "function",
|
||||
webcodecsOk: typeof VideoEncoder !== "undefined",
|
||||
bridgeOk: window.j621Desktop?.isDesktop === true,
|
||||
};
|
||||
})()`, true)) as Record<string, unknown>;
|
||||
|
||||
const expected: Record<string, unknown> = {
|
||||
// In dev the window hosts the Vite dev server, which has no /setup
|
||||
// screen (the SPA only asks for a backend in production builds).
|
||||
origin: DEV_SERVER || APP_ORIGIN,
|
||||
title: "J621",
|
||||
assetOk: true,
|
||||
routeOk: true,
|
||||
mounted: true,
|
||||
setupOk: !DEV_SERVER,
|
||||
// Both are app://-only checks: the Vite dev server serves the SPA
|
||||
// for /health and never shows the setup screen.
|
||||
healthOk: !DEV_SERVER,
|
||||
emptyTestOk: !DEV_SERVER,
|
||||
storageOk: true,
|
||||
historyOk: true,
|
||||
opfsOk: true,
|
||||
webcodecsOk: true,
|
||||
bridgeOk: true,
|
||||
};
|
||||
const failures = Object.entries(expected).filter(
|
||||
([key, value]) => result[key] !== value,
|
||||
);
|
||||
console.log(
|
||||
"[smoke] electron",
|
||||
process.versions.electron,
|
||||
"chrome",
|
||||
process.versions.chrome,
|
||||
);
|
||||
console.log("[smoke]", JSON.stringify(result));
|
||||
if (failures.length > 0) {
|
||||
console.error(
|
||||
"[smoke] FAILED:",
|
||||
failures.map(([key]) => key).join(", "),
|
||||
);
|
||||
app.exit(1);
|
||||
return;
|
||||
}
|
||||
if (process.env.J621_UPDATE_URL) {
|
||||
const update = await new Promise<UpdateEvent>((resolve) => {
|
||||
updateReporter = resolve;
|
||||
const timer = setTimeout(
|
||||
() => resolve({ state: "error", message: "update check timed out" }),
|
||||
60_000,
|
||||
);
|
||||
void checkForUpdates(win).finally(() => clearTimeout(timer));
|
||||
});
|
||||
updateReporter = null;
|
||||
console.log("[smoke] update:", JSON.stringify(update));
|
||||
if (update.state === "error") {
|
||||
console.error("[smoke] FAILED: update check");
|
||||
app.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
console.log("[smoke] OK");
|
||||
app.exit(0);
|
||||
} catch (error) {
|
||||
console.error("[smoke] FAILED:", error);
|
||||
app.exit(1);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on("second-instance", () => {
|
||||
if (!mainWindow) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.focus();
|
||||
});
|
||||
|
||||
app.setAppUserModelId("io.j621.desktop");
|
||||
|
||||
app.whenReady().then(() => {
|
||||
protocol.handle(SCHEME, handleAppRequest);
|
||||
installRefererFix(session.defaultSession);
|
||||
|
||||
ipcMain.handle("j621:version", () => app.getVersion());
|
||||
ipcMain.handle("j621:open-external", (_event, url: unknown) => {
|
||||
if (typeof url === "string") openExternal(url);
|
||||
});
|
||||
|
||||
mainWindow = createWindow();
|
||||
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
mainWindow = createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") app.quit();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* The only bridge between the desktop shell and the SPA. Keep it tiny: the
|
||||
* renderer is the normal web build and must not need Electron to work.
|
||||
*/
|
||||
import { contextBridge, ipcRenderer } from "electron";
|
||||
|
||||
contextBridge.exposeInMainWorld("j621Desktop", {
|
||||
isDesktop: true as const,
|
||||
platform: process.platform,
|
||||
// The SPA's own origin (`app://j621`), shown by the setup screen when it
|
||||
// has to explain which origin to allow in the backend's CORS settings.
|
||||
origin: window.location.origin,
|
||||
getVersion: (): Promise<string> => ipcRenderer.invoke("j621:version"),
|
||||
});
|
||||
|
||||
// Menu → "Open backend in browser": read the SPA's stored backend URL and
|
||||
// hand it to the main process after checking it is a real web URL.
|
||||
ipcRenderer.on("j621:open-backend", () => {
|
||||
let url = "";
|
||||
try {
|
||||
url = window.localStorage.getItem("j621.backend") ?? "";
|
||||
} catch {
|
||||
// Storage can be unavailable in rare cases; nothing to open then.
|
||||
}
|
||||
if (url === "none") url = "";
|
||||
if (url) void ipcRenderer.invoke("j621:open-external", url);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"types": ["node"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
# fish_greeting (updated for the J621 rewrite)
|
||||
|
||||
Shows a random library image as your shell greeting, using fastfetch. This is
|
||||
the updated version of the script from
|
||||
`J621-Django/extras/fish_greeting system/fish_greeting.fish`, adapted to the
|
||||
new REST API.
|
||||
|
||||
## What changed from the old script
|
||||
|
||||
| | Old J621-Django | This version |
|
||||
| --- | --- | --- |
|
||||
| Endpoint | `GET /random/?rating=X` returned **image bytes** | `GET /api/random/?fastfetch=1&rating=X` returns **JSON** |
|
||||
| Image access | same response, `X-File-MD5`/`X-File-Name` headers | signed `url` from the JSON, downloaded separately |
|
||||
| Unsupported types | rolled again (recursion) | server only returns png/jpg/gif in fastfetch mode |
|
||||
| Config | hardcoded `https://j621.jake.i` | `J621_BASE` / `J621_TOKEN` / `J621_WEB` |
|
||||
| Printed link | `/view/<md5>` on the old host | `/detail/<J-ID>` on `J621_WEB` |
|
||||
| Modes, logging, gifsicle, fastfetch flags | same | same (`~/.config/fish/greeting_mode`, `~/.config/j621Logos/logs.log`) |
|
||||
|
||||
The `gm-switch` helper and the `.desktop` launchers from the old repo keep
|
||||
working unchanged: they write the same `~/.config/fish/greeting_mode` file
|
||||
(0 = NSFW, 1 = SFW, 2 = Questionable).
|
||||
|
||||
## Install
|
||||
|
||||
```fish
|
||||
cd extras/fish_greeting
|
||||
fish install.fish
|
||||
```
|
||||
|
||||
The installer copies the function into `~/.config/fish/functions/`, **asks for
|
||||
your API origin** (and an optional scoped token — see below), writes
|
||||
`~/.config/j621Greeting/config.fish` (mode 600, it may hold the token), checks
|
||||
the tools it needs and then verifies the backend: `/health` must answer and a
|
||||
random roll is attempted. It exits non-zero when the backend cannot be
|
||||
reached.
|
||||
|
||||
It also appends a guarded block to `~/.config/fish/config.fish` that sources
|
||||
the function. That is needed on setups whose distro/theme config (CachyOS,
|
||||
Oh My Fish, hand-rolled `config.fish`) defines `fish_greeting` inline while
|
||||
the shell starts — such a definition wins over autoloading from
|
||||
`functions/`, so ours has to be loaded afterwards. The block is written once
|
||||
and repeated installs leave it alone; pass `--no-config` to skip it.
|
||||
|
||||
Non-interactive / re-install:
|
||||
|
||||
```fish
|
||||
fish install.fish --url https://j621.example.ts.net --token <api-token>
|
||||
fish install.fish --no-prompt # defaults, never asks
|
||||
fish install.fish --force # rewrite an existing config
|
||||
```
|
||||
|
||||
Then test:
|
||||
|
||||
```fish
|
||||
fish_greeting
|
||||
```
|
||||
|
||||
Requirements: `curl` (or `wget`), `fastfetch`, `file`. Optional: `gifsicle`
|
||||
(GIF downscaling), `jq` or `python3` (JSON parsing — without either it falls
|
||||
back to grep/sed).
|
||||
|
||||
## No token? That is fine
|
||||
|
||||
The greeting is meant to run **without** a token: it then behaves like a
|
||||
guest of your instance — unsigned image links and only items that are visible
|
||||
to guests. Adding a token to the config unlocks signed links (useful when
|
||||
something else fetches the URL for you) and items that are hidden from
|
||||
guests.
|
||||
|
||||
## Configuration
|
||||
|
||||
Any of these work; the config file is read by the function on every run:
|
||||
|
||||
```fish
|
||||
# ~/.config/j621Greeting/config.fish, or universal variables
|
||||
set -g J621_BASE https://j621.rainbow-herring.ts.net # API origin
|
||||
set -g J621_WEB https://j621.example.ts.net # link origin (split deploys)
|
||||
set -g J621_TOKEN <api token> # signed URLs + hidden items
|
||||
set -g J621_FASTFETCH_CONFIG jake # fastfetch config name
|
||||
```
|
||||
|
||||
`J621_TOKEN` is optional. The recommended value is a **scoped greeting
|
||||
token**: open the app, go to *Account → Shell tokens* (or `/tokens`), create
|
||||
one and copy the `j621r_…` key — it only works with `/api/random/`, so it is
|
||||
safe to keep in this config. It also gives you signed image URLs and access
|
||||
to items that are hidden from guests. Without a token the greeting runs as a
|
||||
guest and sees the public library only.
|
||||
|
||||
## Rating filters
|
||||
|
||||
| `greeting_mode` | Rating requested | Label |
|
||||
| --- | --- | --- |
|
||||
| `0` | `e` | NSFW |
|
||||
| `1` | `s` | SFW |
|
||||
| `2` (default) | `q` | Questionable |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **The distro/theme greeting still shows** (CachyOS, Oh My Fish, …): those
|
||||
configs define `fish_greeting` while the shell starts, which beats
|
||||
autoloading. Check what fish resolved:
|
||||
`functions --details fish_greeting` — it must point at
|
||||
`~/.config/fish/functions/fish_greeting.fish`. If it points at a distro
|
||||
file, run `fish install.fish` again (it adds the source block to
|
||||
`config.fish`) or add it yourself:
|
||||
```fish
|
||||
if test -f ~/.config/fish/functions/fish_greeting.fish
|
||||
source ~/.config/fish/functions/fish_greeting.fish
|
||||
end
|
||||
```
|
||||
at the **end** of `~/.config/fish/config.fish`.
|
||||
- `No logo (No image matches those filters.)` — the library has no images for
|
||||
that rating; try another mode or add files.
|
||||
- `No logo (API error)` — check `J621_BASE`, and that the deployment is up
|
||||
(`https://<host>/health`).
|
||||
- `No logo (download failed)` — the signed URL expired (they last 24 h) or the
|
||||
item was deleted between the two requests; just run it again.
|
||||
- The greeting is slow — the API and image fetch have `--max-time` guards; a
|
||||
slow tailnet link is usually the cause.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Copy to ~/.config/j621Greeting/config.fish and adjust. All of these can also
|
||||
# be universal variables, e.g. `set -Ux J621_BASE https://j621.example.ts.net`.
|
||||
|
||||
# API origin (no trailing slash needed).
|
||||
set -g J621_BASE https://j621.rainbow-herring.ts.net
|
||||
|
||||
# Web origin used in the printed link. Only needed when the SPA is served
|
||||
# from a different host than the API (split deployment).
|
||||
# set -g J621_WEB https://j621-frontend.rainbow-herring.ts.net
|
||||
|
||||
# API token. Optional: gives you signed image URLs and access to items that
|
||||
# are hidden from guests. Use a scoped greeting token (Account -> Shell
|
||||
# tokens, or /tokens in the app): those only work with /api/random/, so they
|
||||
# are safe to keep in this file. The full API token works too, but grants
|
||||
# everything.
|
||||
# set -g J621_TOKEN paste-your-token-here
|
||||
|
||||
# fastfetch config name used for the greeting logo.
|
||||
# set -g J621_FASTFETCH_CONFIG jake
|
||||
@@ -0,0 +1,219 @@
|
||||
# fish_greeting — show a random library image as the shell greeting.
|
||||
#
|
||||
# Updated for the J621 Docker/REST rewrite: the API answers with JSON that
|
||||
# carries a signed URL instead of streaming the image, so this function asks
|
||||
# for one random png/jpg/gif (fastfetch mode), downloads the signed link and
|
||||
# hands the file to fastfetch. Based on the original script from
|
||||
# J621-Django/extras/fish_greeting system/fish_greeting.fish.
|
||||
#
|
||||
# Requires: curl (or wget) and fastfetch; gifsicle is used for animated GIFs
|
||||
# (without it, the GIF is shown as-is).
|
||||
#
|
||||
# Rating modes (same file as the original script):
|
||||
# ~/.config/fish/greeting_mode 0 = NSFW (explicit), 1 = SFW (safe),
|
||||
# 2 = Questionable (default)
|
||||
#
|
||||
# Configuration, any of these (all optional):
|
||||
# ~/.config/j621Greeting/config.fish with `set -g VAR value` lines, or
|
||||
# universal variables, e.g. `set -Ux J621_BASE https://j621.example.ts.net`
|
||||
# J621_BASE API origin (default https://j621.rainbow-herring.ts.net)
|
||||
# J621_TOKEN API token, sent as `Authorization: Token …`; needed for
|
||||
# signed URLs and for hidden-from-guests items (optional)
|
||||
# J621_WEB Web origin used in the printed link (defaults to J621_BASE)
|
||||
# J621_FASTFETCH_CONFIG fastfetch config name (default "jake")
|
||||
|
||||
function __j621_json_field --argument-names json field
|
||||
if command -v jq >/dev/null 2>&1
|
||||
printf '%s' "$json" | jq -r --arg field "$field" '.[$field] // empty'
|
||||
else if command -v python3 >/dev/null 2>&1
|
||||
printf '%s' "$json" | python3 -c "import json,sys; value = json.load(sys.stdin).get(sys.argv[1], ''); print(value if value is not None else '')" $field
|
||||
else
|
||||
printf '%s' "$json" | grep -o "\"$field\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" | head -1 | sed -E 's/.*:[[:space:]]*"//; s/"$//'
|
||||
end
|
||||
end
|
||||
|
||||
function __j621_fetch_random --argument-names rating
|
||||
set base (string trim --right --chars=/ "$J621_BASE")
|
||||
set api_url "$base/api/random/?fastfetch=1&rating=$rating"
|
||||
set curl_args -s -L --max-time 20
|
||||
if set -q J621_TOKEN; and test -n "$J621_TOKEN"
|
||||
set -a curl_args -H "Authorization: Token $J621_TOKEN"
|
||||
end
|
||||
if command -v curl >/dev/null 2>&1
|
||||
curl $curl_args "$api_url" | string collect
|
||||
else if command -v wget >/dev/null 2>&1
|
||||
wget -qO- "$api_url" | string collect
|
||||
end
|
||||
end
|
||||
|
||||
function fish_greeting --argument-names depth
|
||||
# Initialize depth to 0 if not provided or empty
|
||||
if not set -q depth; or test -z "$depth"
|
||||
set depth 0
|
||||
end
|
||||
set MAX_DEPTH 3
|
||||
|
||||
# --- Configuration ---
|
||||
set config_file ~/.config/j621Greeting/config.fish
|
||||
if test -f $config_file
|
||||
source $config_file
|
||||
end
|
||||
set -q J621_BASE; or set -g J621_BASE https://j621.rainbow-herring.ts.net
|
||||
set -q J621_WEB; or set -g J621_WEB $J621_BASE
|
||||
set -q J621_FASTFETCH_CONFIG; or set -g J621_FASTFETCH_CONFIG jake
|
||||
set web_base (string trim --right --chars=/ "$J621_WEB")
|
||||
|
||||
# --- Setup logging ---
|
||||
set log_dir ~/.config/j621Logos
|
||||
if not test -d $log_dir
|
||||
mkdir -p $log_dir
|
||||
end
|
||||
set log_file $log_dir/logs.log
|
||||
|
||||
# --- Mode selection ---
|
||||
if test -f ~/.config/fish/greeting_mode
|
||||
set mode (cat ~/.config/fish/greeting_mode | string trim)
|
||||
else
|
||||
set mode 2
|
||||
end
|
||||
|
||||
switch $mode
|
||||
case "0"
|
||||
set rating e
|
||||
set modename "NSFW"
|
||||
case "1"
|
||||
set rating s
|
||||
set modename "SFW"
|
||||
case "2"
|
||||
set rating q
|
||||
set modename "Questionable"
|
||||
case "*"
|
||||
echo "Unknown mode, using default NSFW"
|
||||
set rating e
|
||||
set modename "NSFW"
|
||||
end
|
||||
|
||||
# --- Ask the API for a random image (JSON with a signed URL) ---
|
||||
set json (__j621_fetch_random $rating)
|
||||
set image_url (__j621_json_field "$json" url)
|
||||
set j_id (__j621_json_field "$json" j_id)
|
||||
set md5 (__j621_json_field "$json" md5)
|
||||
set filename (__j621_json_field "$json" filename)
|
||||
|
||||
if test -z "$image_url"
|
||||
set detail (__j621_json_field "$json" detail)
|
||||
fastfetch --config "$J621_FASTFETCH_CONFIG"
|
||||
if test -n "$detail"
|
||||
printf '\e[4;2;38;2;138;0;222;49mNo logo (%s)\e[0m\n' "$detail"
|
||||
else
|
||||
printf '\e[4;2;38;2;138;0;222;49mNo logo (API error)\e[0m\n'
|
||||
end
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') No logo for rating=$rating: $detail" >> "$log_file"
|
||||
return
|
||||
end
|
||||
|
||||
# --- Download the signed image ---
|
||||
set tempfile (mktemp /tmp/fastfetch_logo_XXXXXX)
|
||||
set download_success 0
|
||||
if command -v curl >/dev/null 2>&1
|
||||
curl -s -L --max-time 60 -o "$tempfile" "$image_url"
|
||||
if test $status -eq 0 -a -s "$tempfile"
|
||||
set download_success 1
|
||||
end
|
||||
else if command -v wget >/dev/null 2>&1
|
||||
wget -q -O "$tempfile" "$image_url"
|
||||
if test $status -eq 0 -a -s "$tempfile"
|
||||
set download_success 1
|
||||
end
|
||||
end
|
||||
|
||||
if test $download_success -ne 1
|
||||
fastfetch --config "$J621_FASTFETCH_CONFIG"
|
||||
printf '\e[4;2;38;2;138;0;222;49mNo logo (download failed)\e[0m\n'
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') Download failed for $j_id" >> "$log_file"
|
||||
rm -f "$tempfile"
|
||||
return
|
||||
end
|
||||
|
||||
# --- Determine MIME type ---
|
||||
set mime (file --mime-type -b "$tempfile" 2>/dev/null | string trim | string collect)
|
||||
|
||||
# --- Accept only PNG, JPEG, GIF; everything else causes one more roll ---
|
||||
if not string match -q "image/png" "$mime"; and not string match -q "image/jpeg" "$mime"; and not string match -q "image/gif" "$mime"
|
||||
if test $depth -lt $MAX_DEPTH
|
||||
fish_greeting (math $depth + 1)
|
||||
else
|
||||
printf '\e[31mMax recursion depth reached, skipping unsupported file type: %s\e[0m\n' "$mime"
|
||||
end
|
||||
if test $depth -eq 0
|
||||
printf '\e[31mForked (Got unsupported type: %s - %s)\e[0m\n' "$mime" "$filename"
|
||||
if test -n "$md5"
|
||||
echo "Link: $web_base/view/$md5"
|
||||
end
|
||||
end
|
||||
rm -f "$tempfile" "$tempfile.tmp" 2>/dev/null
|
||||
return
|
||||
end
|
||||
|
||||
# --- Process the file (only PNG, JPEG, GIF reach here) ---
|
||||
set logo_type "kitty"
|
||||
set processing_success 1
|
||||
|
||||
switch "$mime"
|
||||
case "image/png" "image/jpeg"
|
||||
set logo_type "kitty"
|
||||
|
||||
case "image/gif"
|
||||
set processed_ok 0
|
||||
set simple_file (mktemp /tmp/fastfetch_simple_XXXXXX)
|
||||
set gif_err (mktemp)
|
||||
if command -v gifsicle >/dev/null 2>&1
|
||||
gifsicle --colors 255 "$tempfile" > "$simple_file" 2> "$gif_err"
|
||||
set gif_exit $status
|
||||
if test $gif_exit -eq 0 -a -s "$simple_file"
|
||||
gifsicle --unoptimize --resize-fit 360x360 "$simple_file" > "$tempfile.tmp" 2> "$gif_err"
|
||||
set final_exit $status
|
||||
set gif_warnings (cat "$gif_err" | string trim)
|
||||
if test $final_exit -eq 0 -a -s "$tempfile.tmp" -a -z "$gif_warnings"
|
||||
mv "$tempfile.tmp" "$tempfile"
|
||||
set processed_ok 1
|
||||
set logo_type "kitty-icat"
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') GIF processed with gifsicle" >> "$log_file"
|
||||
else
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') gifsicle final failed: $gif_warnings" >> "$log_file"
|
||||
end
|
||||
else
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') gifsicle --colors failed" >> "$log_file"
|
||||
end
|
||||
end
|
||||
rm -f "$simple_file" "$gif_err"
|
||||
if test $processed_ok -eq 0
|
||||
set logo_type "kitty-icat"
|
||||
echo "$(date '+%Y-%m-%d %H:%M:%S') Using original GIF (processing failed)" >> "$log_file"
|
||||
end
|
||||
end
|
||||
|
||||
# --- Display result ---
|
||||
if test $processing_success -eq 1
|
||||
fastfetch --config "$J621_FASTFETCH_CONFIG" --logo-type "$logo_type" --logo "$tempfile" --logo-width 35
|
||||
set timestamp (date '+%Y-%m-%d %H:%M:%S')
|
||||
echo "$timestamp Downloaded: $j_id $filename [$modename]" >> "$log_file"
|
||||
if test -n "$filename"
|
||||
printf '\e[4;2;38;2;138;0;222;49mLogo from API: %s (%s, %s)\e[0m\n' "$filename" "$modename" "$j_id"
|
||||
echo "Link: $web_base/detail/$j_id"
|
||||
else
|
||||
printf '\e[4;2;38;2;138;0;222;49mLogo from API (%s)\e[0m\n' "$modename"
|
||||
end
|
||||
else
|
||||
fastfetch --config "$J621_FASTFETCH_CONFIG"
|
||||
printf '\e[4;2;38;2;138;0;222;49mNo logo (Image processing error)\e[0m\n'
|
||||
end
|
||||
|
||||
# --- Keep only the last 1000 lines of the log ---
|
||||
set log_tmp (mktemp)
|
||||
tail -n 1000 "$log_file" > "$log_tmp" 2>/dev/null
|
||||
mv "$log_tmp" "$log_file"
|
||||
|
||||
# --- Cleanup ---
|
||||
rm -f "$tempfile" "$tempfile.tmp" 2>/dev/null
|
||||
end
|
||||
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env fish
|
||||
# Install the J621 fish greeting:
|
||||
# * copies fish_greeting.fish into ~/.config/fish/functions/
|
||||
# * writes ~/.config/j621Greeting/config.fish (asks for the API origin and an
|
||||
# optional token, or takes them as flags)
|
||||
# * checks the tools it needs and probes the configured backend
|
||||
#
|
||||
# Usage:
|
||||
# fish install.fish
|
||||
# fish install.fish --url https://j621.example.ts.net --token <api-token>
|
||||
# fish install.fish --no-prompt # never ask, keep/derive defaults
|
||||
# fish install.fish --force # rewrite an existing config
|
||||
# fish install.fish --no-config # do not touch ~/.config/fish/config.fish
|
||||
|
||||
argparse 'url=' 'token=' 'no-prompt' 'force' 'no-config' 'help' -- $argv
|
||||
or begin
|
||||
echo "Try: fish install.fish --help"
|
||||
exit 1
|
||||
end
|
||||
|
||||
if set -q _flag_help
|
||||
sed -n '2,12p' (status --current-filename) | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
end
|
||||
|
||||
set -l here (path resolve (dirname (status --current-filename)))
|
||||
set -l functions_dir ~/.config/fish/functions
|
||||
set -l config_dir ~/.config/j621Greeting
|
||||
set -l config_file $config_dir/config.fish
|
||||
set -l default_url https://j621.rainbow-herring.ts.net
|
||||
set -l url $default_url
|
||||
set -l token ""
|
||||
set -l problems 0
|
||||
|
||||
if set -q _flag_url
|
||||
set url $_flag_url
|
||||
end
|
||||
if set -q _flag_token
|
||||
set token $_flag_token
|
||||
end
|
||||
|
||||
# --- Install the function -----------------------------------------------------
|
||||
mkdir -p $functions_dir
|
||||
cp "$here/fish_greeting.fish" "$functions_dir/fish_greeting.fish"
|
||||
echo "Installed $functions_dir/fish_greeting.fish"
|
||||
|
||||
# --- Override distro/OMF greetings -------------------------------------------
|
||||
# Some setups (CachyOS, OMF themes, hand-rolled configs) define fish_greeting
|
||||
# inline while config.fish is being read. A function defined that way wins over
|
||||
# autoloading, so our file would never load. Source it from the end of
|
||||
# config.fish to define ours last.
|
||||
set -l marker "# >>> J621 greeting >>>"
|
||||
set -l user_config ~/.config/fish/config.fish
|
||||
if set -q _flag_no_config
|
||||
echo "Skipping $user_config (--no-config)"
|
||||
else if test -f $user_config; and grep -qF "$marker" $user_config
|
||||
echo "Config already sources the greeting (marker present)"
|
||||
else
|
||||
mkdir -p (dirname $user_config)
|
||||
begin
|
||||
echo ""
|
||||
echo "$marker"
|
||||
echo "# Loaded after the distro config so it wins over an inline fish_greeting."
|
||||
echo "if test -f $functions_dir/fish_greeting.fish"
|
||||
echo " source $functions_dir/fish_greeting.fish"
|
||||
echo "end"
|
||||
echo "# <<< J621 greeting <<<"
|
||||
end >> $user_config
|
||||
echo "Added the J621 greeting to $user_config (removes any distro greeting override)"
|
||||
end
|
||||
|
||||
# --- Configuration ------------------------------------------------------------
|
||||
mkdir -p $config_dir
|
||||
|
||||
set -l write_config 0
|
||||
if not test -f $config_file
|
||||
set write_config 1
|
||||
else if set -q _flag_force; or set -q _flag_url
|
||||
set write_config 1
|
||||
else
|
||||
echo "Keeping existing $config_file (use --force to rewrite it)"
|
||||
# Read the current origin back so the probe below uses it.
|
||||
set -l current (grep -E '^set -g J621_BASE ' $config_file 2>/dev/null | head -1 | string replace -r '^set -g J621_BASE +' '')
|
||||
test -n "$current"; and set url $current
|
||||
end
|
||||
|
||||
if test $write_config -eq 1
|
||||
if not set -q _flag_no_prompt
|
||||
if not set -q _flag_url
|
||||
read -P "API origin [$default_url]: " answer
|
||||
test -n "$answer"; and set url $answer
|
||||
end
|
||||
if not set -q _flag_token
|
||||
read -P "API token (optional, Enter to run as a guest): " answer
|
||||
test -n "$answer"; and set token $answer
|
||||
end
|
||||
end
|
||||
|
||||
set url (string trim --right --chars=/ "$url")
|
||||
printf '# Written by install.fish on %s\n' (date '+%Y-%m-%d') > $config_file
|
||||
printf 'set -g J621_BASE %s\n' "$url" >> $config_file
|
||||
if test -n "$token"
|
||||
printf 'set -g J621_TOKEN %s\n' "$token" >> $config_file
|
||||
else
|
||||
printf '# set -g J621_TOKEN paste-a-token-here\n' >> $config_file
|
||||
end
|
||||
printf '# set -g J621_WEB %s\n' "$url" >> $config_file
|
||||
printf '# set -g J621_FASTFETCH_CONFIG jake\n' >> $config_file
|
||||
chmod 600 $config_file
|
||||
echo "Wrote $config_file"
|
||||
end
|
||||
|
||||
# --- Dependencies -------------------------------------------------------------
|
||||
for tool in fastfetch file
|
||||
if not command -v $tool >/dev/null 2>&1
|
||||
set problems 1
|
||||
echo "Missing required tool: $tool"
|
||||
end
|
||||
end
|
||||
if not command -v curl >/dev/null 2>&1; and not command -v wget >/dev/null 2>&1
|
||||
set problems 1
|
||||
echo "Missing required tool: curl (or wget)"
|
||||
end
|
||||
for tool in gifsicle jq python3
|
||||
if not command -v $tool >/dev/null 2>&1
|
||||
echo "Optional tool not found: $tool (the greeting still works)"
|
||||
end
|
||||
end
|
||||
|
||||
# --- Probe the configured backend --------------------------------------------
|
||||
echo
|
||||
echo "Checking $url ..."
|
||||
set -l health ""
|
||||
if command -v curl >/dev/null 2>&1
|
||||
set health (curl -s -m 10 "$url/health" | string collect)
|
||||
else
|
||||
set health (wget -qO- -T 10 "$url/health" | string collect)
|
||||
end
|
||||
|
||||
if string match -q '*"status":"ok"*' "$health"
|
||||
echo " backend: ok"
|
||||
set -l random_args -s -m 10
|
||||
if test -n "$token"
|
||||
set -a random_args -H "Authorization: Token $token"
|
||||
end
|
||||
set -l probe ""
|
||||
if command -v curl >/dev/null 2>&1
|
||||
set probe (curl $random_args "$url/api/random/?fastfetch=1" | string collect)
|
||||
else
|
||||
set probe (wget -qO- -T 10 "$url/api/random/?fastfetch=1" | string collect)
|
||||
end
|
||||
set -l detail (printf '%s' "$probe" | grep -o '"detail"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*:[[:space:]]*"//; s/"$//')
|
||||
if test -n "$detail"
|
||||
echo " random: $detail"
|
||||
if test -z "$token"
|
||||
echo " (a token would also let you see items that are hidden from guests)"
|
||||
end
|
||||
else
|
||||
echo " random: ok"
|
||||
end
|
||||
else
|
||||
set problems 1
|
||||
echo " backend did not answer at $url/health"
|
||||
echo " got: $health"
|
||||
echo " Fix J621_BASE in $config_file (or pass --url) and run again."
|
||||
end
|
||||
|
||||
# --- Wrap up ------------------------------------------------------------------
|
||||
echo
|
||||
if test $problems -eq 0
|
||||
echo "Done. Test it now with: fish_greeting"
|
||||
else
|
||||
echo "Finished with problems above; the greeting will report them too."
|
||||
end
|
||||
echo "Rating mode lives in ~/.config/fish/greeting_mode (0=NSFW, 1=SFW, 2=Questionable)."
|
||||
echo "The old gm-switch tool / .desktop launchers keep working — same file."
|
||||
|
||||
exit $problems
|
||||
@@ -23,9 +23,11 @@ import Md5Redirect from "@/features/library/Md5Redirect";
|
||||
import OnlinePage from "@/features/online/OnlinePage";
|
||||
import PoolDetailPage from "@/features/pools/PoolDetailPage";
|
||||
import PoolsPage from "@/features/pools/PoolsPage";
|
||||
import RandomPage from "@/features/random/RandomPage";
|
||||
import SimilarPage from "@/features/similar/SimilarPage";
|
||||
import { SetupPage } from "@/features/setup/SetupPage";
|
||||
import StatsPage from "@/features/stats/StatsPage";
|
||||
import TokensPage from "@/features/tokens/TokensPage";
|
||||
import UploadPage from "@/features/upload/UploadPage";
|
||||
import UsersPage from "@/features/users/UsersPage";
|
||||
import { isAgeVerified, markAgeVerified } from "@/lib/age";
|
||||
@@ -99,6 +101,14 @@ export default function App() {
|
||||
</RequireBackend>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/random"
|
||||
element={
|
||||
<RequireBackend>
|
||||
<RandomPage />
|
||||
</RequireBackend>
|
||||
}
|
||||
/>
|
||||
<Route path="/online" element={<OnlinePage />} />
|
||||
<Route path="/online/view/:postId" element={<PostRedirect />} />
|
||||
<Route path="/pools" element={<PoolsPage />} />
|
||||
@@ -174,6 +184,16 @@ export default function App() {
|
||||
}
|
||||
/>
|
||||
<Route path="/account" element={<AccountPage />} />
|
||||
<Route
|
||||
path="/tokens"
|
||||
element={
|
||||
<RequireBackend>
|
||||
<RequireAuth>
|
||||
<TokensPage />
|
||||
</RequireAuth>
|
||||
</RequireBackend>
|
||||
}
|
||||
/>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
<Route
|
||||
|
||||
@@ -15,6 +15,7 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { StatusFooter } from "@/components/StatusFooter";
|
||||
import { StatusPill } from "@/components/StatusPill";
|
||||
import { Toasts } from "@/components/Toasts";
|
||||
import { UploadIndicator } from "@/components/UploadIndicator";
|
||||
import { api } from "@/lib/api";
|
||||
import { hasBackend } from "@/lib/backend";
|
||||
import { cn } from "@/lib/cn";
|
||||
@@ -82,6 +83,7 @@ export function AppShell() {
|
||||
|
||||
const navItems = [
|
||||
...(backend ? [{ to: "/", label: "Library" }] : []),
|
||||
...(backend ? [{ to: "/random", label: "Random" }] : []),
|
||||
{ to: "/online", label: "Online" },
|
||||
{ to: "/pools", label: "Pools" },
|
||||
...(backend && user ? [{ to: "/followed", label: "Followed" }] : []),
|
||||
@@ -111,7 +113,7 @@ export function AppShell() {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<header className="sticky top-0 z-40 border-b border-ctp-surface0 bg-ctp-crust/95 backdrop-blur">
|
||||
<div className="mx-auto flex h-14 w-full max-w-[1600px] items-center gap-4 px-4">
|
||||
<div className="flex h-14 w-full items-center gap-4 px-3 sm:px-4">
|
||||
<Link to="/" className="flex shrink-0 items-center">
|
||||
<span className="rounded bg-ctp-mauve px-2 py-1 font-mono text-xs font-bold tracking-wide text-ctp-crust">
|
||||
J621
|
||||
@@ -152,6 +154,7 @@ export function AppShell() {
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{backend ? (
|
||||
<>
|
||||
<UploadIndicator />
|
||||
<StatusPill status={status} />
|
||||
{user ? (
|
||||
<>
|
||||
|
||||
@@ -2,11 +2,13 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Cable,
|
||||
Copy,
|
||||
Dices,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
Globe,
|
||||
History,
|
||||
Images,
|
||||
KeyRound,
|
||||
Layers,
|
||||
LogIn,
|
||||
LogOut,
|
||||
@@ -124,6 +126,12 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
icon: FolderOpen,
|
||||
run: () => navigate("/"),
|
||||
},
|
||||
{
|
||||
id: "random",
|
||||
label: "Roll a random image",
|
||||
icon: Dices,
|
||||
run: () => navigate("/random"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
@@ -213,6 +221,12 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
icon: Settings,
|
||||
run: () => navigate("/account"),
|
||||
});
|
||||
list.push({
|
||||
id: "tokens",
|
||||
label: "API tokens",
|
||||
icon: KeyRound,
|
||||
run: () => navigate("/tokens"),
|
||||
});
|
||||
if (user.is_staff || user.is_superuser || user.role === "staff") {
|
||||
list.push({
|
||||
id: "users",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { apiUrl } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import type { MediaItem } from "@/lib/types";
|
||||
@@ -17,8 +18,9 @@ const ratingLabels: Record<string, string> = {
|
||||
};
|
||||
|
||||
export function MediaCard({ item }: { item: MediaItem }) {
|
||||
const preview =
|
||||
item.kind === "video" ? item.thumbnail_url : item.raw_url;
|
||||
// The thumbnail endpoint now builds real 480px previews for images too, so
|
||||
// the grid no longer pulls full-size originals.
|
||||
const preview = apiUrl(item.thumbnail_url);
|
||||
const rating = item.display_rating;
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { useUploadStatus } from "@/lib/uploadStatus";
|
||||
|
||||
const PHASE_LABELS: Record<string, string> = {
|
||||
md5: "e621 MD5",
|
||||
visual: "Visual similarity",
|
||||
iqdb: "IQDB",
|
||||
};
|
||||
|
||||
/**
|
||||
* Shell-wide progress for the server-side upload pipeline.
|
||||
*
|
||||
* Staged uploads keep processing after the Upload page is closed, so this
|
||||
* pill is the "leave and keep an eye on it" view: it lives in the header on
|
||||
* every page and links back to the board.
|
||||
*/
|
||||
export function UploadIndicator() {
|
||||
const { data: status } = useUploadStatus();
|
||||
if (!status || !status.active) return null;
|
||||
|
||||
const total = Math.max(status.total, status.processed + status.failed);
|
||||
const done = status.processed + status.failed;
|
||||
const percent = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0;
|
||||
const broken = status.status === "paused" || status.status === "error";
|
||||
const label =
|
||||
status.status === "paused"
|
||||
? "uploads paused"
|
||||
: status.status === "error"
|
||||
? "uploads failed"
|
||||
: (PHASE_LABELS[status.phase] ?? "processing uploads");
|
||||
const count = total > 0 ? `${done}/${total}` : `${status.outstanding} left`;
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/upload"
|
||||
title={status.error || "Staged uploads are being processed in the background"}
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border px-2 py-1 font-mono text-[10px] transition",
|
||||
broken
|
||||
? "border-ctp-red/40 text-ctp-red hover:bg-ctp-red/10"
|
||||
: "border-ctp-surface1 text-ctp-subtext0 hover:bg-ctp-surface0 hover:text-ctp-text",
|
||||
)}
|
||||
>
|
||||
<span className="hidden sm:inline">{label}</span>
|
||||
<span>{count}</span>
|
||||
<span className="h-1 w-12 overflow-hidden rounded-full bg-ctp-surface0">
|
||||
<span
|
||||
className={cn("block h-full", broken ? "bg-ctp-red" : "bg-ctp-teal")}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { toast } from "@/store/toasts";
|
||||
|
||||
import { AvatarCard } from "./AvatarCard";
|
||||
import { PreferencesCard } from "./PreferencesCard";
|
||||
import { TokensCard } from "./TokensCard";
|
||||
|
||||
const BASE_URL_OPTIONS = [
|
||||
{ value: "https://e621.net", label: "e621.net — main site" },
|
||||
@@ -247,6 +248,7 @@ export default function AccountPage() {
|
||||
</header>
|
||||
<AvatarCard />
|
||||
<PreferencesCard />
|
||||
<TokensCard />
|
||||
{loading && !credentials ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spinner className="h-6 w-6" />
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { UserPreferences } from "@/lib/types";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { toast } from "@/store/toasts";
|
||||
|
||||
const E621_PER_PAGE_OPTIONS = [48, 100, 200, 320];
|
||||
|
||||
const LANDING_OPTIONS: { value: NonNullable<UserPreferences["landing_page"]>; label: string }[] = [
|
||||
{ value: "library", label: "Library" },
|
||||
{ value: "online", label: "Online" },
|
||||
@@ -26,6 +28,7 @@ export function PreferencesCard() {
|
||||
per_page: user?.preferences?.per_page ?? 48,
|
||||
zoom: user?.preferences?.zoom ?? 190,
|
||||
online_hot_default: user?.preferences?.online_hot_default ?? true,
|
||||
e621_per_page: user?.preferences?.e621_per_page ?? 48,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
@@ -149,8 +152,32 @@ export function PreferencesCard() {
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="e621 posts per page">
|
||||
<select
|
||||
className={inputClass}
|
||||
value={values.e621_per_page ?? 48}
|
||||
onChange={(event) =>
|
||||
setValues((current) => ({
|
||||
...current,
|
||||
e621_per_page: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{E621_PER_PAGE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] leading-relaxed text-ctp-overlay0">
|
||||
e621 posts per page drives the Online browser and how many pool posts
|
||||
load per request; e621 caps a request at 320.
|
||||
</p>
|
||||
|
||||
<label className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-ctp-overlay1">
|
||||
Thumbnail size
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { linkButtonClass } from "@/components/ui";
|
||||
|
||||
export function TokensCard() {
|
||||
return (
|
||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-5">
|
||||
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||||
Shell tokens
|
||||
</h2>
|
||||
<p className="mt-1 text-xs leading-relaxed text-ctp-overlay0">
|
||||
Long-lived tokens that only work with the random-image endpoint — for
|
||||
shell greetings and small scripts. They cannot read the library,
|
||||
upload or change your account.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Link to="/tokens" className={linkButtonClass}>
|
||||
Manage API tokens
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -14,11 +14,14 @@ export default function DetailPage() {
|
||||
if (J_ID_RE.test(itemId)) {
|
||||
if (!hasBackend()) return <BackendNeeded />;
|
||||
const normalized = `J-${itemId.slice(2)}`;
|
||||
return <LibraryDetail jId={normalized} />;
|
||||
// Key per item: remount so local state (delete confirm, optimizer, task
|
||||
// view) cannot leak from the previously viewed item.
|
||||
return <LibraryDetail key={normalized} jId={normalized} />;
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(itemId)) {
|
||||
return <OnlineDetail postId={Number(itemId)} />;
|
||||
// Key per post: the download task view must not survive a post change.
|
||||
return <OnlineDetail key={itemId} postId={Number(itemId)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Eye, StarOff } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Eye, StarOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
@@ -13,7 +13,6 @@ import type {
|
||||
FollowedPool,
|
||||
FollowedTag,
|
||||
FollowFeed,
|
||||
Paginated,
|
||||
} from "@/lib/types";
|
||||
|
||||
const ratingPill: Record<string, string> = {
|
||||
@@ -149,48 +148,65 @@ function BlacklistCloudPanel() {
|
||||
q.state.data?.status === "building" ? 2000 : false,
|
||||
});
|
||||
const cloud = query.data;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-ctp-surface0 bg-ctp-base p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
title={expanded ? "Hide the tags" : "Show the tags"}
|
||||
className="flex w-full items-center justify-between gap-2 text-left"
|
||||
>
|
||||
<h2 className="text-sm font-semibold text-ctp-subtext1">
|
||||
Blacklisted tags
|
||||
</h2>
|
||||
<p className="text-xs text-ctp-overlay0">
|
||||
{cloud?.source === "user"
|
||||
? "From your e621 blacklist"
|
||||
: "From e621's anonymous default blacklist"}
|
||||
{cloud ? ` · ${cloud.blacklist_count} entries` : ""}
|
||||
{cloud ? ` · ${cloud.posts} feed post(s) scanned` : ""}
|
||||
{cloud?.computed_at ? ` · computed ${formatDate(cloud.computed_at)}` : ""}
|
||||
<span className="flex items-center gap-1.5 font-mono text-[11px] text-ctp-overlay0">
|
||||
{cloud ? `${cloud.blacklist_count} entries` : "…"}
|
||||
{cloud?.status === "building" ? (
|
||||
<span className="ml-2 inline-flex items-center gap-1.5">
|
||||
<Spinner className="h-3 w-3" /> building…
|
||||
</span>
|
||||
<Spinner className="h-3 w-3" />
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{query.isPending ? (
|
||||
<div className="mt-3 flex justify-center py-4">
|
||||
<Spinner className="h-4 w-4" />
|
||||
</div>
|
||||
) : cloud && cloud.tags.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{cloud.tags.map(([tag, count]) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded border border-ctp-red/30 bg-ctp-red/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-red"
|
||||
>
|
||||
{tag} ({count})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-xs text-ctp-overlay0">
|
||||
No blacklisted tags in your feeds.
|
||||
</p>
|
||||
)}
|
||||
{expanded ? (
|
||||
<>
|
||||
<p className="mt-2 text-xs text-ctp-overlay0">
|
||||
{cloud?.source === "user"
|
||||
? "From your e621 blacklist"
|
||||
: "From e621's anonymous default blacklist"}
|
||||
{cloud ? ` · ${cloud.posts} feed post(s) scanned` : ""}
|
||||
{cloud?.computed_at
|
||||
? ` · computed ${formatDate(cloud.computed_at)}`
|
||||
: ""}
|
||||
</p>
|
||||
{query.isPending ? (
|
||||
<div className="mt-3 flex justify-center py-4">
|
||||
<Spinner className="h-4 w-4" />
|
||||
</div>
|
||||
) : cloud && cloud.tags.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{cloud.tags.map(([tag, count]) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="rounded border border-ctp-red/30 bg-ctp-red/10 px-1.5 py-0.5 font-mono text-[11px] text-ctp-red"
|
||||
>
|
||||
{tag} ({count})
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-xs text-ctp-overlay0">
|
||||
No blacklisted tags in your feeds.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -207,11 +223,12 @@ export default function FollowedPage() {
|
||||
|
||||
const tagsQuery = useQuery({
|
||||
queryKey: ["follows-tags"],
|
||||
queryFn: () => api<Paginated<FollowedTag>>("/api/follows/tags/"),
|
||||
// Unpaginated: this is the complete follow list, shown as cards below.
|
||||
queryFn: () => api<FollowedTag[]>("/api/follows/tags/"),
|
||||
});
|
||||
const poolsQuery = useQuery({
|
||||
queryKey: ["follows-pools"],
|
||||
queryFn: () => api<Paginated<FollowedPool>>("/api/follows/pools/"),
|
||||
queryFn: () => api<FollowedPool[]>("/api/follows/pools/"),
|
||||
});
|
||||
|
||||
const feedQuery = useQuery({
|
||||
@@ -269,8 +286,8 @@ export default function FollowedPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const tags = tagsQuery.data?.results ?? [];
|
||||
const pools = poolsQuery.data?.results ?? [];
|
||||
const tags = tagsQuery.data ?? [];
|
||||
const pools = poolsQuery.data ?? [];
|
||||
const feed = feedQuery.data;
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useState } from "react";
|
||||
import { Button, Spinner } from "@/components/ui";
|
||||
import { api, errorMessage } from "@/lib/api";
|
||||
import { hasBackend } from "@/lib/backend";
|
||||
import type { FollowedPool, Paginated } from "@/lib/types";
|
||||
import type { FollowedPool } from "@/lib/types";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { toast } from "@/store/toasts";
|
||||
|
||||
@@ -23,12 +23,13 @@ export function PoolFollowButton({
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["follows-pools"],
|
||||
queryFn: () => api<Paginated<FollowedPool>>("/api/follows/pools/"),
|
||||
// Unpaginated on purpose: the button must see every follow, not page one.
|
||||
queryFn: () => api<FollowedPool[]>("/api/follows/pools/"),
|
||||
enabled: Boolean(user),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const follow = query.data?.results.find((entry) => entry.pool_id === poolId);
|
||||
const follow = query.data?.find((entry) => entry.pool_id === poolId);
|
||||
const followed = Boolean(follow);
|
||||
|
||||
const mutation = useMutation({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, type MouseEvent } from "react";
|
||||
|
||||
import { api, errorMessage } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import type { FollowedTag, Paginated } from "@/lib/types";
|
||||
import type { FollowedTag } from "@/lib/types";
|
||||
import { useAuth } from "@/store/auth";
|
||||
import { toast } from "@/store/toasts";
|
||||
|
||||
@@ -16,13 +16,14 @@ export function TagFollowToggle({ tag }: { tag: string }) {
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["follows-tags"],
|
||||
queryFn: () => api<Paginated<FollowedTag>>("/api/follows/tags/"),
|
||||
// Unpaginated on purpose: the toggle must see every follow, not page one.
|
||||
queryFn: () => api<FollowedTag[]>("/api/follows/tags/"),
|
||||
enabled: Boolean(user),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const normalized = tag.trim().toLowerCase();
|
||||
const follow = query.data?.results.find((entry) => entry.tag === normalized);
|
||||
const follow = query.data?.find((entry) => entry.tag === normalized);
|
||||
const followed = Boolean(follow);
|
||||
|
||||
const mutation = useMutation({
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { Button, EmptyState, Spinner, inputClass } from "@/components/ui";
|
||||
import { api, errorMessage } from "@/lib/api";
|
||||
import { api, apiUrl, errorMessage } from "@/lib/api";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { formatBytes } from "@/lib/format";
|
||||
import type { MediaItem, Paginated, StorageInfo } from "@/lib/types";
|
||||
@@ -245,7 +245,7 @@ export default function DeletePage() {
|
||||
>
|
||||
<div className="relative aspect-square overflow-hidden bg-ctp-mantle">
|
||||
<img
|
||||
src={item.thumbnail_url}
|
||||
src={apiUrl(item.thumbnail_url)}
|
||||
alt={item.filename}
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover"
|
||||
|
||||