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:
|
||||
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:
|
||||
# - write:package, read:package (for docker push to git.fabledsword.com)
|
||||
@@ -158,8 +168,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download signed XPI from Forgejo release asset (main only)
|
||||
if: github.ref == 'refs/heads/main'
|
||||
- name: Download signed XPI from Forgejo release asset (main + tags)
|
||||
# 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:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
@@ -200,7 +214,20 @@ jobs:
|
||||
- name: Determine tag
|
||||
id: tag
|
||||
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"
|
||||
else
|
||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
|
||||
@@ -231,7 +258,13 @@ jobs:
|
||||
- name: Determine tag
|
||||
id: tag
|
||||
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"
|
||||
else
|
||||
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT"
|
||||
|
||||
+15
-30
@@ -8,8 +8,10 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
# pull_request trigger intentionally absent — with branches: [dev, 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:
|
||||
backend-lint-and-test:
|
||||
@@ -24,13 +26,17 @@ jobs:
|
||||
steps:
|
||||
- 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-
|
||||
# Cache step removed 2026-05-26: act_runner's cache backend has been
|
||||
# broken on this homelab runner since 2026-05-15 (first as request-
|
||||
# timeout warnings, then as hard "Cannot find module .../dist/restore/
|
||||
# index.js" failures that tank the whole job). The cache step targeted
|
||||
# ~/.cache/pip but the install below uses `uv pip install` primarily,
|
||||
# whose own cache lives at ~/.cache/uv — so the cache step's real
|
||||
# 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
|
||||
# ruff is pre-installed in the ci-python image (see CI-Runner/CI-python/
|
||||
@@ -124,13 +130,6 @@ jobs:
|
||||
--health-retries 10
|
||||
steps:
|
||||
- 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)
|
||||
run: |
|
||||
set -eux
|
||||
@@ -189,13 +188,6 @@ jobs:
|
||||
--health-retries 10
|
||||
steps:
|
||||
- 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)
|
||||
run: |
|
||||
set -eux
|
||||
@@ -254,13 +246,6 @@ jobs:
|
||||
--health-retries 10
|
||||
steps:
|
||||
- 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)
|
||||
run: |
|
||||
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:
|
||||
1. Pick canonical — prefer a URL NOT matching '/posts/<id>$' (real
|
||||
campaign URL like /cw/Atole); else min(id).
|
||||
2. Reparent Posts and ImageProvenance off the other Sources onto canonical.
|
||||
3. Merge any Posts that collide on (canonical_source_id, external_post_id)
|
||||
by repointing ImageProvenance + ImageRecord.primary_post_id to the
|
||||
earliest Post, dedupe ImageProvenance, delete the loser Posts.
|
||||
4. Delete the orphan Source rows.
|
||||
5. If the canonical Source's URL still looks like a per-post URL (no
|
||||
2. PRE-merge any Posts under non-canonical sources whose
|
||||
external_post_id ALREADY exists under the canonical source. (Same
|
||||
gallery-dl post imported via two different sidecar paths can plant
|
||||
two Post rows with identical external_post_id under different
|
||||
Sources for the same artist.) Repoint ImageProvenance +
|
||||
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
|
||||
'sidecar:<platform>:<artist_slug>' so the artist detail page shows
|
||||
something readable.
|
||||
@@ -74,7 +83,84 @@ def upgrade() -> None:
|
||||
if not other_ids:
|
||||
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(
|
||||
text("""
|
||||
UPDATE post SET source_id = :canonical
|
||||
@@ -82,7 +168,9 @@ def upgrade() -> None:
|
||||
"""),
|
||||
{"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(
|
||||
text("""
|
||||
UPDATE image_provenance SET source_id = :canonical
|
||||
@@ -91,53 +179,7 @@ def upgrade() -> None:
|
||||
{"canonical": canonical_id, "others": other_ids},
|
||||
)
|
||||
|
||||
# Merge any Posts colliding on (canonical, external_post_id) after
|
||||
# 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.
|
||||
# STEP 5: Drop the orphan Sources.
|
||||
conn.execute(
|
||||
text("DELETE FROM source WHERE id = ANY(:others)"),
|
||||
{"others": other_ids},
|
||||
|
||||
+38
-1
@@ -3,13 +3,23 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from quart import Quart
|
||||
from quart import Quart, request
|
||||
|
||||
from .api import all_blueprints
|
||||
from .config import get_config
|
||||
from .frontend import frontend_bp
|
||||
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")
|
||||
|
||||
|
||||
@@ -35,6 +45,33 @@ def create_app() -> Quart:
|
||||
# Registered last so /api/* routes win over the SPA catch-all.
|
||||
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
|
||||
async def _dispose_db_engine() -> None:
|
||||
from .extensions import dispose_engine
|
||||
|
||||
@@ -25,6 +25,11 @@
|
||||
</v-tab>
|
||||
<v-tab value="management">Management</v-tab>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -54,11 +59,13 @@ const stats = computed(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Matches TopNav.vue's frosted recipe exactly — top:64px parks it under
|
||||
the 64px-tall TopNav with no visible seam. */
|
||||
/* Matches TopNav.vue's frosted recipe exactly. top:48px parks it flush
|
||||
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 {
|
||||
position: sticky;
|
||||
top: 64px;
|
||||
top: 48px;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -107,6 +114,11 @@ const stats = computed(() => {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.fc-artist-header__right {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fc-artist-header__tab-count {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
<v-progress-circular indeterminate color="accent" size="36" />
|
||||
</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-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-container>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
:post-count="store.postCount"
|
||||
: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-item value="posts">
|
||||
<ArtistPostsTab
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
|
||||
<div class="fc-artists__controls">
|
||||
<v-text-field
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<ExtensionKeyBar />
|
||||
|
||||
<v-alert v-if="credentialsStore.error" type="error" variant="tonal" closable class="mb-4">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<FilterPills v-model="pill" />
|
||||
|
||||
<v-alert v-if="store.error" type="error" variant="tonal" closable class="my-4">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<Teleport to="#fc-nav-actions">
|
||||
<v-btn
|
||||
:color="sel.isSelectMode ? 'accent' : undefined"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container class="py-8">
|
||||
<v-container class="pt-3 pb-8">
|
||||
<h1 class="fc-h1 mb-4">{{ title }}</h1>
|
||||
<v-alert type="info" variant="tonal" icon="mdi-toolbox">
|
||||
This surface is a placeholder. It will be implemented in
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container class="py-6" max-width="900">
|
||||
<v-container class="pt-2 pb-6" max-width="900">
|
||||
<PostsFilterBar
|
||||
:artist-id="artistFilter"
|
||||
:platform="platformFilter"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<div class="fc-series__head">
|
||||
<span class="fc-series__name">{{ store.series?.name || 'Series' }}</span>
|
||||
<span class="fc-series__count">{{ store.pages.length }} page(s)</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<Teleport to="#fc-nav-actions">
|
||||
<v-btn
|
||||
prepend-icon="mdi-shuffle-variant" variant="tonal" color="accent"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
<div class="fc-subs__bar">
|
||||
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
|
||||
Add subscription
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<v-container fluid class="pt-2 pb-6">
|
||||
|
||||
<div class="fc-tags__controls">
|
||||
<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