Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e82208926 | |||
| 85b640f32e | |||
| c7001f4aed | |||
| f827612930 | |||
| 3f0153cba5 | |||
| 52fff00353 | |||
| f3e8f30a8f | |||
| eee107766e | |||
| c14338cbce | |||
| 7a64730bd2 | |||
| 1803a09306 | |||
| 8c36dd28b0 | |||
| 0f7cd3cb76 | |||
| 7b0dd4182c |
@@ -2,7 +2,17 @@ name: Build images
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [dev, main]
|
# `:dev` builds dropped 2026-05-26 — operator tests from `:latest` after
|
||||||
|
# merge-to-main, not from the dev branch image. Saves one full docker
|
||||||
|
# build per dev push.
|
||||||
|
branches: [main]
|
||||||
|
# Tag-push triggers an immutable per-version image build (e.g.
|
||||||
|
# `:v26.05.26.5`) — gives a real rollback story alongside the floating
|
||||||
|
# `:main` / `:latest`. Layer reuse keeps the registry-storage cost
|
||||||
|
# negligible per tag. Doesn't overlap with the push-to-main build (that
|
||||||
|
# one publishes `:main` + `:latest`; the tag-push build publishes only
|
||||||
|
# `:<tag>`).
|
||||||
|
tags: ['v*']
|
||||||
|
|
||||||
# Requires repo secret RELEASE_TOKEN — a Forgejo PAT with scopes:
|
# Requires repo secret RELEASE_TOKEN — a Forgejo PAT with scopes:
|
||||||
# - write:package, read:package (for docker push to git.fabledsword.com)
|
# - write:package, read:package (for docker push to git.fabledsword.com)
|
||||||
@@ -158,8 +168,12 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Download signed XPI from Forgejo release asset (main only)
|
- name: Download signed XPI from Forgejo release asset (main + tags)
|
||||||
if: github.ref == 'refs/heads/main'
|
# Fires on main-push AND on tag-push. Tag-push builds re-package the
|
||||||
|
# same source code as the preceding main-push build but with an
|
||||||
|
# immutable version tag — they need the XPI too, otherwise the
|
||||||
|
# versioned image ships without the signed extension.
|
||||||
|
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/')
|
||||||
env:
|
env:
|
||||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
@@ -200,7 +214,20 @@ jobs:
|
|||||||
- name: Determine tag
|
- name: Determine tag
|
||||||
id: tag
|
id: tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${GITHUB_REF##*/}" = "main" ]; then
|
# Three trigger shapes:
|
||||||
|
# refs/tags/v… → tag-push: publish ONLY the immutable version
|
||||||
|
# tag (e.g. :v26.05.26.5). Don't touch :latest;
|
||||||
|
# that already got published by the main-push
|
||||||
|
# build for the merge commit.
|
||||||
|
# refs/heads/main → push to main (incl. PR merge commits):
|
||||||
|
# publish :main + :latest (floating).
|
||||||
|
# anything else → safety net; shouldn't fire given the `on:`
|
||||||
|
# config above (dev was dropped). Tag :dev to
|
||||||
|
# surface the unexpected run in the registry.
|
||||||
|
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
|
||||||
|
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||||
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT"
|
||||||
|
elif [ "${GITHUB_REF##*/}" = "main" ]; then
|
||||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:main,git.fabledsword.com/bvandeusen/fabledcurator:latest" >> "$GITHUB_OUTPUT"
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:main,git.fabledsword.com/bvandeusen/fabledcurator:latest" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
|
||||||
@@ -231,7 +258,13 @@ jobs:
|
|||||||
- name: Determine tag
|
- name: Determine tag
|
||||||
id: tag
|
id: tag
|
||||||
run: |
|
run: |
|
||||||
if [ "${GITHUB_REF##*/}" = "main" ]; then
|
# Mirrors build-web's three-shape logic (tag-push / main-push /
|
||||||
|
# safety-net dev). The -ml image follows the same release cadence
|
||||||
|
# as the web image.
|
||||||
|
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
|
||||||
|
TAG_NAME="${GITHUB_REF#refs/tags/}"
|
||||||
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT"
|
||||||
|
elif [ "${GITHUB_REF##*/}" = "main" ]; then
|
||||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:main,git.fabledsword.com/bvandeusen/fabledcurator-ml:latest" >> "$GITHUB_OUTPUT"
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:main,git.fabledsword.com/bvandeusen/fabledcurator-ml:latest" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT"
|
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT"
|
||||||
|
|||||||
+15
-30
@@ -8,8 +8,10 @@ name: CI
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [dev, main]
|
branches: [dev, main]
|
||||||
pull_request:
|
# pull_request trigger intentionally absent — with branches: [dev, main]
|
||||||
branches: [main]
|
# above, every PR commit already fires CI via the push event on dev. Adding
|
||||||
|
# pull_request would duplicate runs on dev→main PRs. FC has no fork PRs
|
||||||
|
# (single-operator Forgejo repo) so push coverage is complete.
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
backend-lint-and-test:
|
backend-lint-and-test:
|
||||||
@@ -24,13 +26,17 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Cache pip wheels
|
# Cache step removed 2026-05-26: act_runner's cache backend has been
|
||||||
uses: actions/cache@v4
|
# broken on this homelab runner since 2026-05-15 (first as request-
|
||||||
with:
|
# timeout warnings, then as hard "Cannot find module .../dist/restore/
|
||||||
path: ~/.cache/pip
|
# index.js" failures that tank the whole job). The cache step targeted
|
||||||
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
# ~/.cache/pip but the install below uses `uv pip install` primarily,
|
||||||
restore-keys: |
|
# whose own cache lives at ~/.cache/uv — so the cache step's real
|
||||||
pip-${{ runner.os }}-py314-
|
# benefit was marginal even when working. Cost of removal: ~30s of
|
||||||
|
# wheel downloads per job. Future re-enable: mount ~/.cache/uv as a
|
||||||
|
# docker volume at the runner level (skips actions/cache entirely),
|
||||||
|
# or fix the runner-side cache backend (clear /var/run/act/actions/*,
|
||||||
|
# pin act_runner version, etc.).
|
||||||
|
|
||||||
- name: Install Python deps
|
- name: Install Python deps
|
||||||
# ruff is pre-installed in the ci-python image (see CI-Runner/CI-python/
|
# ruff is pre-installed in the ci-python image (see CI-Runner/CI-python/
|
||||||
@@ -124,13 +130,6 @@ jobs:
|
|||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Cache pip wheels
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.cache/pip
|
|
||||||
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
|
||||||
restore-keys: |
|
|
||||||
pip-${{ runner.os }}-py314-
|
|
||||||
- name: API integration shard (resolve service IPs, migrate, test)
|
- name: API integration shard (resolve service IPs, migrate, test)
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
@@ -189,13 +188,6 @@ jobs:
|
|||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Cache pip wheels
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.cache/pip
|
|
||||||
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
|
||||||
restore-keys: |
|
|
||||||
pip-${{ runner.os }}-py314-
|
|
||||||
- name: Importer integration shard (resolve service IPs, migrate, test)
|
- name: Importer integration shard (resolve service IPs, migrate, test)
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
@@ -254,13 +246,6 @@ jobs:
|
|||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Cache pip wheels
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.cache/pip
|
|
||||||
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
|
||||||
restore-keys: |
|
|
||||||
pip-${{ runner.os }}-py314-
|
|
||||||
- name: Core integration shard (everything not api / importer / migration / phash / sidecar / scan / archive / backfill)
|
- name: Core integration shard (everything not api / importer / migration / phash / sidecar / scan / archive / backfill)
|
||||||
run: |
|
run: |
|
||||||
set -eux
|
set -eux
|
||||||
|
|||||||
@@ -16,12 +16,21 @@ filesystem importer was misusing Source as a per-post key.
|
|||||||
Migration steps per (artist_id, platform) group with >1 Source:
|
Migration steps per (artist_id, platform) group with >1 Source:
|
||||||
1. Pick canonical — prefer a URL NOT matching '/posts/<id>$' (real
|
1. Pick canonical — prefer a URL NOT matching '/posts/<id>$' (real
|
||||||
campaign URL like /cw/Atole); else min(id).
|
campaign URL like /cw/Atole); else min(id).
|
||||||
2. Reparent Posts and ImageProvenance off the other Sources onto canonical.
|
2. PRE-merge any Posts under non-canonical sources whose
|
||||||
3. Merge any Posts that collide on (canonical_source_id, external_post_id)
|
external_post_id ALREADY exists under the canonical source. (Same
|
||||||
by repointing ImageProvenance + ImageRecord.primary_post_id to the
|
gallery-dl post imported via two different sidecar paths can plant
|
||||||
earliest Post, dedupe ImageProvenance, delete the loser Posts.
|
two Post rows with identical external_post_id under different
|
||||||
4. Delete the orphan Source rows.
|
Sources for the same artist.) Repoint ImageProvenance +
|
||||||
5. If the canonical Source's URL still looks like a per-post URL (no
|
ImageRecord.primary_post_id to the canonical-side Post, dedupe
|
||||||
|
ImageProvenance against alembic 0021's uq, then delete the
|
||||||
|
non-canonical-side Post. This MUST happen before step 3 — Postgres
|
||||||
|
fires uq_post_source_external_id row-by-row during the bulk UPDATE
|
||||||
|
and the merge-after-reparent ordering 500s on first collision
|
||||||
|
(operator-hit during v26.05.26.1 deploy, 2026-05-26).
|
||||||
|
3. Reparent remaining Posts onto canonical (no collisions possible now).
|
||||||
|
4. Reparent ImageProvenance.source_id off the non-canonical sources.
|
||||||
|
5. Delete the orphan Source rows.
|
||||||
|
6. If the canonical Source's URL still looks like a per-post URL (no
|
||||||
campaign URL existed among candidates), rewrite it to
|
campaign URL existed among candidates), rewrite it to
|
||||||
'sidecar:<platform>:<artist_slug>' so the artist detail page shows
|
'sidecar:<platform>:<artist_slug>' so the artist detail page shows
|
||||||
something readable.
|
something readable.
|
||||||
@@ -74,7 +83,84 @@ def upgrade() -> None:
|
|||||||
if not other_ids:
|
if not other_ids:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Reparent Posts off the other Sources.
|
# STEP 2: PRE-merge ALL Posts with duplicate external_post_id
|
||||||
|
# across the entire (canonical + others) group, BEFORE the bulk
|
||||||
|
# reparent. Two cases must both be handled:
|
||||||
|
# (A) canonical has Post X with epid=N; an "other" source has
|
||||||
|
# Post Y with epid=N → after bulk UPDATE, (canonical, N)
|
||||||
|
# collides with itself.
|
||||||
|
# (B) two different "other" sources each have a Post with
|
||||||
|
# epid=N; canonical has none → after bulk UPDATE, both
|
||||||
|
# are repointed to (canonical, N) and the second collides.
|
||||||
|
# The earlier version of this migration only handled (A); the
|
||||||
|
# operator's deploy 2026-05-26 tripped (B) at line 139.
|
||||||
|
# Fix: group ALL Posts in the (artist, platform) by epid; for
|
||||||
|
# any group with count>1, pick the keep (prefer one already
|
||||||
|
# under canonical; else lowest id) and merge the rest into it.
|
||||||
|
all_posts = conn.execute(
|
||||||
|
text("""
|
||||||
|
SELECT external_post_id, id, source_id
|
||||||
|
FROM post
|
||||||
|
WHERE source_id = :canonical OR source_id = ANY(:others)
|
||||||
|
ORDER BY external_post_id, id
|
||||||
|
"""),
|
||||||
|
{"canonical": canonical_id, "others": other_ids},
|
||||||
|
).fetchall()
|
||||||
|
by_epid: dict = {}
|
||||||
|
for epid, post_id, src_id in all_posts:
|
||||||
|
by_epid.setdefault(epid, []).append((post_id, src_id))
|
||||||
|
for _epid, posts in by_epid.items():
|
||||||
|
if len(posts) <= 1:
|
||||||
|
continue
|
||||||
|
# Prefer a Post already under canonical as the keep.
|
||||||
|
canonical_posts = [p for p in posts if p[1] == canonical_id]
|
||||||
|
if canonical_posts:
|
||||||
|
keep_id = canonical_posts[0][0]
|
||||||
|
else:
|
||||||
|
keep_id = posts[0][0] # already sorted by id ASC
|
||||||
|
drop_ids = [p[0] for p in posts if p[0] != keep_id]
|
||||||
|
for drop_id in drop_ids:
|
||||||
|
# Pre-delete image_provenance rows under drop_ whose
|
||||||
|
# image_record_id ALREADY has a provenance under keep —
|
||||||
|
# the UPDATE below would otherwise repoint them and
|
||||||
|
# trip uq_image_provenance_image_post (alembic 0021)
|
||||||
|
# row-by-row before any after-the-fact dedupe could
|
||||||
|
# run. Operator's v26.05.26.3 deploy 2026-05-26 tripped
|
||||||
|
# this at line 123.
|
||||||
|
conn.execute(
|
||||||
|
text("""
|
||||||
|
DELETE FROM image_provenance
|
||||||
|
WHERE post_id = :drop_
|
||||||
|
AND image_record_id IN (
|
||||||
|
SELECT image_record_id FROM image_provenance
|
||||||
|
WHERE post_id = :keep
|
||||||
|
)
|
||||||
|
"""),
|
||||||
|
{"keep": keep_id, "drop_": drop_id},
|
||||||
|
)
|
||||||
|
# Now safe to repoint the survivors.
|
||||||
|
conn.execute(
|
||||||
|
text("""
|
||||||
|
UPDATE image_provenance SET post_id = :keep
|
||||||
|
WHERE post_id = :drop_
|
||||||
|
"""),
|
||||||
|
{"keep": keep_id, "drop_": drop_id},
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text("""
|
||||||
|
UPDATE image_record SET primary_post_id = :keep
|
||||||
|
WHERE primary_post_id = :drop_
|
||||||
|
"""),
|
||||||
|
{"keep": keep_id, "drop_": drop_id},
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
text("DELETE FROM post WHERE id = :drop_"),
|
||||||
|
{"drop_": drop_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
# STEP 3: Bulk reparent the remaining Posts off the other
|
||||||
|
# Sources. After step 2, no collisions on
|
||||||
|
# (canonical, external_post_id) are possible.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
text("""
|
text("""
|
||||||
UPDATE post SET source_id = :canonical
|
UPDATE post SET source_id = :canonical
|
||||||
@@ -82,7 +168,9 @@ def upgrade() -> None:
|
|||||||
"""),
|
"""),
|
||||||
{"canonical": canonical_id, "others": other_ids},
|
{"canonical": canonical_id, "others": other_ids},
|
||||||
)
|
)
|
||||||
# Reparent ImageProvenance.source_id similarly (denormalized FK).
|
|
||||||
|
# STEP 4: Reparent ImageProvenance.source_id (denormalized FK).
|
||||||
|
# No UNIQUE on source_id; safe bulk update.
|
||||||
conn.execute(
|
conn.execute(
|
||||||
text("""
|
text("""
|
||||||
UPDATE image_provenance SET source_id = :canonical
|
UPDATE image_provenance SET source_id = :canonical
|
||||||
@@ -91,53 +179,7 @@ def upgrade() -> None:
|
|||||||
{"canonical": canonical_id, "others": other_ids},
|
{"canonical": canonical_id, "others": other_ids},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Merge any Posts colliding on (canonical, external_post_id) after
|
# STEP 5: Drop the orphan Sources.
|
||||||
# reparent. In practice within a single (artist, platform) group
|
|
||||||
# these should be rare — each gallery-dl post has a unique id — but
|
|
||||||
# safe to handle.
|
|
||||||
post_collisions = conn.execute(
|
|
||||||
text("""
|
|
||||||
SELECT external_post_id, ARRAY_AGG(id ORDER BY id) AS ids
|
|
||||||
FROM post
|
|
||||||
WHERE source_id = :canonical
|
|
||||||
GROUP BY external_post_id
|
|
||||||
HAVING COUNT(*) > 1
|
|
||||||
"""),
|
|
||||||
{"canonical": canonical_id},
|
|
||||||
).fetchall()
|
|
||||||
for _epid, post_ids in post_collisions:
|
|
||||||
keep = post_ids[0]
|
|
||||||
drops = post_ids[1:]
|
|
||||||
conn.execute(
|
|
||||||
text("""
|
|
||||||
UPDATE image_provenance SET post_id = :keep
|
|
||||||
WHERE post_id = ANY(:drops)
|
|
||||||
"""),
|
|
||||||
{"keep": keep, "drops": drops},
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
text("""
|
|
||||||
UPDATE image_record SET primary_post_id = :keep
|
|
||||||
WHERE primary_post_id = ANY(:drops)
|
|
||||||
"""),
|
|
||||||
{"keep": keep, "drops": drops},
|
|
||||||
)
|
|
||||||
# Repointed provenance rows may now collide on
|
|
||||||
# uq_image_provenance_image_post (alembic 0021). Same dedupe
|
|
||||||
# pattern: keep min(id) per (image_record_id, post_id).
|
|
||||||
conn.execute(text("""
|
|
||||||
DELETE FROM image_provenance ip1
|
|
||||||
USING image_provenance ip2
|
|
||||||
WHERE ip1.image_record_id = ip2.image_record_id
|
|
||||||
AND ip1.post_id = ip2.post_id
|
|
||||||
AND ip1.id > ip2.id
|
|
||||||
"""))
|
|
||||||
conn.execute(
|
|
||||||
text("DELETE FROM post WHERE id = ANY(:drops)"),
|
|
||||||
{"drops": drops},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Drop the orphan Sources.
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
text("DELETE FROM source WHERE id = ANY(:others)"),
|
text("DELETE FROM source WHERE id = ANY(:others)"),
|
||||||
{"others": other_ids},
|
{"others": other_ids},
|
||||||
|
|||||||
+38
-1
@@ -3,13 +3,23 @@
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Quart
|
from quart import Quart, request
|
||||||
|
|
||||||
from .api import all_blueprints
|
from .api import all_blueprints
|
||||||
from .config import get_config
|
from .config import get_config
|
||||||
from .frontend import frontend_bp
|
from .frontend import frontend_bp
|
||||||
from .services.credential_crypto import CredentialCrypto
|
from .services.credential_crypto import CredentialCrypto
|
||||||
|
|
||||||
|
# Browser-extension origins. The FabledCurator extension fetches from
|
||||||
|
# moz-extension://<uuid>/ on Firefox and chrome-extension://<uuid>/ on
|
||||||
|
# Chromium-based browsers. Operator-flagged 2026-05-26: extension's
|
||||||
|
# 'Test connection' returned `NetworkError` because the X-Extension-Key
|
||||||
|
# header on /api/credentials triggers a CORS preflight that our routes
|
||||||
|
# don't handle. Whitelisting only these two schemes (not opening CORS
|
||||||
|
# up generally) lets the extension talk to a plain-HTTP self-hosted FC
|
||||||
|
# without weakening the no-CORS posture for normal browser usage.
|
||||||
|
_EXTENSION_ORIGIN_SCHEMES = ("moz-extension://", "chrome-extension://")
|
||||||
|
|
||||||
_CREDENTIAL_KEY_PATH = Path("/images/secrets/credential_key.b64")
|
_CREDENTIAL_KEY_PATH = Path("/images/secrets/credential_key.b64")
|
||||||
|
|
||||||
|
|
||||||
@@ -35,6 +45,33 @@ def create_app() -> Quart:
|
|||||||
# Registered last so /api/* routes win over the SPA catch-all.
|
# Registered last so /api/* routes win over the SPA catch-all.
|
||||||
app.register_blueprint(frontend_bp)
|
app.register_blueprint(frontend_bp)
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
async def _extension_cors_preflight():
|
||||||
|
# Short-circuit OPTIONS preflight from the browser extension with a
|
||||||
|
# 204 + CORS headers (the after_request hook below adds them).
|
||||||
|
# Without this, OPTIONS lands on routes that only declared POST/GET
|
||||||
|
# methods and 405s before the after_request gets a chance.
|
||||||
|
if request.method != "OPTIONS":
|
||||||
|
return None
|
||||||
|
origin = request.headers.get("Origin", "")
|
||||||
|
if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES):
|
||||||
|
return "", 204
|
||||||
|
return None
|
||||||
|
|
||||||
|
@app.after_request
|
||||||
|
async def _extension_cors_headers(response):
|
||||||
|
origin = request.headers.get("Origin", "")
|
||||||
|
if any(origin.startswith(s) for s in _EXTENSION_ORIGIN_SCHEMES):
|
||||||
|
response.headers["Access-Control-Allow-Origin"] = origin
|
||||||
|
response.headers["Access-Control-Allow-Methods"] = (
|
||||||
|
"GET, POST, PATCH, DELETE, OPTIONS"
|
||||||
|
)
|
||||||
|
response.headers["Access-Control-Allow-Headers"] = (
|
||||||
|
"Content-Type, X-Extension-Key"
|
||||||
|
)
|
||||||
|
response.headers["Access-Control-Max-Age"] = "86400"
|
||||||
|
return response
|
||||||
|
|
||||||
@app.after_serving
|
@app.after_serving
|
||||||
async def _dispose_db_engine() -> None:
|
async def _dispose_db_engine() -> None:
|
||||||
from .extensions import dispose_engine
|
from .extensions import dispose_engine
|
||||||
|
|||||||
@@ -25,6 +25,11 @@
|
|||||||
</v-tab>
|
</v-tab>
|
||||||
<v-tab value="management">Management</v-tab>
|
<v-tab value="management">Management</v-tab>
|
||||||
</v-tabs>
|
</v-tabs>
|
||||||
|
|
||||||
|
<!-- Right-side spacer: balances the left cell's flex weight so the
|
||||||
|
centered tabs stay geometrically centered regardless of the
|
||||||
|
artist-name length. Mirrors TopNav's 1fr | auto | 1fr layout. -->
|
||||||
|
<div class="fc-artist-header__right" />
|
||||||
</header>
|
</header>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -54,11 +59,13 @@ const stats = computed(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Matches TopNav.vue's frosted recipe exactly — top:64px parks it under
|
/* Matches TopNav.vue's frosted recipe exactly. top:48px parks it flush
|
||||||
the 64px-tall TopNav with no visible seam. */
|
against TopNav's bottom edge (TopNav is 0.75rem padding + ~24px content
|
||||||
|
= ~48px tall; operator-flagged 2026-05-26 that top:64px left a visible
|
||||||
|
gap). The two bars now read as one continuous frosted strip. */
|
||||||
.fc-artist-header {
|
.fc-artist-header {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 64px;
|
top: 48px;
|
||||||
z-index: 4;
|
z-index: 4;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -107,6 +114,11 @@ const stats = computed(() => {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fc-artist-header__right {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.fc-artist-header__tab-count {
|
.fc-artist-header__tab-count {
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
<v-progress-circular indeterminate color="accent" size="36" />
|
<v-progress-circular indeterminate color="accent" size="36" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-container v-else-if="store.notFound" class="py-6">
|
<v-container v-else-if="store.notFound" class="pt-2 pb-6">
|
||||||
<v-alert type="warning" variant="tonal">Artist not found.</v-alert>
|
<v-alert type="warning" variant="tonal">Artist not found.</v-alert>
|
||||||
</v-container>
|
</v-container>
|
||||||
|
|
||||||
<v-container v-else-if="store.error && !store.overview" class="py-6">
|
<v-container v-else-if="store.error && !store.overview" class="pt-2 pb-6">
|
||||||
<v-alert type="error" variant="tonal" closable>{{ store.error }}</v-alert>
|
<v-alert type="error" variant="tonal" closable>{{ store.error }}</v-alert>
|
||||||
</v-container>
|
</v-container>
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
:post-count="store.postCount"
|
:post-count="store.postCount"
|
||||||
:last-added="store.lastAdded"
|
:last-added="store.lastAdded"
|
||||||
/>
|
/>
|
||||||
<v-container fluid class="py-4">
|
<v-container fluid class="pt-2 pb-4">
|
||||||
<v-window v-model="tab">
|
<v-window v-model="tab">
|
||||||
<v-window-item value="posts">
|
<v-window-item value="posts">
|
||||||
<ArtistPostsTab
|
<ArtistPostsTab
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="py-6">
|
<v-container fluid class="pt-2 pb-6">
|
||||||
|
|
||||||
<div class="fc-artists__controls">
|
<div class="fc-artists__controls">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="py-6">
|
<v-container fluid class="pt-2 pb-6">
|
||||||
<ExtensionKeyBar />
|
<ExtensionKeyBar />
|
||||||
|
|
||||||
<v-alert v-if="credentialsStore.error" type="error" variant="tonal" closable class="mb-4">
|
<v-alert v-if="credentialsStore.error" type="error" variant="tonal" closable class="mb-4">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="py-6">
|
<v-container fluid class="pt-2 pb-6">
|
||||||
<FilterPills v-model="pill" />
|
<FilterPills v-model="pill" />
|
||||||
|
|
||||||
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
|
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="py-6">
|
<v-container fluid class="pt-2 pb-6">
|
||||||
<Teleport to="#fc-nav-actions">
|
<Teleport to="#fc-nav-actions">
|
||||||
<v-btn
|
<v-btn
|
||||||
:color="sel.isSelectMode ? 'accent' : undefined"
|
:color="sel.isSelectMode ? 'accent' : undefined"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container class="py-8">
|
<v-container class="pt-3 pb-8">
|
||||||
<h1 class="fc-h1 mb-4">{{ title }}</h1>
|
<h1 class="fc-h1 mb-4">{{ title }}</h1>
|
||||||
<v-alert type="info" variant="tonal" icon="mdi-toolbox">
|
<v-alert type="info" variant="tonal" icon="mdi-toolbox">
|
||||||
This surface is a placeholder. It will be implemented in
|
This surface is a placeholder. It will be implemented in
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container class="py-6" max-width="900">
|
<v-container class="pt-2 pb-6" max-width="900">
|
||||||
<PostsFilterBar
|
<PostsFilterBar
|
||||||
:artist-id="artistFilter"
|
:artist-id="artistFilter"
|
||||||
:platform="platformFilter"
|
:platform="platformFilter"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="py-6">
|
<v-container fluid class="pt-2 pb-6">
|
||||||
<div class="fc-series__head">
|
<div class="fc-series__head">
|
||||||
<span class="fc-series__name">{{ store.series?.name || 'Series' }}</span>
|
<span class="fc-series__name">{{ store.series?.name || 'Series' }}</span>
|
||||||
<span class="fc-series__count">{{ store.pages.length }} page(s)</span>
|
<span class="fc-series__count">{{ store.pages.length }} page(s)</span>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="py-6">
|
<v-container fluid class="pt-2 pb-6">
|
||||||
<!-- Sticky tabs: operator-flagged 2026-05-25 — long Import / Maintenance
|
<!-- Sticky tabs: operator-flagged 2026-05-25 — long Import / Maintenance
|
||||||
panels pushed the tab strip out of the viewport, forcing a scroll-
|
panels pushed the tab strip out of the viewport, forcing a scroll-
|
||||||
to-top just to change tab. AppShell's TopNav is 64px sticky, so the
|
to-top just to change tab. AppShell's TopNav is 64px sticky, so the
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="py-6">
|
<v-container fluid class="pt-2 pb-6">
|
||||||
<Teleport to="#fc-nav-actions">
|
<Teleport to="#fc-nav-actions">
|
||||||
<v-btn
|
<v-btn
|
||||||
prepend-icon="mdi-shuffle-variant" variant="tonal" color="accent"
|
prepend-icon="mdi-shuffle-variant" variant="tonal" color="accent"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="py-6">
|
<v-container fluid class="pt-2 pb-6">
|
||||||
<div class="fc-subs__bar">
|
<div class="fc-subs__bar">
|
||||||
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
|
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
|
||||||
Add subscription
|
Add subscription
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-container fluid class="py-6">
|
<v-container fluid class="pt-2 pb-6">
|
||||||
|
|
||||||
<div class="fc-tags__controls">
|
<div class="fc-tags__controls">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"""CORS preflight + response headers for moz-extension:// + chrome-extension://.
|
||||||
|
|
||||||
|
Operator-flagged 2026-05-26: extension's first 'Test connection' tap
|
||||||
|
returned `NetworkError` because /api/credentials had no OPTIONS handler
|
||||||
|
and no Access-Control-Allow-Origin response header. Browser preflight
|
||||||
|
failed → fetch blocked.
|
||||||
|
|
||||||
|
These tests pin the contract: any request from a moz-extension:// or
|
||||||
|
chrome-extension:// origin gets the right ACL headers. Plain browser
|
||||||
|
requests (no Origin header, or a regular https:// Origin) get nothing
|
||||||
|
— we don't want to open CORS up generally.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.app import create_app
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def client():
|
||||||
|
app = create_app()
|
||||||
|
async with app.test_client() as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extension_preflight_returns_204_with_acl_headers(client):
|
||||||
|
resp = await client.options(
|
||||||
|
"/api/credentials",
|
||||||
|
headers={
|
||||||
|
"Origin": "moz-extension://abcd1234-uuid-fake",
|
||||||
|
"Access-Control-Request-Method": "GET",
|
||||||
|
"Access-Control-Request-Headers": "X-Extension-Key",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
assert resp.headers["Access-Control-Allow-Origin"] == "moz-extension://abcd1234-uuid-fake"
|
||||||
|
assert "OPTIONS" in resp.headers["Access-Control-Allow-Methods"]
|
||||||
|
assert "X-Extension-Key" in resp.headers["Access-Control-Allow-Headers"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_extension_get_carries_acl_headers(client):
|
||||||
|
# The actual response (post-preflight) also needs ACL headers — the
|
||||||
|
# browser checks them again before exposing the response body.
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/credentials",
|
||||||
|
headers={"Origin": "moz-extension://abcd1234-uuid-fake"},
|
||||||
|
)
|
||||||
|
# 200 with empty list (no creds seeded) — what matters here is the
|
||||||
|
# CORS header is present.
|
||||||
|
assert resp.headers["Access-Control-Allow-Origin"] == "moz-extension://abcd1234-uuid-fake"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_chrome_extension_origin_also_allowed(client):
|
||||||
|
resp = await client.options(
|
||||||
|
"/api/credentials",
|
||||||
|
headers={
|
||||||
|
"Origin": "chrome-extension://abcd1234-uuid-fake",
|
||||||
|
"Access-Control-Request-Method": "POST",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 204
|
||||||
|
assert resp.headers["Access-Control-Allow-Origin"] == "chrome-extension://abcd1234-uuid-fake"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_normal_browser_request_gets_no_cors_headers(client):
|
||||||
|
# A regular browser tab (https://example.com / file:// / no Origin
|
||||||
|
# at all) should NOT get any Access-Control-Allow-* — the extension
|
||||||
|
# whitelist is intentionally narrow.
|
||||||
|
resp = await client.get(
|
||||||
|
"/api/credentials",
|
||||||
|
headers={"Origin": "https://evil.example.com"},
|
||||||
|
)
|
||||||
|
assert "Access-Control-Allow-Origin" not in resp.headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_origin_header_unaffected(client):
|
||||||
|
# Same-origin requests (no Origin header) — unaffected.
|
||||||
|
resp = await client.get("/api/credentials")
|
||||||
|
assert "Access-Control-Allow-Origin" not in resp.headers
|
||||||
Reference in New Issue
Block a user