diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 2c4ea30..bf0bd36 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -202,6 +202,31 @@ jobs: SENTRY_ORG: libnovel SENTRY_PROJECT: ui + - name: Prune old GlitchTip releases (keep latest 10) + run: | + set -euo pipefail + KEEP=10 + # Fetch releases sorted newest-first, extract versions after the first $KEEP + OLD=$(curl -sf \ + -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \ + "$SENTRY_URL/api/0/organizations/$SENTRY_ORG/releases/?project=$SENTRY_PROJECT&per_page=100" \ + | python3 -c " + import sys, json + releases = json.load(sys.stdin) + # Skip beyond the first KEEP entries + for r in releases[$KEEP:]: + print(r['version']) + " KEEP=$KEEP) + for ver in $OLD; do + echo "Deleting old release: $ver" + glitchtip-cli releases delete "$ver" || true + done + env: + SENTRY_URL: https://errors.libnovel.cc + SENTRY_AUTH_TOKEN: ${{ secrets.GLITCHTIP_AUTH_TOKEN }} + SENTRY_ORG: libnovel + SENTRY_PROJECT: ui + # ── docker: ui ──────────────────────────────────────────────────────────────── docker-ui: name: Docker / ui diff --git a/homelab/docker-compose.yml b/homelab/docker-compose.yml index 16898aa..63d439c 100644 --- a/homelab/docker-compose.yml +++ b/homelab/docker-compose.yml @@ -172,6 +172,8 @@ services: MEDIA_ROOT: "/code/uploads" volumes: - glitchtip_uploads:/code/uploads + # Patch: GzipChunk fallback for sentry-cli 3.x raw zip uploads (GlitchTip bug) + - ./glitchtip/files_api.py:/code/apps/files/api.py:ro healthcheck: test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/0/')"] interval: 15s @@ -196,6 +198,8 @@ services: MEDIA_ROOT: "/code/uploads" volumes: - glitchtip_uploads:/code/uploads + # Patch: GzipChunk fallback for sentry-cli 3.x raw zip uploads (GlitchTip bug) + - ./glitchtip/files_api.py:/code/apps/files/api.py:ro # ── Umami ─────────────────────────────────────────────────────────────────── umami: diff --git a/homelab/glitchtip/files_api.py b/homelab/glitchtip/files_api.py new file mode 100644 index 0000000..0a3672a --- /dev/null +++ b/homelab/glitchtip/files_api.py @@ -0,0 +1,127 @@ +"""Port of sentry.api.endpoints.chunk.ChunkUploadEndpoint""" + +import logging +from gzip import GzipFile +from io import BytesIO + +from django.conf import settings +from django.shortcuts import aget_object_or_404 +from django.urls import reverse +from ninja import File, Router +from ninja.errors import HttpError +from ninja.files import UploadedFile + +from apps.organizations_ext.models import Organization +from glitchtip.api.authentication import AuthHttpRequest +from glitchtip.api.decorators import optional_slash +from glitchtip.api.permissions import has_permission + +from .models import FileBlob + +# Force just one blob +CHUNK_UPLOAD_BLOB_SIZE = 32 * 1024 * 1024 # 32MB +MAX_CHUNKS_PER_REQUEST = 1 +MAX_REQUEST_SIZE = CHUNK_UPLOAD_BLOB_SIZE +MAX_CONCURRENCY = 1 +HASH_ALGORITHM = "sha1" + +CHUNK_UPLOAD_ACCEPT = ( + "debug_files", # DIF assemble + "release_files", # Release files assemble + "pdbs", # PDB upload and debug id override + "sources", # Source artifact bundle upload + "artifact_bundles", # Artifact bundles contain debug ids to link source to sourcemaps + "proguard", +) + + +class GzipChunk(BytesIO): + def __init__(self, file): + raw = file.read() + try: + data = GzipFile(fileobj=BytesIO(raw), mode="rb").read() + except Exception: + # sentry-cli 3.x sends raw (uncompressed) zip data despite gzip being + # advertised by the server — fall back to using the raw bytes as-is. + data = raw + self.size = len(data) + self.name = file.name + super().__init__(data) + + +router = Router() + + +@optional_slash(router, "get", "organizations/{slug:organization_slug}/chunk-upload/") +async def get_chunk_upload_info(request: AuthHttpRequest, organization_slug: str): + """Get server settings for chunk file upload""" + path = reverse("api:get_chunk_upload_info", args=[organization_slug]) + url = ( + path + if settings.GLITCHTIP_CHUNK_UPLOAD_USE_RELATIVE_URL + else settings.GLITCHTIP_URL.geturl() + path + ) + return { + "url": url, + "chunkSize": CHUNK_UPLOAD_BLOB_SIZE, + "chunksPerRequest": MAX_CHUNKS_PER_REQUEST, + "maxFileSize": 2147483648, + "maxRequestSize": MAX_REQUEST_SIZE, + "concurrency": MAX_CONCURRENCY, + "hashAlgorithm": HASH_ALGORITHM, + "compression": ["gzip"], + "accept": CHUNK_UPLOAD_ACCEPT, + } + + +@optional_slash(router, "post", "organizations/{slug:organization_slug}/chunk-upload/") +@has_permission(["project:write", "project:admin", "project:releases"]) +async def chunk_upload( + request: AuthHttpRequest, + organization_slug: str, + file_gzip: list[UploadedFile] = File(...), +): + """Upload one more more gzipped files to save""" + logger = logging.getLogger("glitchtip.files") + logger.info("chunkupload.start") + + organization = await aget_object_or_404( + Organization, slug=organization_slug.lower(), users=request.auth.user_id + ) + + files = [GzipChunk(chunk) for chunk in file_gzip] + + if len(files) == 0: + # No files uploaded is ok + logger.info("chunkupload.end", extra={"status": 200}) + return + + logger.info("chunkupload.post.files", extra={"len": len(files)}) + + # Validate file size + checksums = [] + size = 0 + for chunk in files: + size += chunk.size + if chunk.size > CHUNK_UPLOAD_BLOB_SIZE: + logger.info("chunkupload.end", extra={"status": 400}) + raise HttpError(400, "Chunk size too large") + checksums.append(chunk.name) + + if size > MAX_REQUEST_SIZE: + logger.info("chunkupload.end", extra={"status": 400}) + raise HttpError(400, "Request too large") + + if len(files) > MAX_CHUNKS_PER_REQUEST: + logger.info("chunkupload.end", extra={"status": 400}) + raise HttpError(400, "Too many chunks") + + try: + await FileBlob.from_files( + zip(files, checksums), organization=organization, logger=logger + ) + except IOError as err: + logger.info("chunkupload.end", extra={"status": 400}) + raise HttpError(400, str(err)) from err + + logger.info("chunkupload.end", extra={"status": 200}) diff --git a/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte b/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte index 0dbbd02..df4441e 100644 --- a/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte +++ b/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte @@ -22,14 +22,6 @@ let settingsPanelOpen = $state(false); let settingsTab = $state<'reading' | 'listening'>('reading'); - const READER_THEMES = [ - { id: 'amber', label: 'Amber', swatch: '#f59e0b' }, - { id: 'slate', label: 'Slate', swatch: '#818cf8' }, - { id: 'rose', label: 'Rose', swatch: '#fb7185' }, - { id: 'light', label: 'Light', swatch: '#d97706', light: true }, - { id: 'light-slate', label: 'L·Slate',swatch: '#4f46e5', light: true }, - { id: 'light-rose', label: 'L·Rose', swatch: '#e11d48', light: true }, - ] as const; const READER_FONTS = [ { id: 'system', label: 'System' }, { id: 'serif', label: 'Serif' }, @@ -43,14 +35,9 @@ ] as const; // Mirror context values into local reactive state so the panel shows current values - let panelTheme = $state(settingsCtx?.current ?? 'amber'); let panelFont = $state(settingsCtx?.fontFamily ?? 'system'); let panelSize = $state(settingsCtx?.fontSize ?? 1.0); - function applyTheme(id: string) { - panelTheme = id; - if (settingsCtx) settingsCtx.current = id; - } function applyFont(id: string) { panelFont = id; if (settingsCtx) settingsCtx.fontFamily = id; @@ -97,34 +84,6 @@ if (browser) localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout)); } - // ── Listening settings helpers ─────────────────────────────────────────────── - const SETTINGS_SLEEP_OPTIONS = [15, 30, 45, 60]; - const sleepSettingsLabel = $derived( - audioStore.sleepAfterChapter - ? 'End Ch.' - : audioStore.sleepUntil > Date.now() - ? `${Math.ceil((audioStore.sleepUntil - Date.now()) / 60000)}m` - : 'Off' - ); - - function toggleSleepFromSettings() { - if (!audioStore.sleepUntil && !audioStore.sleepAfterChapter) { - audioStore.sleepAfterChapter = true; - } else if (audioStore.sleepAfterChapter) { - audioStore.sleepAfterChapter = false; - audioStore.sleepUntil = Date.now() + SETTINGS_SLEEP_OPTIONS[0] * 60 * 1000; - } else { - const remaining = audioStore.sleepUntil - Date.now(); - const currentMin = Math.round(remaining / 60000); - const idx = SETTINGS_SLEEP_OPTIONS.findIndex((m) => m >= currentMin); - if (idx === -1 || idx === SETTINGS_SLEEP_OPTIONS.length - 1) { - audioStore.sleepUntil = 0; - } else { - audioStore.sleepUntil = Date.now() + SETTINGS_SLEEP_OPTIONS[idx + 1] * 60 * 1000; - } - } - } - // Apply reading CSS vars whenever layout changes $effect(() => { if (!browser) return; @@ -338,7 +297,8 @@
{/if} - + +{#if !layout.focusMode} +{/if} {#if !data.isPreview && !layout.focusMode} @@ -562,8 +523,8 @@ {/if} - -{#if !(layout.focusMode && layout.readMode === 'paginated')} + +{#if !layout.focusMode}Typography