Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e82208926 | |||
| 85b640f32e | |||
| c7001f4aed | |||
| f827612930 | |||
| 3f0153cba5 | |||
| 52fff00353 | |||
| f3e8f30a8f | |||
| eee107766e | |||
| c14338cbce | |||
| 7a64730bd2 | |||
| 1803a09306 |
@@ -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
-66
@@ -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,22 +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
|
||||||
# continue-on-error: act_runner's cache backend has been broken on
|
# broken on this homelab runner since 2026-05-15 (first as request-
|
||||||
# this homelab setup since 2026-05-15 — bolt.db + cache/ dir exist
|
# timeout warnings, then as hard "Cannot find module .../dist/restore/
|
||||||
# but the action's JS bundle now fails to load at all
|
# index.js" failures that tank the whole job). The cache step targeted
|
||||||
# ("Cannot find module .../dist/restore/index.js"), hard-failing
|
# ~/.cache/pip but the install below uses `uv pip install` primarily,
|
||||||
# the whole job. The install step that follows handles cold caches
|
# whose own cache lives at ~/.cache/uv — so the cache step's real
|
||||||
# natively (uv pip / pip install both work without it), so net
|
# benefit was marginal even when working. Cost of removal: ~30s of
|
||||||
# cost of disabling the cache here is ~30s of wheel downloads per
|
# wheel downloads per job. Future re-enable: mount ~/.cache/uv as a
|
||||||
# job. Re-enable once the runner-side cache backend is fixed.
|
# docker volume at the runner level (skips actions/cache entirely),
|
||||||
continue-on-error: true
|
# or fix the runner-side cache backend (clear /var/run/act/actions/*,
|
||||||
uses: actions/cache@v4
|
# pin act_runner version, etc.).
|
||||||
with:
|
|
||||||
path: ~/.cache/pip
|
|
||||||
key: pip-${{ runner.os }}-py314-${{ hashFiles('requirements.txt') }}
|
|
||||||
restore-keys: |
|
|
||||||
pip-${{ runner.os }}-py314-
|
|
||||||
|
|
||||||
- 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/
|
||||||
@@ -133,22 +130,6 @@ jobs:
|
|||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Cache pip wheels
|
|
||||||
# continue-on-error: act_runner's cache backend has been broken on
|
|
||||||
# this homelab setup since 2026-05-15 — bolt.db + cache/ dir exist
|
|
||||||
# but the action's JS bundle now fails to load at all
|
|
||||||
# ("Cannot find module .../dist/restore/index.js"), hard-failing
|
|
||||||
# the whole job. The install step that follows handles cold caches
|
|
||||||
# natively (uv pip / pip install both work without it), so net
|
|
||||||
# cost of disabling the cache here is ~30s of wheel downloads per
|
|
||||||
# job. Re-enable once the runner-side cache backend is fixed.
|
|
||||||
continue-on-error: true
|
|
||||||
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
|
||||||
@@ -207,22 +188,6 @@ jobs:
|
|||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Cache pip wheels
|
|
||||||
# continue-on-error: act_runner's cache backend has been broken on
|
|
||||||
# this homelab setup since 2026-05-15 — bolt.db + cache/ dir exist
|
|
||||||
# but the action's JS bundle now fails to load at all
|
|
||||||
# ("Cannot find module .../dist/restore/index.js"), hard-failing
|
|
||||||
# the whole job. The install step that follows handles cold caches
|
|
||||||
# natively (uv pip / pip install both work without it), so net
|
|
||||||
# cost of disabling the cache here is ~30s of wheel downloads per
|
|
||||||
# job. Re-enable once the runner-side cache backend is fixed.
|
|
||||||
continue-on-error: true
|
|
||||||
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
|
||||||
@@ -281,22 +246,6 @@ jobs:
|
|||||||
--health-retries 10
|
--health-retries 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Cache pip wheels
|
|
||||||
# continue-on-error: act_runner's cache backend has been broken on
|
|
||||||
# this homelab setup since 2026-05-15 — bolt.db + cache/ dir exist
|
|
||||||
# but the action's JS bundle now fails to load at all
|
|
||||||
# ("Cannot find module .../dist/restore/index.js"), hard-failing
|
|
||||||
# the whole job. The install step that follows handles cold caches
|
|
||||||
# natively (uv pip / pip install both work without it), so net
|
|
||||||
# cost of disabling the cache here is ~30s of wheel downloads per
|
|
||||||
# job. Re-enable once the runner-side cache backend is fixed.
|
|
||||||
continue-on-error: true
|
|
||||||
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
|
||||||
|
|||||||
@@ -83,55 +83,80 @@ def upgrade() -> None:
|
|||||||
if not other_ids:
|
if not other_ids:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# STEP 2: PRE-merge colliding Posts BEFORE the bulk reparent.
|
# STEP 2: PRE-merge ALL Posts with duplicate external_post_id
|
||||||
# Find pairs (keep, drop) where:
|
# across the entire (canonical + others) group, BEFORE the bulk
|
||||||
# keep = a Post under the canonical source
|
# reparent. Two cases must both be handled:
|
||||||
# drop = a Post under one of the non-canonical sources whose
|
# (A) canonical has Post X with epid=N; an "other" source has
|
||||||
# external_post_id equals keep.external_post_id
|
# Post Y with epid=N → after bulk UPDATE, (canonical, N)
|
||||||
# If we let the bulk UPDATE try to repoint drop onto canonical,
|
# collides with itself.
|
||||||
# uq_post_source_external_id fires row-by-row and aborts the
|
# (B) two different "other" sources each have a Post with
|
||||||
# whole migration.
|
# epid=N; canonical has none → after bulk UPDATE, both
|
||||||
colliding = conn.execute(
|
# 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("""
|
text("""
|
||||||
SELECT keep.id AS keep_id, drop_.id AS drop_id
|
SELECT external_post_id, id, source_id
|
||||||
FROM post AS keep
|
FROM post
|
||||||
JOIN post AS drop_
|
WHERE source_id = :canonical OR source_id = ANY(:others)
|
||||||
ON drop_.external_post_id = keep.external_post_id
|
ORDER BY external_post_id, id
|
||||||
AND drop_.id != keep.id
|
|
||||||
WHERE keep.source_id = :canonical
|
|
||||||
AND drop_.source_id = ANY(:others)
|
|
||||||
"""),
|
"""),
|
||||||
{"canonical": canonical_id, "others": other_ids},
|
{"canonical": canonical_id, "others": other_ids},
|
||||||
).fetchall()
|
).fetchall()
|
||||||
for keep_id, drop_id in colliding:
|
by_epid: dict = {}
|
||||||
conn.execute(
|
for epid, post_id, src_id in all_posts:
|
||||||
text("""
|
by_epid.setdefault(epid, []).append((post_id, src_id))
|
||||||
UPDATE image_provenance SET post_id = :keep
|
for _epid, posts in by_epid.items():
|
||||||
WHERE post_id = :drop_
|
if len(posts) <= 1:
|
||||||
"""),
|
continue
|
||||||
{"keep": keep_id, "drop_": drop_id},
|
# Prefer a Post already under canonical as the keep.
|
||||||
)
|
canonical_posts = [p for p in posts if p[1] == canonical_id]
|
||||||
conn.execute(
|
if canonical_posts:
|
||||||
text("""
|
keep_id = canonical_posts[0][0]
|
||||||
UPDATE image_record SET primary_post_id = :keep
|
else:
|
||||||
WHERE primary_post_id = :drop_
|
keep_id = posts[0][0] # already sorted by id ASC
|
||||||
"""),
|
drop_ids = [p[0] for p in posts if p[0] != keep_id]
|
||||||
{"keep": keep_id, "drop_": drop_id},
|
for drop_id in drop_ids:
|
||||||
)
|
# Pre-delete image_provenance rows under drop_ whose
|
||||||
# Repointed provenance may now collide on
|
# image_record_id ALREADY has a provenance under keep —
|
||||||
# uq_image_provenance_image_post (alembic 0021). Dedupe:
|
# the UPDATE below would otherwise repoint them and
|
||||||
# keep min(id) per (image_record_id, post_id).
|
# trip uq_image_provenance_image_post (alembic 0021)
|
||||||
conn.execute(text("""
|
# row-by-row before any after-the-fact dedupe could
|
||||||
DELETE FROM image_provenance ip1
|
# run. Operator's v26.05.26.3 deploy 2026-05-26 tripped
|
||||||
USING image_provenance ip2
|
# this at line 123.
|
||||||
WHERE ip1.image_record_id = ip2.image_record_id
|
conn.execute(
|
||||||
AND ip1.post_id = ip2.post_id
|
text("""
|
||||||
AND ip1.id > ip2.id
|
DELETE FROM image_provenance
|
||||||
"""))
|
WHERE post_id = :drop_
|
||||||
conn.execute(
|
AND image_record_id IN (
|
||||||
text("DELETE FROM post WHERE id = :drop_"),
|
SELECT image_record_id FROM image_provenance
|
||||||
{"drop_": drop_id},
|
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
|
# STEP 3: Bulk reparent the remaining Posts off the other
|
||||||
# Sources. After step 2, no collisions on
|
# Sources. After step 2, no collisions on
|
||||||
|
|||||||
+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