From 49b29e617f33e93318934b0ed39913c2f19bb02c Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Tue, 22 Sep 2026 14:37:47 -0600 Subject: [PATCH 01/10] feat: reject uploads of videos already in the library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every upload is hashed once it lands on disk. When that hash matches a video the library already holds, the new copy is removed and the request answers 409 with the existing video's id, title, and watch URL instead of creating a second entry for the same content. A row whose own file is missing from disk does not count as a duplicate: that upload is a restore, and scan-video flips the row back to available. Nor does the row whose path is the file just written, which is the same restore landing exactly where the missing original used to live. Applies to all four video upload routes — public and authenticated, single-shot and chunked. The upload card tracks a 'duplicate' status alongside done and error, shows it in amber as "Already exists", and the batch summary names how many were already in the library with links to open them. That alert stays up for ten seconds rather than the usual three and a half, since a message with links in it needs long enough to click; SnackbarAlert now takes autoHideDuration to allow it. --- app/client/src/components/cards/UploadCard.js | 99 +++++++++++++++---- app/client/src/components/nav/MainNavbar.js | 7 +- app/server/fireshare/api/upload.py | 54 ++++++++++ 3 files changed, 142 insertions(+), 18 deletions(-) diff --git a/app/client/src/components/cards/UploadCard.js b/app/client/src/components/cards/UploadCard.js index dff7d09e..cbed9acd 100644 --- a/app/client/src/components/cards/UploadCard.js +++ b/app/client/src/components/cards/UploadCard.js @@ -17,6 +17,7 @@ import { InputAdornment, Checkbox, FormControlLabel, + Link, } from '@mui/material' import CloudUploadIcon from '@mui/icons-material/CloudUpload' import styled from '@emotion/styled' @@ -24,6 +25,55 @@ import { keyframes } from '@emotion/react' import { VideoService, GameService, TagService } from '../../services' import { getSetting } from '../../common/utils' +const FINISHED_STATUSES = new Set(['done', 'error', 'duplicate']) +const isFinished = (item) => FINISHED_STATUSES.has(item.status) + +function summarizeBatch(succeeded, duplicates, failed, total) { + if (duplicates.length === 0 && failed === 0) { + return succeeded === 1 + ? 'Your upload will be available in a few seconds.' + : `${succeeded} uploads will be available in a few seconds.` + } + if (succeeded === 0 && failed === 0) { + return duplicates.length === 1 + ? `${duplicates[0].file.name} is already in the library.` + : `All ${duplicates.length} of those videos are already in the library.` + } + const parts = [] + if (duplicates.length > 0) parts.push(`${duplicates.length} already in the library`) + if (failed > 0) parts.push(`${failed} failed`) + return `${succeeded} of ${total} uploads succeeded — ${parts.join(', ')}.` +} + +// Links to the existing copies of duplicate uploads. They open in a new tab so +// the page the uploader is sitting on is left alone. +function renderDuplicateLinks(items) { + const link = (item, label) => ( + + {label} + + ) + if (items.length === 1) return link(items[0], 'Open the existing video') + return ( + <> + Open the existing videos:{' '} + {items.map((item, idx) => ( + + {idx > 0 && ', '} + {link(item, item.duplicate.title || item.file.name)} + + ))} + + ) +} + function checkUploadLimit(file, handleAlert) { const limitMb = getSetting('upload_limit_mb') || 0 if (limitMb > 0 && file.size > limitMb * 1024 * 1024) { @@ -473,6 +523,13 @@ const UploadCard = React.forwardRef(function UploadCard( updateQueueItem(item.id, { status: 'done', progress: 1 }) if (onUploadComplete) onUploadComplete() } catch (err) { + // The server hashes every upload and answers 409 when that content is + // already in the library. It has removed its copy; nothing more to do. + const duplicate = err?.response?.status === 409 ? err.response.data : null + if (duplicate?.video_id) { + updateQueueItem(item.id, { status: 'duplicate', progress: 1, duplicate }) + return + } updateQueueItem(item.id, { status: 'error' }) handleAlert({ type: 'error', @@ -500,19 +557,21 @@ const UploadCard = React.forwardRef(function UploadCard( } // Once every upload has finished, show a single summary alert and reset the card - if (uploadQueue.every((i) => i.status === 'done' || i.status === 'error')) { + if (uploadQueue.every(isFinished)) { const succeeded = uploadQueue.filter((i) => i.status === 'done').length - const failed = uploadQueue.length - succeeded - if (succeeded > 0) { + const duplicates = uploadQueue.filter((i) => i.status === 'duplicate') + const failed = uploadQueue.length - succeeded - duplicates.length + if (succeeded > 0 || duplicates.length > 0) { handleAlert({ - type: failed > 0 ? 'warning' : 'success', - message: - failed > 0 - ? `${succeeded} of ${uploadQueue.length} uploads succeeded — ${failed} failed.` - : succeeded === 1 - ? 'Your upload will be available in a few seconds.' - : `${succeeded} uploads will be available in a few seconds.`, - autohideDuration: 3500, + type: failed > 0 ? 'warning' : succeeded > 0 ? 'success' : 'info', + message: ( + <> + {summarizeBatch(succeeded, duplicates, failed, uploadQueue.length)} + {duplicates.length > 0 && <> {renderDuplicateLinks(duplicates)}} + + ), + // A message with a link in it needs to stay up long enough to click. + autoHideDuration: duplicates.length > 0 ? 10000 : 3500, open: true, }) } @@ -539,7 +598,7 @@ const UploadCard = React.forwardRef(function UploadCard( 0, ) const aggregateProgress = totalQueueBytes > 0 ? Math.min(loadedQueueBytes / totalQueueBytes, 1) : 0 - const finishedCount = uploadQueue.filter((i) => i.status === 'done' || i.status === 'error').length + const finishedCount = uploadQueue.filter(isFinished).length const allSent = isUploading && uploadQueue.every((i) => i.status !== 'queued' && i.status !== 'uploading') const singleUpload = uploadQueue.length === 1 ? uploadQueue[0] : null @@ -1179,9 +1238,11 @@ const UploadCard = React.forwardRef(function UploadCard( color: item.status === 'error' ? '#FF6B6B' - : item.status === 'done' - ? '#6BFF95' - : '#FFFFFFCC', + : item.status === 'duplicate' + ? '#FFD166' + : item.status === 'done' + ? '#6BFF95' + : '#FFFFFFCC', }} > {item.status === 'queued' @@ -1192,7 +1253,9 @@ const UploadCard = React.forwardRef(function UploadCard( ? 'Done' : item.status === 'error' ? 'Failed' - : `${(100 * item.progress).toFixed(0)}%`} + : item.status === 'duplicate' + ? 'Already exists' + : `${(100 * item.progress).toFixed(0)}%`} diff --git a/app/client/src/components/nav/MainNavbar.js b/app/client/src/components/nav/MainNavbar.js index 6c4409c9..da1f46c1 100644 --- a/app/client/src/components/nav/MainNavbar.js +++ b/app/client/src/components/nav/MainNavbar.js @@ -841,7 +841,12 @@ function MainNavbar({ > {showDemoBanner && } {toolbar && page !== '/watch' && showTopBar && } - setAlert({ ...alert, open })}> + setAlert({ ...alert, open })} + > {alert.message} {React.cloneElement(children, { diff --git a/app/server/fireshare/api/upload.py b/app/server/fireshare/api/upload.py index 43f7437d..9e33f44d 100644 --- a/app/server/fireshare/api/upload.py +++ b/app/server/fireshare/api/upload.py @@ -5,6 +5,7 @@ import re import string import threading +from pathlib import Path from subprocess import Popen from flask import current_app, jsonify, request, Response @@ -12,6 +13,7 @@ from .. import logger, util from ..constants import SUPPORTED_FILE_TYPES +from ..models import Video from .. import permissions as P from . import api from .decorators import require_perm @@ -48,6 +50,46 @@ def _current_uploader_id(): return current_user.id if current_user.is_authenticated else None +def _reject_duplicate(save_path): + """ + If the file just written to save_path has the same content hash as a video + already in the library, delete it and return a 409 response pointing at the + existing video. Returns None when the upload is genuinely new. + + A row whose own file is missing from disk does not count: that upload is a + restore, and scan-video flips the row back to available. Nor does the row + whose path *is* save_path, which is the same restore landing exactly where + the missing original used to live. + """ + paths = current_app.config['PATHS'] + try: + video_id = util.video_id(Path(save_path)) + except OSError as e: + logger.warning(f"Could not hash upload {save_path} for duplicate check: {e}") + return None + existing = Video.query.filter_by(video_id=video_id).first() + if not existing: + return None + existing_file = paths['video'] / existing.path + if not existing_file.is_file() or existing_file.resolve() == Path(save_path).resolve(): + return None + try: + os.remove(save_path) + except OSError as e: + logger.warning(f"Could not remove duplicate upload {save_path}: {e}") + title = existing.info.title if existing.info and existing.info.title else Path(existing.path).stem + logger.info(f"Rejected duplicate upload {save_path}: identical to video {video_id} at {existing.path}") + resp = jsonify({ + 'error': 'duplicate', + 'message': 'This video is already in the library.', + 'video_id': video_id, + 'title': title, + 'url': f'/w/{video_id}', + }) + resp.status_code = 409 + return resp + + def _launch_scan_video(save_path, config, tag_ids=None, game_id=None, title=None, uploaded_by=None): """ Launch scan-video and publish an initial transcoding-running status when @@ -137,6 +179,9 @@ def public_upload_video(): uid = ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(6)) save_path = os.path.join(paths['video'], upload_folder, f"{name_no_type}-{uid}.{filetype}") file.save(save_path) + duplicate = _reject_duplicate(save_path) + if duplicate: + return duplicate _launch_scan_video(save_path, config, *_parse_upload_metadata(), uploaded_by=_current_uploader_id()) return Response(status=201) @@ -235,6 +280,9 @@ def public_upload_videoChunked(): os.remove(save_path) return Response(status=500, response="Error reassembling file") + duplicate = _reject_duplicate(save_path) + if duplicate: + return duplicate _launch_scan_video(save_path, config, *_parse_upload_metadata(), uploaded_by=_current_uploader_id()) return Response(status=201) @@ -335,6 +383,9 @@ def upload_video(): uid = ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(6)) save_path = os.path.join(paths['video'], upload_folder, f"{name_no_type}-{uid}.{filetype}") file.save(save_path) + duplicate = _reject_duplicate(save_path) + if duplicate: + return duplicate _launch_scan_video(save_path, config, *_parse_upload_metadata(), uploaded_by=_current_uploader_id()) return Response(status=201) @@ -439,6 +490,9 @@ def upload_videoChunked(): os.remove(save_path) return Response(status=500, response="Error reassembling file") + duplicate = _reject_duplicate(save_path) + if duplicate: + return duplicate _launch_scan_video(save_path, config, *_parse_upload_metadata(), uploaded_by=_current_uploader_id()) return Response(status=201) From 76a6c04cc5df463681b8ba6c87d88b1d5e7271ea Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Tue, 22 Sep 2026 14:38:21 -0600 Subject: [PATCH 02/10] feat: upload tokens for scripts and other tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a token-authenticated upload route so an external tool can push videos and images into Fireshare without a password or a browser session, and gives every account that may upload a place to mint, rename, regenerate and delete its own tokens. POST /api/upload/token takes one video or image per request, deciding which by extension, and accepts the same title, folder, game and tag options the browser routes take. It reuses the helpers behind /api/upload and /api/upload/image rather than reimplementing them, so filename sanitising, the extension allowlist, folder containment, the demo size cap, duplicate rejection and uploader attribution are the same code paths the UI already exercises. A `game` name is accepted alongside `game_id`, resolved against existing games only, since a machine rarely knows a row id. GET on the same route validates a token and reports the owner and accepted types, so a tool can check its configuration without uploading anything. A token carries no permissions of its own. Every request re-reads the owner's account, so revoking upload, disabling the account, or deleting it stops the token on the next call. Only the sha256 of the secret is stored — the raw value exists in exactly one response, the one that mints it — and the credential is read from a header only, never a query string, which would land in proxy access logs and shell history. An unknown token, a revoked permission and a disabled account are all reported identically. Repeated bad tokens from one address are throttled, deliberately in a separate bucket from login_throttle so a tool looping on a stale token cannot lock people out of signing in. Tokens never expire; they end when deleted or regenerated. Deleting a user deletes their tokens with them. The management UI lives in Settings > Security, shown only to accounts holding the upload permission, and reveals each new secret once with a copy button and a ready-to-paste curl example. --- README.md | 1 + .../src/components/settings/UploadTokens.js | 375 +++++++++++++ app/client/src/services/UserService.js | 17 + app/client/src/views/Settings.js | 9 + app/server/fireshare/api/__init__.py | 2 +- app/server/fireshare/api/upload_tokens.py | 497 ++++++++++++++++++ app/server/fireshare/models.py | 43 ++ docs/UploadTokens.md | 129 +++++ .../s4n5o6p7q8r9_add_upload_token_table.py | 51 ++ 9 files changed, 1123 insertions(+), 1 deletion(-) create mode 100644 app/client/src/components/settings/UploadTokens.js create mode 100644 app/server/fireshare/api/upload_tokens.py create mode 100644 docs/UploadTokens.md create mode 100644 migrations/versions/s4n5o6p7q8r9_add_upload_token_table.py diff --git a/README.md b/README.md index 26cba793..a1fbbc60 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ If Fireshare is useful to you, [GitHub Sponsors](https://github.com/sponsors/Sha - [Shareable user profiles for uploads](./docs/Users.md#profiles) - [Two-factor authentication (TOTP authenticator apps)](./docs/Security.md#two-factor-authentication-mfa) - [Login IP whitelisting](./docs/Security.md#login-ip-whitelist) +- [Upload tokens for scripts and other tools](./docs/UploadTokens.md) ## Supported Video Formats diff --git a/app/client/src/components/settings/UploadTokens.js b/app/client/src/components/settings/UploadTokens.js new file mode 100644 index 00000000..b680726c --- /dev/null +++ b/app/client/src/components/settings/UploadTokens.js @@ -0,0 +1,375 @@ +import React from 'react' +import { + Box, + Button, + Chip, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material' +import TerminalIcon from '@mui/icons-material/Terminal' +import ContentCopyIcon from '@mui/icons-material/ContentCopy' +import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline' +import AutorenewIcon from '@mui/icons-material/Autorenew' +import AddIcon from '@mui/icons-material/Add' +import { UserService } from '../../services' +import SnackbarAlert from '../alert/SnackbarAlert' +import { dialogPaperSx, dialogTitleSx, inputSx, helperTextSx, rowBoxSx } from '../../common/modalStyles' + +const NAME_MAX = 64 +const DOCS_URL = 'https://github.com/fireshare-app/fireshare/blob/main/docs/UploadTokens.md' + +// Matches the external links in the Settings panes. +const docsLinkStyle = { color: '#2684FF', textDecoration: 'none' } + +// The API serialises these columns as naive UTC, so the zone has to be supplied +// here — without it the browser would read the timestamp as local time. +const formatDate = (value) => { + if (!value) return null + const parsed = new Date(`${value}Z`) + if (Number.isNaN(parsed.getTime())) return null + return parsed.toLocaleString() +} + +const errorMessage = (err, fallback) => err.response?.data?.error || fallback + +const UploadTokens = () => { + const [tokens, setTokens] = React.useState(null) + const [maxTokens, setMaxTokens] = React.useState(20) + const [createOpen, setCreateOpen] = React.useState(false) + const [name, setName] = React.useState('') + const [busy, setBusy] = React.useState(false) + const [alert, setAlert] = React.useState({ open: false }) + // The one moment the raw token exists on the client. Held only in component + // state so it is gone the moment the dialog closes or the page is left. + const [revealed, setRevealed] = React.useState(null) + const [confirmAction, setConfirmAction] = React.useState(null) + + const load = React.useCallback(async () => { + try { + const { data } = await UserService.listUploadTokens() + setTokens(data.tokens || []) + if (data.max_tokens) setMaxTokens(data.max_tokens) + } catch (err) { + setTokens([]) + setAlert({ open: true, type: 'error', message: 'Failed to load upload tokens.' }) + } + }, []) + + React.useEffect(() => { + load() + }, [load]) + + const copy = async (value) => { + try { + await navigator.clipboard.writeText(value) + setAlert({ open: true, type: 'success', message: 'Token copied to the clipboard.' }) + } catch { + setAlert({ open: true, type: 'error', message: 'Could not copy — select the token and copy it manually.' }) + } + } + + const handleCreate = async () => { + setBusy(true) + try { + const { data } = await UserService.createUploadToken(name.trim() || 'Upload token') + setCreateOpen(false) + setName('') + setRevealed({ ...data.token, isNew: true }) + await load() + } catch (err) { + setAlert({ open: true, type: 'error', message: errorMessage(err, 'Could not create the token.') }) + } + setBusy(false) + } + + const handleRegenerate = async (token) => { + setBusy(true) + try { + const { data } = await UserService.regenerateUploadToken(token.id) + setConfirmAction(null) + setRevealed({ ...data.token, isNew: false }) + await load() + } catch (err) { + setAlert({ open: true, type: 'error', message: errorMessage(err, 'Could not regenerate the token.') }) + } + setBusy(false) + } + + const handleDelete = async (token) => { + setBusy(true) + try { + await UserService.deleteUploadToken(token.id) + setConfirmAction(null) + setAlert({ open: true, type: 'success', message: `"${token.name}" was deleted.` }) + await load() + } catch (err) { + setAlert({ open: true, type: 'error', message: errorMessage(err, 'Could not delete the token.') }) + } + setBusy(false) + } + + const atLimit = tokens !== null && tokens.length >= maxTokens + + return ( + + setAlert({ ...alert, open })}> + {alert.message} + + + + + Upload Tokens + + + + Let a script or another tool upload videos and images to Fireshare on your behalf, without your password. + Uploads made with a token are credited to you and obey the permissions you hold right now — if your upload + access is removed, every token stops working with it.{' '} + + Read the documentation + {' '} + for the full list of upload options. + + + {tokens === null ? ( + + ) : ( + + {tokens.length === 0 && ( + + You have no upload tokens yet. + + )} + + {tokens.map((token) => ( + + + + {token.name} + + + {token.prefix} + {'…'} + + + {`Created ${formatDate(token.created_at) || 'unknown'}`} + {' · '} + {token.last_used_at ? `last used ${formatDate(token.last_used_at)}` : 'never used'} + + + + + setConfirmAction({ type: 'regenerate', token })} + sx={{ color: '#FFFFFFB3' }} + > + + + + + setConfirmAction({ type: 'delete', token })} + sx={{ color: '#FF6B6B' }} + > + + + + + + ))} + + + + + + + + + + )} + + {/* Create */} + setCreateOpen(false)} + PaperProps={{ sx: dialogPaperSx }} + maxWidth="xs" + fullWidth + > + Create an upload token + + + Name it after the tool or machine that will use it, so you know which one to revoke later. + + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !busy) { + e.preventDefault() + handleCreate() + } + }} + sx={{ ...inputSx, mt: 2 }} + /> + + + + + + + + {/* Reveal-once secret */} + setRevealed(null)} + PaperProps={{ sx: dialogPaperSx }} + maxWidth="sm" + fullWidth + > + + {revealed?.isNew ? 'Your new upload token' : 'Your regenerated upload token'} + + + + + + Copy it now — you won't be able to see it again. + + + + + + {revealed?.secret} + + + copy(revealed?.secret)} sx={{ color: '#66B2FF' }}> + + + + + + + Send it as a bearer token to upload a video or an image: + + +{`curl -X POST ${window.location.origin}/api/upload/token \\ + -H "Authorization: Bearer ${revealed?.secret || ''}" \\ + -F "file=@clip.mp4" \\ + -F "title=My clip" \\ + -F "folder=uploads"`} + + + Titles, folders, games and tags can all be set on the upload —{' '} + + see the documentation + + . + + + + + + + + {/* Regenerate / delete confirmation */} + setConfirmAction(null)} + PaperProps={{ sx: dialogPaperSx }} + maxWidth="xs" + fullWidth + > + + {confirmAction?.type === 'delete' ? 'Delete this token?' : 'Regenerate this token?'} + + + + {confirmAction?.type === 'delete' + ? `Anything using "${confirmAction?.token?.name}" will stop being able to upload immediately. This cannot be undone.` + : `"${confirmAction?.token?.name}" gets a new secret and the old one stops working immediately. You will need to update whatever uses it.`} + + + + + + + + + ) +} + +export default UploadTokens diff --git a/app/client/src/services/UserService.js b/app/client/src/services/UserService.js index 62a18483..317cb69a 100644 --- a/app/client/src/services/UserService.js +++ b/app/client/src/services/UserService.js @@ -52,6 +52,23 @@ class UserService { }) } + // --- Upload tokens (machine credentials for the authenticated user) --- + listUploadTokens() { + return Api().get('/api/account/upload-tokens') + } + createUploadToken(name) { + return Api().post('/api/account/upload-tokens', { name }) + } + renameUploadToken(id, name) { + return Api().put(`/api/account/upload-tokens/${id}`, { name }) + } + regenerateUploadToken(id) { + return Api().post(`/api/account/upload-tokens/${id}/regenerate`) + } + deleteUploadToken(id) { + return Api().delete(`/api/account/upload-tokens/${id}`) + } + // --- Invite redemption (unauthenticated) --- checkSetupToken(token) { return Api().get('/api/account/setup-password', { params: { token } }) diff --git a/app/client/src/views/Settings.js b/app/client/src/views/Settings.js index 14603b04..8931c62b 100644 --- a/app/client/src/views/Settings.js +++ b/app/client/src/views/Settings.js @@ -45,6 +45,7 @@ import { setSetting, getSetting } from '../common/utils' import LightTooltip from '../components/ui/LightTooltip' import GameSearch from '../components/game/GameSearch' import SecuritySettings from '../components/settings/SecuritySettings' +import UploadTokens from '../components/settings/UploadTokens' import ChangePassword from '../components/settings/ChangePassword' import UserManagement from '../components/settings/UserManagement' import SidebarPagesEditor from '../components/settings/SidebarPagesEditor' @@ -1612,6 +1613,14 @@ const Settings = ({ isAdmin, currentUser, can = () => false }) => { + {/* Only an account that may upload has anything to mint a token for; + the routes behind this pane enforce the same permission. */} + {(isAdmin || can('upload')) && ( + <> + + + + )} )} diff --git a/app/server/fireshare/api/__init__.py b/app/server/fireshare/api/__init__.py index 249993f3..60bfddd4 100644 --- a/app/server/fireshare/api/__init__.py +++ b/app/server/fireshare/api/__init__.py @@ -4,4 +4,4 @@ templates_path = os.environ.get('TEMPLATE_PATH') or 'templates' api = Blueprint('api', __name__, template_folder=templates_path) -from . import transcoding, scan, misc, admin, video, upload, game, tag, image, folder, profile, users, relink # noqa: E402,F401 +from . import transcoding, scan, misc, admin, video, upload, game, tag, image, folder, profile, users, relink, upload_tokens # noqa: E402,F401 diff --git a/app/server/fireshare/api/upload_tokens.py b/app/server/fireshare/api/upload_tokens.py new file mode 100644 index 00000000..5afa6223 --- /dev/null +++ b/app/server/fireshare/api/upload_tokens.py @@ -0,0 +1,497 @@ +"""Upload tokens: long-lived credentials that let external tools upload media. + +The shape of the feature, and why: + +* A token is a bearer credential owned by exactly one user. It grants nothing on + its own — every request re-reads the owner's account, so revoking `upload`, + disabling the account, or deleting it stops the token on the very next call. + That is what keeps the token route as tight as the session-authenticated ones + rather than becoming a way around the permission system. +* Only the sha256 of the secret is stored. A database copy cannot be replayed + against the API, and a mislaid token can be replaced but never recovered. The + raw value exists in exactly one response: the one that created or regenerated + it. +* Tokens never expire. They are ended by deleting or regenerating them, which is + what an unattended tool needs — an expiry would silently break automation. +* The credential is read from a header only. Query strings land in access logs + and browser history, so a token in one would leak somewhere the owner cannot + clean up. + +The upload route itself deliberately reuses the helpers behind `/api/upload` and +`/api/upload/image` rather than reimplementing them, so the filename sanitising, +extension allowlist, folder containment, demo size cap, duplicate rejection, and +uploader attribution are all literally the same code paths the browser uses. +""" +import hashlib +import json +import os +import random +import secrets +import string +import threading +import time +from datetime import datetime, timezone +from functools import wraps +from pathlib import Path + +from flask import current_app, jsonify, request, Response +from flask_login import current_user + +from .. import db, logger +from .. import permissions as P +from ..constants import SUPPORTED_FILE_TYPES +from ..ip_whitelist import get_client_ip +from ..models import GameMetadata, UploadToken, User +from . import api +from .decorators import json_body, require_perm +from .helpers import sanitize_upload_folder, secure_filename +from .image import SUPPORTED_IMAGE_TYPES, _launch_scan_image +from .upload import _check_upload_size, _launch_scan_video, _parse_upload_metadata, _reject_duplicate + +# Identifies a Fireshare upload token on sight, the way `ghp_` does for GitHub. +# Secret scanners and log filters can key on it, and a user who pastes the wrong +# credential into a config file can tell at a glance. +TOKEN_PREFIX = 'fsk_' + +# Bytes of randomness behind the prefix: 256 bits, so guessing is not a threat +# model and the throttle below only exists to stop somebody making us do the work. +TOKEN_ENTROPY_BYTES = 32 + +# How much of the token the UI may show. This is the leading fragment of the real +# secret, which is how the owner recognises it in a config file; the remaining +# ~200 bits are what actually authenticates. +PREFIX_DISPLAY_LENGTH = len(TOKEN_PREFIX) + 8 + +TOKEN_NAME_MAX_LENGTH = 64 +MAX_TOKENS_PER_USER = 20 + +# Writing last_used_at on every call would mean a database write per upload +# request for a field nobody reads at that resolution. +LAST_USED_WRITE_INTERVAL = 60 + + +# --------------------------------------------------------------------------- +# Failed-token throttle +# +# Kept separate from login_throttle on purpose: sharing its per-IP bucket would +# let a tool looping on a stale token lock real people out of signing in from the +# same address. Counters live in process memory, exactly like the login ones, and +# an attacker cannot force the reset that restarting would give them. +# --------------------------------------------------------------------------- + +_THROTTLE_WINDOW_SECONDS = 300 +_THROTTLE_MAX_FAILURES = 30 +_THROTTLE_MAX_TRACKED_IPS = 4096 + +_throttle_lock = threading.Lock() +_throttle_failures = {} + + +def _throttle_recent(ip, now): + stamps = [t for t in _throttle_failures.get(ip, ()) if t > now - _THROTTLE_WINDOW_SECONDS] + if stamps: + _throttle_failures[ip] = stamps + else: + _throttle_failures.pop(ip, None) + return stamps + + +def _throttle_retry_after(ip): + """Seconds this address must wait before another token will be checked.""" + now = time.time() + with _throttle_lock: + stamps = _throttle_recent(ip, now) + if len(stamps) >= _THROTTLE_MAX_FAILURES: + return int(stamps[0] + _THROTTLE_WINDOW_SECONDS - now) + 1 + return 0 + + +def _throttle_record_failure(ip): + now = time.time() + with _throttle_lock: + stamps = _throttle_recent(ip, now) + stamps.append(now) + del stamps[: max(0, len(stamps) - _THROTTLE_MAX_FAILURES)] + _throttle_failures[ip] = stamps + for key in [k for k in _throttle_failures + if not any(t > now - _THROTTLE_WINDOW_SECONDS for t in _throttle_failures[k])]: + del _throttle_failures[key] + if len(_throttle_failures) > _THROTTLE_MAX_TRACKED_IPS: + stale = sorted(_throttle_failures, key=lambda k: _throttle_failures[k][-1]) + for key in stale[: len(_throttle_failures) - _THROTTLE_MAX_TRACKED_IPS]: + del _throttle_failures[key] + + +def _throttle_clear(ip): + with _throttle_lock: + _throttle_failures.pop(ip, None) + + +# --------------------------------------------------------------------------- +# Token helpers +# --------------------------------------------------------------------------- + +def _now(): + """Naive UTC, matching the DateTime columns elsewhere in the schema.""" + return datetime.now(timezone.utc).replace(tzinfo=None) + + +def _generate_token(): + return TOKEN_PREFIX + secrets.token_urlsafe(TOKEN_ENTROPY_BYTES) + + +def _hash_token(raw): + return hashlib.sha256(raw.encode('utf-8')).hexdigest() + + +def _clean_token_name(value): + """Reduce a caller-supplied token name to something safe to store and render. + + Control characters are stripped for the same reason as in the profile fields: + a name containing them would render as something other than what it says. + """ + if not isinstance(value, str): + return None + cleaned = ''.join(ch for ch in value if ch.isprintable()).strip() + return cleaned[:TOKEN_NAME_MAX_LENGTH] or None + + +def _token_from_request(): + """The raw token presented by the caller, or None. + + Headers only. A token in a query string would be written to the access log of + every proxy in front of Fireshare and to the caller's shell history. + """ + header = request.headers.get('Authorization', '') + if header[:7].lower() == 'bearer ': + candidate = header[7:].strip() + if candidate: + return candidate + return (request.headers.get('X-Fireshare-Token') or '').strip() or None + + +def _resolve_token(raw): + """Return (token, user) for a presented secret, or (None, None). + + The lookup is by hash, so nothing here compares secrets byte by byte, and an + unknown token costs one indexed read. + """ + if not raw or len(raw) > 512: + return None, None + token = UploadToken.query.filter_by(token_hash=_hash_token(raw)).first() + if not token: + return None, None + user = db.session.get(User, token.user_id) + if not user or user.disabled or not user.can(P.UPLOAD): + return None, None + return token, user + + +def _note_token_use(token): + now = _now() + if token.last_used_at and (now - token.last_used_at).total_seconds() < LAST_USED_WRITE_INTERVAL: + return + token.last_used_at = now + try: + db.session.commit() + except Exception as e: + db.session.rollback() + logger.warning(f"Could not record use of upload token {token.id}: {e}") + + +def upload_token_required(f): + """Authenticate the caller by upload token, or reject with 401. + + The decorated view receives `token_user` as a keyword argument. It is the live + User row, re-read on every request, so the owner's current permissions govern + the call rather than whatever they held when the token was minted. + """ + @wraps(f) + def decorated(*args, **kwargs): + client_ip = get_client_ip() + wait = _throttle_retry_after(client_ip) + if wait: + return Response( + status=429, + response='Too many invalid upload tokens. Try again later.', + headers={'Retry-After': str(wait)}, + ) + + raw = _token_from_request() + if not raw: + return Response( + status=401, + response='An upload token is required. Send it as "Authorization: Bearer ".', + headers={'WWW-Authenticate': 'Bearer realm="fireshare-upload"'}, + ) + + token, user = _resolve_token(raw) + if not user: + # One message for an unknown token, a revoked permission, and a + # disabled account alike: the difference is not the caller's business + # and telling them would confirm which tokens exist. + _throttle_record_failure(client_ip) + logger.warning(f"Rejected an upload token presented from {client_ip}") + return Response(status=401, response='Invalid upload token.') + + _throttle_clear(client_ip) + _note_token_use(token) + kwargs['token_user'] = user + return f(*args, **kwargs) + return decorated + + +# --------------------------------------------------------------------------- +# Token management (session-authenticated, owner-scoped) +# --------------------------------------------------------------------------- + +def _owned_token_or_404(token_id): + """A token belonging to the signed-in user, or a 404 response. + + Scoped by owner rather than looked up and then checked, so another account's + token id is indistinguishable from one that does not exist. + """ + token = UploadToken.query.filter_by(id=token_id, user_id=current_user.id).first() + if not token: + return None, (jsonify({'error': 'Upload token not found.'}), 404) + return token, None + + +@api.route('/api/account/upload-tokens', methods=['GET']) +@require_perm(P.UPLOAD) +def list_upload_tokens(): + tokens = (UploadToken.query + .filter_by(user_id=current_user.id) + .order_by(UploadToken.created_at.desc(), UploadToken.id.desc()) + .all()) + return jsonify({'tokens': [t.json() for t in tokens], 'max_tokens': MAX_TOKENS_PER_USER}) + + +@api.route('/api/account/upload-tokens', methods=['POST']) +@require_perm(P.UPLOAD) +@json_body +def create_upload_token(): + name = _clean_token_name(request.json_body.get('name')) or 'Upload token' + + if UploadToken.query.filter_by(user_id=current_user.id).count() >= MAX_TOKENS_PER_USER: + return jsonify({ + 'error': f'You already have {MAX_TOKENS_PER_USER} upload tokens. ' + 'Delete one before creating another.' + }), 400 + + raw = _generate_token() + token = UploadToken( + user_id=current_user.id, + name=name, + token_hash=_hash_token(raw), + prefix=raw[:PREFIX_DISPLAY_LENGTH], + created_at=_now(), + ) + db.session.add(token) + db.session.commit() + logger.info(f"User '{current_user.username}' created upload token {token.prefix}") + + # The only time the raw token is ever returned. + return jsonify({'token': {**token.json(), 'secret': raw}}), 201 + + +@api.route('/api/account/upload-tokens/', methods=['PUT']) +@require_perm(P.UPLOAD) +@json_body +def rename_upload_token(token_id): + token, error = _owned_token_or_404(token_id) + if error: + return error + name = _clean_token_name(request.json_body.get('name')) + if not name: + return jsonify({'error': 'A name is required.'}), 400 + token.name = name + db.session.commit() + return jsonify({'token': token.json()}) + + +@api.route('/api/account/upload-tokens//regenerate', methods=['POST']) +@require_perm(P.UPLOAD) +def regenerate_upload_token(token_id): + token, error = _owned_token_or_404(token_id) + if error: + return error + + raw = _generate_token() + token.token_hash = _hash_token(raw) + token.prefix = raw[:PREFIX_DISPLAY_LENGTH] + token.created_at = _now() + # The old secret is gone, so anything it had done is no longer this token's + # history. Clearing this keeps "last used" honest about the new one. + token.last_used_at = None + db.session.commit() + logger.info(f"User '{current_user.username}' regenerated upload token {token.id} as {token.prefix}") + + return jsonify({'token': {**token.json(), 'secret': raw}}) + + +@api.route('/api/account/upload-tokens/', methods=['DELETE']) +@require_perm(P.UPLOAD) +def delete_upload_token(token_id): + token, error = _owned_token_or_404(token_id) + if error: + return error + prefix = token.prefix + db.session.delete(token) + db.session.commit() + logger.info(f"User '{current_user.username}' deleted upload token {prefix}") + return jsonify({'deleted': True}) + + +# --------------------------------------------------------------------------- +# Token-authenticated upload +# --------------------------------------------------------------------------- + +def _resolve_game_id(game_id): + """The game to link the upload to, from `game_id` or a `game` name. + + A machine usually knows the game as a name, not as a Fireshare row id, so the + name is accepted as well. It only ever resolves an existing game — creating + one from an upload would let a token fill the library with typos. + """ + if game_id is not None: + return game_id, None + name = (request.form.get('game') or '').strip() + if not name: + return None, None + game = GameMetadata.query.filter(db.func.lower(GameMetadata.name) == name.lower()).first() + if not game: + return None, jsonify({ + 'error': 'unknown_game', + 'message': f'No game named "{name}" exists in this library. Add it first, ' + 'or pass game_id.', + }) + return game.id, None + + +def _unique_save_path(directory, filename, filetype): + """A path under `directory` for `filename`, suffixed if something is there.""" + save_path = os.path.join(directory, filename) + if os.path.exists(save_path): + stem = '.'.join(filename.split('.')[:-1]) + uid = ''.join(random.choice(string.ascii_lowercase + string.digits) for _ in range(6)) + save_path = os.path.join(directory, f"{stem}-{uid}.{filetype}") + return save_path + + +@api.route('/api/upload/token', methods=['POST']) +@upload_token_required +def token_upload(token_user): + """Upload one video or image as the token's owner. + + multipart/form-data: + file the media (required); the extension decides video or image + title optional title for the item + folder optional destination folder under the media root + game_id optional Fireshare game id, or + game optional game name, resolved against existing games + tag_ids optional comma-separated tag ids + """ + paths = current_app.config['PATHS'] + try: + with open(paths['data'] / 'config.json', 'r') as configfile: + config = json.load(configfile) + except Exception: + logger.error("Invalid or corrupt config file") + return Response(status=500, response='Invalid or corrupt config file.') + + if 'file' not in request.files: + return Response(status=400, response='A "file" part is required.') + file = request.files['file'] + if not file.filename: + return Response(status=400, response='The uploaded file has no name.') + + filename = secure_filename(file.filename) + if not filename: + return Response(status=400, response='The uploaded file has no usable name.') + filetype = filename.rsplit('.', 1)[-1].lower() if '.' in filename else '' + + if filetype in SUPPORTED_FILE_TYPES: + media_type = 'video' + elif filetype in SUPPORTED_IMAGE_TYPES: + media_type = 'image' + else: + supported = ', '.join(sorted(set(SUPPORTED_FILE_TYPES) | SUPPORTED_IMAGE_TYPES)) + return Response(status=400, response=f'Unsupported file type. Supported: {supported}.') + + file.seek(0, 2) + size_err = _check_upload_size(file.tell()) + file.seek(0) + if size_err: + return size_err + + tag_ids, game_id, title = _parse_upload_metadata() + game_id, game_err = _resolve_game_id(game_id) + if game_err: + return game_err, 400 + + # Same default as /api/upload and /api/upload/image: a token belongs to a real + # account with the upload permission, so it files media where that account's + # own uploads go, not into the public drop folder. + upload_folder = config['app_config'].get('admin_upload_folder_name', 'uploads') + requested_folder = sanitize_upload_folder(request.form.get('folder')) + if requested_folder: + upload_folder = requested_folder + + if media_type == 'image': + image_directory = current_app.config.get('IMAGE_DIRECTORY') + if not image_directory: + return Response(status=503, response='IMAGE_DIRECTORY is not configured.') + upload_directory = Path(image_directory) / upload_folder + upload_directory.mkdir(parents=True, exist_ok=True) + save_path = _unique_save_path(str(upload_directory), filename, filetype) + file.save(save_path) + _launch_scan_image(save_path, config, game_id=game_id, tag_ids=tag_ids, + title=title, uploaded_by=token_user.id) + else: + upload_directory = paths['video'] / upload_folder + upload_directory.mkdir(parents=True, exist_ok=True) + save_path = _unique_save_path(str(upload_directory), filename, filetype) + file.save(save_path) + duplicate = _reject_duplicate(save_path) + if duplicate: + return duplicate + _launch_scan_video(save_path, config, tag_ids, game_id, title, + uploaded_by=token_user.id) + + logger.info( + f"Token upload: {media_type} '{os.path.basename(save_path)}' into '{upload_folder}' " + f"as '{token_user.username}'" + ) + return jsonify({ + 'status': 'accepted', + 'media_type': media_type, + 'filename': os.path.basename(save_path), + 'folder': upload_folder, + }), 201 + + +@api.route('/api/upload/token', methods=['GET']) +@upload_token_required +def token_upload_check(token_user): + """Confirm a token works, and report what it may do, without uploading. + + Integrations need a way to validate their configuration that does not involve + putting a file in somebody's library. + """ + paths = current_app.config['PATHS'] + try: + with open(paths['data'] / 'config.json', 'r') as configfile: + config = json.load(configfile) + default_folder = config['app_config'].get('admin_upload_folder_name', 'uploads') + except Exception: + default_folder = None + + return jsonify({ + 'ok': True, + 'username': token_user.username, + 'default_folder': default_folder, + 'images_enabled': bool(current_app.config.get('IMAGE_DIRECTORY')), + 'supported_video_types': sorted(SUPPORTED_FILE_TYPES), + 'supported_image_types': sorted(SUPPORTED_IMAGE_TYPES), + }) diff --git a/app/server/fireshare/models.py b/app/server/fireshare/models.py index e3325fe7..3363ceaf 100644 --- a/app/server/fireshare/models.py +++ b/app/server/fireshare/models.py @@ -44,6 +44,12 @@ class User(UserMixin, db.Model): # game's art and then to a generated gradient. banner_version = db.Column(db.Integer, nullable=False, default=0, server_default='0') + # Long-lived credentials for machine uploads. Deleted with the account: a + # token outliving its owner would authenticate as a user that no longer exists. + upload_tokens = db.relationship( + "UploadToken", back_populates="user", cascade="all, delete-orphan", lazy="select" + ) + @property def granted_permissions(self): """The set of grantable keys this user holds (empty for admins, who bypass).""" @@ -645,3 +651,40 @@ class TranscodeJob(db.Model): def __repr__(self): return "".format(self.id, self.video_id, self.status) + + +class UploadToken(db.Model): + """A long-lived bearer credential that lets an external tool upload as its owner. + + Only the sha256 of the secret is stored, so a database copy cannot be replayed + against the API and a lost token can only be replaced, never recovered. The + token carries no permissions of its own: every request re-reads the owner's + account, so revoking `upload` or disabling the user stops it immediately. + """ + __tablename__ = "upload_token" + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, index=True) + name = db.Column(db.String(64), nullable=False) + # sha256 hex of the raw token. Unique so a hash collision cannot silently + # shadow another user's credential. + token_hash = db.Column(db.String(64), unique=True, index=True, nullable=False) + # The leading, non-secret part of the token, shown in the UI so an owner can + # tell two tokens apart without the server ever holding the rest. + prefix = db.Column(db.String(24), nullable=False) + created_at = db.Column(db.DateTime(), nullable=True) + last_used_at = db.Column(db.DateTime(), nullable=True) + + user = db.relationship("User", back_populates="upload_tokens") + + def json(self): + return { + "id": self.id, + "name": self.name, + "prefix": self.prefix, + "created_at": self.created_at.isoformat() if self.created_at else None, + "last_used_at": self.last_used_at.isoformat() if self.last_used_at else None, + } + + def __repr__(self): + return "".format(self.id, self.prefix, self.user_id) diff --git a/docs/UploadTokens.md b/docs/UploadTokens.md new file mode 100644 index 00000000..fb65d77a --- /dev/null +++ b/docs/UploadTokens.md @@ -0,0 +1,129 @@ +# Upload Tokens + +Upload tokens let a script, a capture box, or any other tool push videos and +images into Fireshare without a password and without a browser session. + +A token belongs to one user and carries nothing of its own: every request re-reads +that account, so the upload lands with the owner's name on it and obeys whatever +permissions they hold *at that moment*. Take away their `upload` permission, +disable the account, or delete it, and every token they made stops working on the +next call. + +Tokens never expire. They end when you delete or regenerate them. + +## Creating a token + +Any account with the **Upload** permission (and every administrator) can make one: + +**Settings → Security → Upload Tokens → Create token** + +Name it after the machine or tool that will use it, so you know which one to +revoke later. The secret is shown **once**, on creation — Fireshare stores only a +sha256 of it and cannot show it again. If you lose it, regenerate the token to get +a new secret; the old one stops working immediately. + +Each account may hold up to 20 tokens. + +## Uploading + +``` +POST /api/upload/token +Authorization: Bearer +Content-Type: multipart/form-data +``` + +One file per request. Whether it is filed as a video or an image is decided by its +extension — `mp4`, `m4v`, `mov`, `webm` for video; `jpg`, `jpeg`, `png`, `webp`, +`gif` for images. Anything else is rejected with a 400. + +| Field | Required | Description | +| --- | --- | --- | +| `file` | yes | The media to upload. | +| `title` | no | Title for the item. Without it, Fireshare uses the filename. | +| `folder` | no | Destination folder under the media root. Defaults to the configured upload folder. | +| `game_id` | no | Fireshare game id to link the upload to. | +| `game` | no | Game *name*, matched case-insensitively against games already in the library. Ignored when `game_id` is given; a name that matches nothing is a 400. | +| `tag_ids` | no | Comma-separated tag ids. | + +The token goes in a header, never a query string — a URL ends up in proxy access +logs and shell history. `X-Fireshare-Token: ` works as an alternative to +`Authorization: Bearer`. + +```bash +curl -X POST https://fireshare.example.com/api/upload/token \ + -H "Authorization: Bearer fsk_your_token_here" \ + -F "file=@clip.mp4" \ + -F "title=Ace on Ascent" \ + -F "folder=uploads" \ + -F "game=VALORANT" +``` + +A successful upload returns `201` and describes where the file landed: + +```json +{ + "status": "accepted", + "media_type": "video", + "filename": "clip.mp4", + "folder": "uploads" +} +``` + +`accepted` rather than `complete`: the file is on disk and Fireshare has started +scanning it in the background, exactly as it does for a browser upload. The item +appears in the library once that finishes. + +### Responses + +| Status | Meaning | +| --- | --- | +| `201` | Stored; scanning has started. | +| `400` | No file, unsupported extension, or an unknown game name. | +| `401` | Missing, unknown, or revoked token. | +| `409` | This video is already in the library; the body identifies the existing one. | +| `413` | The file is over the demo-mode upload limit (demo instances only). | +| `429` | Too many invalid tokens from this address. Retry after the `Retry-After` header. | +| `503` | An image was uploaded but `IMAGE_DIRECTORY` is not configured. | + +## Checking a token + +`GET /api/upload/token` with the same header validates a token without uploading +anything, which is useful when setting a tool up: + +```bash +curl https://fireshare.example.com/api/upload/token \ + -H "Authorization: Bearer fsk_your_token_here" +``` + +```json +{ + "ok": true, + "username": "shane", + "default_folder": "uploads", + "images_enabled": true, + "supported_video_types": ["m4v", "mov", "mp4", "webm"], + "supported_image_types": ["gif", "jpeg", "jpg", "png", "webp"] +} +``` + +## How this differs from public uploads + +`allow_public_upload` opens `/api/upload/public` to anyone who can reach the +instance, drops everything into the public upload folder, and attributes nothing. +Upload tokens are the opposite: the caller is a known account, the upload is +attributed, folder and metadata are theirs to choose, and access is revoked by +deleting one token rather than by turning a feature off for everybody. + +Leaving public uploads disabled and handing out tokens is the safer arrangement +for anything automated. + +## Keeping tokens safe + +* Treat a token like a password. Anyone holding it can upload as you. +* Give each tool its own token, so revoking one does not break the others. +* Fireshare never logs the secret. It logs the visible prefix (`fsk_xxxxxxxx`) + when a token is created, regenerated, or deleted. +* Serve Fireshare over HTTPS if tokens cross a network you do not control — a + bearer token in a plain HTTP request is readable in transit. +* Regenerate immediately if a token may have leaked; the old secret dies the + moment the new one is issued. diff --git a/migrations/versions/s4n5o6p7q8r9_add_upload_token_table.py b/migrations/versions/s4n5o6p7q8r9_add_upload_token_table.py new file mode 100644 index 00000000..a3498454 --- /dev/null +++ b/migrations/versions/s4n5o6p7q8r9_add_upload_token_table.py @@ -0,0 +1,51 @@ +"""add upload_token table + +Revision ID: s4n5o6p7q8r9 +Revises: r3m4n5o6p7q8 +Create Date: 2026-09-22 10:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 's4n5o6p7q8r9' +down_revision = 'r3m4n5o6p7q8' +branch_labels = None +depends_on = None + + +def table_exists(table_name): + bind = op.get_bind() + return table_name in sa.inspect(bind).get_table_names() + + +def upgrade(): + # Long-lived upload credentials for external tools. Only the sha256 of each + # token is stored; `prefix` is the non-secret leading fragment shown in the UI + # so an owner can tell their tokens apart. + if not table_exists('upload_token'): + op.create_table( + 'upload_token', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(length=64), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('prefix', sa.String(length=24), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('last_used_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + ) + with op.batch_alter_table('upload_token', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_upload_token_user_id'), ['user_id'], unique=False) + batch_op.create_index(batch_op.f('ix_upload_token_token_hash'), ['token_hash'], unique=True) + + +def downgrade(): + if table_exists('upload_token'): + with op.batch_alter_table('upload_token', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_upload_token_token_hash')) + batch_op.drop_index(batch_op.f('ix_upload_token_user_id')) + op.drop_table('upload_token') From fbbc6464bd45f48cad243c2f2c9e683f2bab5e19 Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Tue, 22 Sep 2026 15:09:33 -0600 Subject: [PATCH 03/10] feat: chunked token uploads, and a folders/games listing for tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two things an unattended uploader needs beyond a single POST: a way to send a large file in pieces, and a way to find out what folders and games it may name. POST /api/upload/token/chunked takes one chunk per request, in any order, grouped by a caller-chosen checkSum. Every request but the last answers 202; whichever completes the set reassembles the file, verifies it against the declared fileSize, and answers exactly as the single-shot route does — including the 409 when the finished video is already in the library. Both video and image uploads can be chunked, since the finished file's extension decides which it is. Both upload routes now funnel through _prepare_upload / _finish_upload, so they cannot drift apart in what they accept, where they file it, or how they attribute it. Unlike the browser-facing chunked routes, malformed chunkPart, totalChunks or fileSize values answer 400 rather than raising through to a 500: a token route is driven by somebody else's code, so bad input is expected. totalChunks is capped, because every chunk is a request and a file on disk. An abandoned chunked image upload leaves its parts under the image root rather than the video root, so the startup sweep that removes orphaned .partNNNN files now covers both. GET /api/upload/token/options returns the video and image folders, the default folder, and every game in the library. Games are listed in full rather than through /api/games, which hides games with nothing linked to them yet: those are exactly the ones an upload might be the first to use, and `game` name resolution already accepts them. --- app/server/fireshare/__init__.py | 6 +- app/server/fireshare/api/upload_tokens.py | 304 ++++++++++++++++++---- docs/UploadTokens.md | 84 ++++++ 3 files changed, 344 insertions(+), 50 deletions(-) diff --git a/app/server/fireshare/__init__.py b/app/server/fireshare/__init__.py index d865b0f9..d3d07f6c 100644 --- a/app/server/fireshare/__init__.py +++ b/app/server/fireshare/__init__.py @@ -333,7 +333,11 @@ def create_app(init_schedule=False): if _should_cleanup: import glob as _glob - chunk_files = _glob.glob(str(paths['video'] / '**' / '*.part[0-9][0-9][0-9][0-9]'), recursive=True) + # Images are swept too: token uploads can be chunked, and an image one + # leaves its parts under the image root rather than the video root. + chunk_roots = [paths['video']] + ([paths['images']] if 'images' in paths else []) + chunk_files = [f for root in chunk_roots + for f in _glob.glob(str(root / '**' / '*.part[0-9][0-9][0-9][0-9]'), recursive=True)] for chunk_file in chunk_files: try: os.remove(chunk_file) diff --git a/app/server/fireshare/api/upload_tokens.py b/app/server/fireshare/api/upload_tokens.py index 5afa6223..bb96ad92 100644 --- a/app/server/fireshare/api/upload_tokens.py +++ b/app/server/fireshare/api/upload_tokens.py @@ -26,7 +26,9 @@ import json import os import random +import re import secrets +import shutil import string import threading import time @@ -345,8 +347,17 @@ def delete_upload_token(token_id): # --------------------------------------------------------------------------- # Token-authenticated upload +# +# Both routes funnel through _prepare_upload / _finish_upload so a chunked +# upload and a single-shot one cannot drift apart in what they accept, where +# they file it, or how they attribute it. # --------------------------------------------------------------------------- +# A cap on totalChunks. Every chunk is one request and one file on disk, so an +# absurd value is a way to make the server create a great many of both. +MAX_CHUNKS = 20000 + + def _resolve_game_id(game_id): """The game to link the upload to, from `game_id` or a `game` name. @@ -379,18 +390,25 @@ def _unique_save_path(directory, filename, filetype): return save_path -@api.route('/api/upload/token', methods=['POST']) -@upload_token_required -def token_upload(token_user): - """Upload one video or image as the token's owner. +def _media_type_for(filetype): + if filetype in SUPPORTED_FILE_TYPES: + return 'video' + if filetype in SUPPORTED_IMAGE_TYPES: + return 'image' + return None - multipart/form-data: - file the media (required); the extension decides video or image - title optional title for the item - folder optional destination folder under the media root - game_id optional Fireshare game id, or - game optional game name, resolved against existing games - tag_ids optional comma-separated tag ids + +def _supported_types_message(): + supported = ', '.join(sorted(set(SUPPORTED_FILE_TYPES) | SUPPORTED_IMAGE_TYPES)) + return f'Unsupported file type. Supported: {supported}.' + + +def _prepare_upload(raw_filename, file_size): + """Validate a token upload and work out where it belongs. + + Returns (plan, error_response). The plan carries everything both routes need + after this point; the destination directory exists by the time it is handed + back, because the chunked route writes its parts into it. """ paths = current_app.config['PATHS'] try: @@ -398,37 +416,24 @@ def token_upload(token_user): config = json.load(configfile) except Exception: logger.error("Invalid or corrupt config file") - return Response(status=500, response='Invalid or corrupt config file.') - - if 'file' not in request.files: - return Response(status=400, response='A "file" part is required.') - file = request.files['file'] - if not file.filename: - return Response(status=400, response='The uploaded file has no name.') + return None, Response(status=500, response='Invalid or corrupt config file.') - filename = secure_filename(file.filename) + filename = secure_filename(raw_filename or '') if not filename: - return Response(status=400, response='The uploaded file has no usable name.') + return None, Response(status=400, response='The uploaded file has no usable name.') filetype = filename.rsplit('.', 1)[-1].lower() if '.' in filename else '' + media_type = _media_type_for(filetype) + if not media_type: + return None, Response(status=400, response=_supported_types_message()) - if filetype in SUPPORTED_FILE_TYPES: - media_type = 'video' - elif filetype in SUPPORTED_IMAGE_TYPES: - media_type = 'image' - else: - supported = ', '.join(sorted(set(SUPPORTED_FILE_TYPES) | SUPPORTED_IMAGE_TYPES)) - return Response(status=400, response=f'Unsupported file type. Supported: {supported}.') - - file.seek(0, 2) - size_err = _check_upload_size(file.tell()) - file.seek(0) + size_err = _check_upload_size(file_size) if size_err: - return size_err + return None, size_err tag_ids, game_id, title = _parse_upload_metadata() game_id, game_err = _resolve_game_id(game_id) if game_err: - return game_err, 400 + return None, (game_err, 400) # Same default as /api/upload and /api/upload/image: a token belongs to a real # account with the upload permission, so it files media where that account's @@ -441,43 +446,202 @@ def token_upload(token_user): if media_type == 'image': image_directory = current_app.config.get('IMAGE_DIRECTORY') if not image_directory: - return Response(status=503, response='IMAGE_DIRECTORY is not configured.') + return None, Response(status=503, response='IMAGE_DIRECTORY is not configured.') upload_directory = Path(image_directory) / upload_folder - upload_directory.mkdir(parents=True, exist_ok=True) - save_path = _unique_save_path(str(upload_directory), filename, filetype) - file.save(save_path) - _launch_scan_image(save_path, config, game_id=game_id, tag_ids=tag_ids, - title=title, uploaded_by=token_user.id) else: upload_directory = paths['video'] / upload_folder - upload_directory.mkdir(parents=True, exist_ok=True) - save_path = _unique_save_path(str(upload_directory), filename, filetype) - file.save(save_path) + upload_directory.mkdir(parents=True, exist_ok=True) + + return { + 'config': config, + 'filename': filename, + 'filetype': filetype, + 'media_type': media_type, + 'upload_folder': upload_folder, + 'upload_directory': str(upload_directory), + 'tag_ids': tag_ids, + 'game_id': game_id, + 'title': title, + }, None + + +def _finish_upload(plan, save_path, token_user): + """Hand a fully written upload to the background scan and answer the caller.""" + if plan['media_type'] == 'image': + _launch_scan_image(save_path, plan['config'], game_id=plan['game_id'], + tag_ids=plan['tag_ids'], title=plan['title'], + uploaded_by=token_user.id) + else: duplicate = _reject_duplicate(save_path) if duplicate: return duplicate - _launch_scan_video(save_path, config, tag_ids, game_id, title, - uploaded_by=token_user.id) + _launch_scan_video(save_path, plan['config'], plan['tag_ids'], plan['game_id'], + plan['title'], uploaded_by=token_user.id) logger.info( - f"Token upload: {media_type} '{os.path.basename(save_path)}' into '{upload_folder}' " - f"as '{token_user.username}'" + f"Token upload: {plan['media_type']} '{os.path.basename(save_path)}' into " + f"'{plan['upload_folder']}' as '{token_user.username}'" ) return jsonify({ 'status': 'accepted', - 'media_type': media_type, + 'media_type': plan['media_type'], 'filename': os.path.basename(save_path), - 'folder': upload_folder, + 'folder': plan['upload_folder'], }), 201 +@api.route('/api/upload/token', methods=['POST']) +@upload_token_required +def token_upload(token_user): + """Upload one video or image as the token's owner, in a single request. + + multipart/form-data: + file the media (required); the extension decides video or image + title optional title for the item + folder optional destination folder under the media root + game_id optional Fireshare game id, or + game optional game name, resolved against existing games + tag_ids optional comma-separated tag ids + """ + if 'file' not in request.files: + return Response(status=400, response='A "file" part is required.') + file = request.files['file'] + if not file.filename: + return Response(status=400, response='The uploaded file has no name.') + + file.seek(0, 2) + file_size = file.tell() + file.seek(0) + + plan, error = _prepare_upload(file.filename, file_size) + if error: + return error + + save_path = _unique_save_path(plan['upload_directory'], plan['filename'], plan['filetype']) + file.save(save_path) + return _finish_upload(plan, save_path, token_user) + + +def _positive_int(value, maximum=None): + """A positive int from form input, or None. + + The browser-facing chunked routes call int() straight on the form value, + which turns a malformed request into a 500. A token route is driven by + somebody else's code, so bad input is expected and answered with a 400. + """ + try: + parsed = int(value) + except (TypeError, ValueError): + return None + if parsed < 1 or (maximum is not None and parsed > maximum): + return None + return parsed + + +@api.route('/api/upload/token/chunked', methods=['POST']) +@upload_token_required +def token_upload_chunked(token_user): + """Upload one video or image in chunks, for files too large to send at once. + + Send each chunk as its own request, in any order, with the same checkSum + throughout. Every request but the last answers 202; the one that completes + the set reassembles the file and answers 201 exactly as /api/upload/token + does, including a 409 when the finished video is already in the library. + + multipart/form-data: + blob this chunk's bytes (required) + chunkPart 1-based index of this chunk (required) + totalChunks how many chunks make up the file (required) + checkSum caller-chosen id grouping the chunks, [A-Za-z0-9_-] (required) + fileName the finished file's name; its extension picks video or image (required) + fileSize the finished file's size in bytes, verified after reassembly (required) + title / folder / game_id / game / tag_ids as for /api/upload/token + """ + if 'blob' not in request.files: + return Response(status=400, response='A "blob" part is required.') + blob = request.files['blob'] + + total_chunks = _positive_int(request.form.get('totalChunks'), MAX_CHUNKS) + if not total_chunks: + return Response(status=400, response=f'totalChunks must be between 1 and {MAX_CHUNKS}.') + chunk_part = _positive_int(request.form.get('chunkPart'), total_chunks) + if not chunk_part: + return Response(status=400, response='chunkPart must be between 1 and totalChunks.') + file_size = _positive_int(request.form.get('fileSize')) + if file_size is None: + return Response(status=400, response='fileSize must be a positive integer.') + + check_sum = re.sub(r'[^a-zA-Z0-9_-]', '', request.form.get('checkSum') or '') + if not check_sum: + return Response(status=400, response='A checkSum grouping the chunks is required.') + + plan, error = _prepare_upload(request.form.get('fileName'), file_size) + if error: + return error + upload_directory = plan['upload_directory'] + + temp_path = os.path.join(upload_directory, f"{check_sum}.part{chunk_part:04d}") + # The checkSum is already reduced to [A-Za-z0-9_-], so this cannot currently + # escape; kept because it is the guarantee that matters, not the regex. + if not os.path.realpath(temp_path).startswith(os.path.realpath(upload_directory) + os.sep): + return Response(status=400) + + with open(temp_path, 'wb') as f: + f.write(blob.read()) + + chunk_paths = [os.path.join(upload_directory, f"{check_sum}.part{i:04d}") + for i in range(1, total_chunks + 1)] + if not all(os.path.exists(p) for p in chunk_paths): + return Response(status=202) + + save_path = _unique_save_path(upload_directory, plan['filename'], plan['filetype']) + try: + with open(save_path, 'wb') as output_file: + for chunk_path in chunk_paths: + with open(chunk_path, 'rb') as chunk_file: + shutil.copyfileobj(chunk_file, output_file) + os.remove(chunk_path) + + if os.path.getsize(save_path) != file_size: + os.remove(save_path) + return Response(status=500, response='File size mismatch after reassembly.') + except Exception as e: + logger.warning(f"Failed to reassemble token upload {check_sum}: {e}") + for chunk_path in chunk_paths: + if os.path.exists(chunk_path): + os.remove(chunk_path) + if os.path.exists(save_path): + os.remove(save_path) + return Response(status=500, response='Error reassembling file.') + + return _finish_upload(plan, save_path, token_user) + + +# --------------------------------------------------------------------------- +# Token-authenticated discovery +# --------------------------------------------------------------------------- + +def _list_subfolders(root): + """Visible immediate subdirectories of a media root, sorted.""" + folders = [] + try: + for entry in os.scandir(root): + if entry.is_dir() and not entry.name.startswith('.'): + folders.append(entry.name) + except Exception: + return [] + folders.sort() + return folders + + @api.route('/api/upload/token', methods=['GET']) @upload_token_required def token_upload_check(token_user): """Confirm a token works, and report what it may do, without uploading. Integrations need a way to validate their configuration that does not involve - putting a file in somebody's library. + putting a file in somebody's library. Deliberately cheap — the folder and game + listings live on /api/upload/token/options. """ paths = current_app.config['PATHS'] try: @@ -495,3 +659,45 @@ def token_upload_check(token_user): 'supported_video_types': sorted(SUPPORTED_FILE_TYPES), 'supported_image_types': sorted(SUPPORTED_IMAGE_TYPES), }) + + +@api.route('/api/upload/token/options', methods=['GET']) +@upload_token_required +def token_upload_options(token_user): + """The folders and games an upload may name, so a tool can offer real choices. + + Games are listed in full rather than through /api/games, which hides games + with nothing linked to them yet: those are exactly the ones an upload might + be the first to use, and `game` name resolution already accepts them. + """ + paths = current_app.config['PATHS'] + try: + with open(paths['data'] / 'config.json', 'r') as configfile: + config = json.load(configfile) + default_folder = config['app_config'].get('admin_upload_folder_name', 'uploads') + except Exception: + default_folder = None + + video_folders = _list_subfolders(paths['video']) + image_directory = current_app.config.get('IMAGE_DIRECTORY') + image_folders = _list_subfolders(image_directory) if image_directory else [] + + # The default is offerable whether or not it has been created on disk yet. + for folders in (video_folders, image_folders): + if default_folder and default_folder not in folders: + folders.append(default_folder) + folders.sort() + + games = GameMetadata.query.order_by(GameMetadata.name).all() + + return jsonify({ + 'default_folder': default_folder, + 'folders': { + 'video': video_folders, + 'image': image_folders, + }, + 'games': [ + {'id': g.id, 'name': g.name, 'steamgriddb_id': g.steamgriddb_id} + for g in games + ], + }) diff --git a/docs/UploadTokens.md b/docs/UploadTokens.md index fb65d77a..0c98d191 100644 --- a/docs/UploadTokens.md +++ b/docs/UploadTokens.md @@ -85,6 +85,90 @@ appears in the library once that finishes. | `429` | Too many invalid tokens from this address. Retry after the `Retry-After` header. | | `503` | An image was uploaded but `IMAGE_DIRECTORY` is not configured. | +## Uploading in chunks + +Large videos can go up a piece at a time instead of in one request: + +``` +POST /api/upload/token/chunked +Authorization: Bearer +Content-Type: multipart/form-data +``` + +Send each chunk as its own request, in any order, using the same `checkSum` +throughout. Every request but the last answers `202`; whichever one completes the +set reassembles the file and answers `201` exactly as `/api/upload/token` does — +including the `409` when the finished video turns out to be a duplicate. + +| Field | Required | Description | +| --- | --- | --- | +| `blob` | yes | This chunk's bytes. | +| `chunkPart` | yes | 1-based index of this chunk. | +| `totalChunks` | yes | How many chunks make up the file (max 20000). | +| `checkSum` | yes | A caller-chosen id grouping the chunks. `A-Z a-z 0-9 _ -` only. | +| `fileName` | yes | The finished file's name; its extension picks video or image. | +| `fileSize` | yes | The finished file's size in bytes, verified after reassembly. | + +`title`, `folder`, `game_id`, `game` and `tag_ids` work as they do for a +single-shot upload — send them with whichever chunk you like, but sending them +with every chunk is simplest, since you cannot know in advance which request will +be the one that completes the set. + +Pick a `checkSum` that is unique per upload; two files sharing one will have their +chunks mixed together. A content hash of the file is the obvious choice, and is +where the name comes from. + +If an upload is abandoned part way, the orphaned `.partNNNN` files are swept on +the next Fireshare restart. + +```bash +# 8 MiB chunks, in order +split -b 8388608 -d -a 4 big.mp4 chunk_ +total=$(ls chunk_* | wc -l | tr -d ' ') +size=$(wc -c < big.mp4 | tr -d ' ') +id=$(shasum -a 256 big.mp4 | cut -c1-32) + +i=1 +for c in chunk_*; do + curl -X POST https://fireshare.example.com/api/upload/token/chunked \ + -H "Authorization: Bearer fsk_your_token_here" \ + -F "blob=@$c" \ + -F "chunkPart=$i" \ + -F "totalChunks=$total" \ + -F "checkSum=$id" \ + -F "fileName=big.mp4" \ + -F "fileSize=$size" \ + -F "title=A long clip" + i=$((i + 1)) +done +``` + +## Listing folders and games + +To offer real choices rather than making a user type a folder name from memory: + +```bash +curl https://fireshare.example.com/api/upload/token/options \ + -H "Authorization: Bearer fsk_your_token_here" +``` + +```json +{ + "default_folder": "uploads", + "folders": { + "video": ["uploads", "clips"], + "image": ["uploads", "screenshots"] + }, + "games": [ + { "id": 3, "name": "VALORANT", "steamgriddb_id": 12345 } + ] +} +``` + +Every game in the library is listed, including ones with nothing linked to them +yet — `/api/games` hides those, but they are exactly the games an upload might be +the first to use, and the `game` field already accepts them. + ## Checking a token `GET /api/upload/token` with the same header validates a token without uploading From 4c425bcbe4a638697b023e3a6e7a899d0c13fcdf Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Tue, 22 Sep 2026 16:01:04 -0600 Subject: [PATCH 04/10] fix: make chunked token uploads survive a restart Three failures a long-running uploader hits and one thing it could not ask. Reassembly wrote straight to the final filename, so a process killed partway through the copy left a truncated file under a real media extension. Nothing sweeps that -- the glob only matched .partNNNN -- while the scheduled bulk-import picks it up within minutes and ingests it as a genuine video. It now assembles under `{checkSum}.assembling` and renames into place once the size check passes, which is atomic within a filesystem; the staging file sits in the destination directory precisely so that holds. The 202 said only "not done yet", which is indistinguishable from "I have none of your parts". A client whose set had been swept would send its remaining chunks, get 202 for each, run out, and hang forever with no error. It now reports how many parts are actually held, so a caller that has sent more than that knows to start the file over. The startup sweep removed every part file unconditionally, which meant any restart during an upload destroyed it. Parts survive a restart perfectly well on their own, so the sweep is now keyed on mtime with a 24h cut -- still collecting sets abandoned by a tool that never came back, since each part carries the mtime of the moment it was written and an upload in progress is nowhere near the threshold. `.assembling` files are swept on the same terms. Adds GET /api/upload/token/exists?video_id=. Duplicate rejection only fires once the file is on disk, so a chunked upload of something already in the library crosses the network in full before the 409. A tool that hashes its own file can now ask first. Matches _reject_duplicate's semantics: a row whose file is missing from disk does not count, because that upload is a restore. Also corrects the docs, which told tool authors to send `folder` "with whichever chunk you like". That is the one field where it breaks -- _prepare_upload derives the parts directory from it, so a chunk that omits it lands in the default folder and the completing request never finds a full set. Co-Authored-By: Claude Opus 5 --- app/server/fireshare/__init__.py | 23 +++++- app/server/fireshare/api/upload_tokens.py | 96 ++++++++++++++++++++--- docs/UploadTokens.md | 87 +++++++++++++++++--- 3 files changed, 179 insertions(+), 27 deletions(-) diff --git a/app/server/fireshare/__init__.py b/app/server/fireshare/__init__.py index d3d07f6c..80eb69c6 100644 --- a/app/server/fireshare/__init__.py +++ b/app/server/fireshare/__init__.py @@ -333,17 +333,32 @@ def create_app(init_schedule=False): if _should_cleanup: import glob as _glob + import time as _time + # Only parts that have gone stale. Sweeping unconditionally would destroy + # uploads that are merely in flight: a token client holding a partly sent + # set has no way to tell that its parts are gone, so every restart of this + # container during an upload would strand that upload at 202. An age cut + # still collects what the sweep is actually for — sets abandoned by a tool + # that never came back — because each part carries the mtime of the moment + # it was written, and an upload in progress is nowhere near the threshold. + _CHUNK_MAX_AGE_SECONDS = 24 * 60 * 60 + cutoff = _time.time() - _CHUNK_MAX_AGE_SECONDS # Images are swept too: token uploads can be chunked, and an image one # leaves its parts under the image root rather than the video root. + # '.assembling' is a chunked upload caught mid-reassembly; it never carries + # a media extension, so nothing will have scanned it. chunk_roots = [paths['video']] + ([paths['images']] if 'images' in paths else []) - chunk_files = [f for root in chunk_roots - for f in _glob.glob(str(root / '**' / '*.part[0-9][0-9][0-9][0-9]'), recursive=True)] + chunk_patterns = ('*.part[0-9][0-9][0-9][0-9]', '*.assembling') + chunk_files = [f for root in chunk_roots for pattern in chunk_patterns + for f in _glob.glob(str(root / '**' / pattern), recursive=True)] for chunk_file in chunk_files: try: + if os.path.getmtime(chunk_file) > cutoff: + continue os.remove(chunk_file) - logger.info(f"Removed leftover upload chunk: {chunk_file}") + logger.info(f"Removed stale upload chunk: {chunk_file}") except OSError as e: - logger.warning(f"Failed to remove leftover upload chunk {chunk_file}: {e}") + logger.warning(f"Failed to remove stale upload chunk {chunk_file}: {e}") # Ensure game_assets directory exists game_assets_dir = paths['data'] / 'game_assets' diff --git a/app/server/fireshare/api/upload_tokens.py b/app/server/fireshare/api/upload_tokens.py index bb96ad92..535b3251 100644 --- a/app/server/fireshare/api/upload_tokens.py +++ b/app/server/fireshare/api/upload_tokens.py @@ -43,7 +43,7 @@ from .. import permissions as P from ..constants import SUPPORTED_FILE_TYPES from ..ip_whitelist import get_client_ip -from ..models import GameMetadata, UploadToken, User +from ..models import GameMetadata, UploadToken, User, Video from . import api from .decorators import json_body, require_perm from .helpers import sanitize_upload_folder, secure_filename @@ -591,27 +591,54 @@ def token_upload_chunked(token_user): chunk_paths = [os.path.join(upload_directory, f"{check_sum}.part{i:04d}") for i in range(1, total_chunks + 1)] - if not all(os.path.exists(p) for p in chunk_paths): - return Response(status=202) - - save_path = _unique_save_path(upload_directory, plan['filename'], plan['filetype']) + held = sum(1 for p in chunk_paths if os.path.exists(p)) + if held != total_chunks: + # How many parts are really here, rather than a bare "not yet". A caller + # that believes it has sent more than this has lost its set — the startup + # sweep ran, or the media directory was cleared underneath it — and must + # start the file over instead of sending its remaining chunks into a set + # that can never complete. Without this number the upload sits at 202 + # forever with nothing to distinguish it from ordinary progress. + return jsonify({ + 'status': 'partial', + 'received': held, + 'total': total_chunks, + }), 202 + + # Reassemble under a staging name and rename into place. A crash partway + # through the copy would otherwise leave a truncated file under a real media + # extension, and the scheduled bulk-import picks such a file up within minutes + # and ingests it as a genuine video. os.rename is atomic within a filesystem, + # and the staging file sits in the destination directory precisely so that + # holds. + staging_path = os.path.join(upload_directory, f"{check_sum}.assembling") + save_path = None try: - with open(save_path, 'wb') as output_file: + with open(staging_path, 'wb') as output_file: for chunk_path in chunk_paths: with open(chunk_path, 'rb') as chunk_file: shutil.copyfileobj(chunk_file, output_file) os.remove(chunk_path) - if os.path.getsize(save_path) != file_size: - os.remove(save_path) + if os.path.getsize(staging_path) != file_size: + os.remove(staging_path) return Response(status=500, response='File size mismatch after reassembly.') + + # Claimed as late as possible, so the window in which a concurrent upload + # could take the same name is as short as it can be made. + save_path = _unique_save_path(upload_directory, plan['filename'], plan['filetype']) + os.rename(staging_path, save_path) except Exception as e: logger.warning(f"Failed to reassemble token upload {check_sum}: {e}") - for chunk_path in chunk_paths: - if os.path.exists(chunk_path): - os.remove(chunk_path) - if os.path.exists(save_path): - os.remove(save_path) + leftovers = chunk_paths + [staging_path] + if save_path: + leftovers.append(save_path) + for leftover in leftovers: + try: + if os.path.exists(leftover): + os.remove(leftover) + except OSError: + pass return Response(status=500, response='Error reassembling file.') return _finish_upload(plan, save_path, token_user) @@ -701,3 +728,46 @@ def token_upload_options(token_user): for g in games ], }) + + +@api.route('/api/upload/token/exists', methods=['GET']) +@upload_token_required +def token_upload_exists(token_user): + """Whether a video is already in the library, before anything is uploaded. + + The duplicate rejection on the upload routes only fires once the file is on + disk, which for a chunked upload means the whole thing has crossed the network + before the 409 comes back. A tool that can hash its own file locally can ask + first and skip the transfer entirely — which is what makes re-scanning a folder + that was already uploaded tolerable rather than a full re-send. + + video_id is the same identity the rest of Fireshare uses: an xxh3_128 hexdigest + of the first 16 MB of the file, as util.video_id computes it. + """ + video_id = (request.args.get('video_id') or '').strip().lower() + if not re.fullmatch(r'[0-9a-f]{32}', video_id): + return jsonify({ + 'error': 'bad_video_id', + 'message': 'video_id must be a 32-character xxh3_128 hex digest.', + }), 400 + + existing = Video.query.filter_by(video_id=video_id).first() + + # A row whose own file is missing from disk does not count, exactly as in + # _reject_duplicate: that upload is a restore, and scan-video flips the row + # back to available once it lands. + if existing: + paths = current_app.config['PATHS'] + if not (paths['video'] / existing.path).is_file(): + existing = None + + if not existing: + return jsonify({'exists': False}) + + title = existing.info.title if existing.info and existing.info.title else Path(existing.path).stem + return jsonify({ + 'exists': True, + 'video_id': video_id, + 'title': title, + 'url': f'/w/{video_id}', + }) diff --git a/docs/UploadTokens.md b/docs/UploadTokens.md index 0c98d191..dd46fadd 100644 --- a/docs/UploadTokens.md +++ b/docs/UploadTokens.md @@ -96,9 +96,18 @@ Content-Type: multipart/form-data ``` Send each chunk as its own request, in any order, using the same `checkSum` -throughout. Every request but the last answers `202`; whichever one completes the -set reassembles the file and answers `201` exactly as `/api/upload/token` does — -including the `409` when the finished video turns out to be a duplicate. +throughout. Every request but the last answers `202` with a count of the parts +held so far; whichever one completes the set reassembles the file and answers +`201` exactly as `/api/upload/token` does — including the `409` when the finished +video turns out to be a duplicate. + +```json +{ "status": "partial", "received": 18, "total": 34 } +``` + +`received` is what is on the server right now, not what you have sent. If it is +lower than the number of chunks you have had accepted, the set is gone — see +*Losing a set*, below — and the upload must start again under a fresh `checkSum`. | Field | Required | Description | | --- | --- | --- | @@ -109,17 +118,39 @@ including the `409` when the finished video turns out to be a duplicate. | `fileName` | yes | The finished file's name; its extension picks video or image. | | `fileSize` | yes | The finished file's size in bytes, verified after reassembly. | -`title`, `folder`, `game_id`, `game` and `tag_ids` work as they do for a -single-shot upload — send them with whichever chunk you like, but sending them -with every chunk is simplest, since you cannot know in advance which request will -be the one that completes the set. +**`folder` must be identical on every chunk.** It decides which directory the +parts are written into, so a chunk that names a different folder — or omits it, +and so lands in the default — leaves its part somewhere the completing request +will not look. The set then never completes and the upload sits at `202` forever. + +`title`, `game_id`, `game` and `tag_ids` are read from whichever request completes +the set. Since you cannot know in advance which one that is, send them on every +chunk as well. Pick a `checkSum` that is unique per upload; two files sharing one will have their chunks mixed together. A content hash of the file is the obvious choice, and is -where the name comes from. +where the name comes from — though if the same file may be uploaded to two folders +at once, add something to tell the two apart. + +Send one file's chunks one at a time. Two requests that both observe a complete +set will both try to reassemble it, and the one that loses finds the parts already +consumed and answers `500`. Uploading several *files* at once is fine. + +### Losing a set -If an upload is abandoned part way, the orphaned `.partNNNN` files are swept on -the next Fireshare restart. +Parts live on disk until the upload completes. They survive a restart, and are +swept only once they are a day old — so an interrupted upload can usually be +resumed by sending the chunks that were never accepted. + +Two things end a set early, and both look the same from outside: the daily sweep +catching an upload that took longer than that, and anything that clears the media +directory underneath Fireshare. Either way the `received` count in the `202` drops +below what you have sent, which is the signal to discard the `checkSum` and start +the file again. + +A `500` reading `File size mismatch after reassembly` is also a start-over rather +than something to retry a chunk against: reassembly consumes the parts as it goes, +so there is nothing left to resume from. ```bash # 8 MiB chunks, in order @@ -169,6 +200,42 @@ Every game in the library is listed, including ones with nothing linked to them yet — `/api/games` hides those, but they are exactly the games an upload might be the first to use, and the `game` field already accepts them. +## Asking before you upload + +The duplicate rejection on the upload routes only fires once the file is on disk, +which for a chunked upload means the whole thing has crossed the network before +the `409` comes back. A tool that can hash its own file first can skip the +transfer entirely: + +``` +GET /api/upload/token/exists?video_id= +``` + +`video_id` is the identity Fireshare uses everywhere else: an **xxh3_128 hexdigest +of the first 16 MB** of the file, 32 hex characters, exactly as `util.video_id` +computes it. Anything else is a `400`. + +```bash +curl "https://fireshare.example.com/api/upload/token/exists?video_id=$ID" \ + -H "Authorization: Bearer fsk_your_token_here" +``` + +```json +{ + "exists": true, + "video_id": "9f2c...", + "title": "Ace on Ascent", + "url": "/w/9f2c..." +} +``` + +A video whose file is missing from disk answers `exists: false`: that upload is a +restore, and Fireshare wants it. This only applies to videos — images are not +deduplicated. + +Worth doing before a large upload and before re-scanning a folder you may have +sent already; there is no point paying for the transfer to be told at the end. + ## Checking a token `GET /api/upload/token` with the same header validates a token without uploading From b6218e18440d972edd0783e1c81c440fabec952a Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Tue, 22 Sep 2026 19:09:54 -0600 Subject: [PATCH 05/10] Tell tools which folder each game's media belongs in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fireshare already maps folders to games: anything scanned in a folder carries that folder's game. The mapping was readable only with a session, so a tool holding an upload token could not see it — and a tool deciding where to *put* a file needs exactly that mapping, read the other way round. Filing a clip in the folder its game already owns is how it ends up tagged without the upload having to name a game at all. Added to /api/upload/token/options rather than given a route of its own: that endpoint already answers "what may I choose", folder rules are the same kind of answer, and a caller that wants them wants the folder list in the same breath. Image folder rules are listed separately, matching how the folders themselves are. Rules whose game has gone are left out, since there is nothing a caller could do with one. --- app/server/fireshare/api/upload_tokens.py | 19 ++++++++++++++++++- docs/UploadTokens.md | 20 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/server/fireshare/api/upload_tokens.py b/app/server/fireshare/api/upload_tokens.py index 535b3251..a7d0b62b 100644 --- a/app/server/fireshare/api/upload_tokens.py +++ b/app/server/fireshare/api/upload_tokens.py @@ -43,7 +43,7 @@ from .. import permissions as P from ..constants import SUPPORTED_FILE_TYPES from ..ip_whitelist import get_client_ip -from ..models import GameMetadata, UploadToken, User, Video +from ..models import FolderRule, GameMetadata, ImageFolderRule, UploadToken, User, Video from . import api from .decorators import json_body, require_perm from .helpers import sanitize_upload_folder, secure_filename @@ -717,12 +717,29 @@ def token_upload_options(token_user): games = GameMetadata.query.order_by(GameMetadata.name).all() + # Which folder each game's media lives in. Fireshare reads these the other + # way round -- anything scanned in a folder is tagged with that folder's + # game -- but a tool deciding where to *put* an upload needs the same + # mapping, and filing a clip in the folder its game already owns is how it + # ends up tagged without the tool having to ask for a tag at all. Rules + # without a game are left out: they describe nothing a caller can act on. + def rules_json(rules): + return [ + {'folder': r.folder_path, 'game_id': r.game_id, 'game': r.game.name} + for r in rules + if r.game + ] + return jsonify({ 'default_folder': default_folder, 'folders': { 'video': video_folders, 'image': image_folders, }, + 'folder_rules': { + 'video': rules_json(FolderRule.query.all()), + 'image': rules_json(ImageFolderRule.query.all()), + }, 'games': [ {'id': g.id, 'name': g.name, 'steamgriddb_id': g.steamgriddb_id} for g in games diff --git a/docs/UploadTokens.md b/docs/UploadTokens.md index dd46fadd..43653047 100644 --- a/docs/UploadTokens.md +++ b/docs/UploadTokens.md @@ -200,6 +200,26 @@ Every game in the library is listed, including ones with nothing linked to them yet — `/api/games` hides those, but they are exactly the games an upload might be the first to use, and the `game` field already accepts them. +### Folder rules + +`folder_rules` is the folder-to-game mapping Fireshare uses when scanning: media +found in a listed folder is tagged with that folder's game. + +```json +{ + "folder_rules": { + "video": [{ "folder": "valorant", "game_id": 3, "game": "VALORANT" }], + "image": [{ "folder": "screenshots", "game_id": 3, "game": "VALORANT" }] + } +} +``` + +A tool deciding where to put an upload can read it the other way round: send a +clip to the folder its game already owns and Fireshare tags it on the way in, +without the upload having to name a game at all. Rules pointing at a game that +no longer exists are left out, since there is nothing a caller could do with +them. + ## Asking before you upload The duplicate rejection on the upload routes only fires once the file is on disk, From 55e670f80726bad9464fe841d288c8680dfd45df Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Tue, 22 Sep 2026 20:12:17 -0600 Subject: [PATCH 06/10] fix: give Videos a path of its own so its sidebar link stops redirecting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Videos page had no route of its own: it lived at "/", which is also the landing route that forwards a visitor to whichever page sits at the top of the sidebar. Home ships at the top of that order, so the sidebar's Videos link pointed at "/" and was immediately bounced to /home. There was no way to reach Videos from the sidebar at all. Videos now lives at /videos and "/" is purely the landing redirect, so a link to a page never passes back through the question of which page comes first. LandingRoute loses its children along with the special case that rendered them. The fallback landingHref returns when every page has been hidden moves from "/" to /videos for the same reason: pointing the landing redirect at itself would now loop. "/" keeps forwarding with the query string attached, so existing links and bookmarks still land somewhere sensible. The two navigate('/') calls left — the logo and the redirect after login — mean "go to the landing page" and are unchanged. --- app/client/src/App.js | 31 ++++++++++--------- app/client/src/common/sidebarPages.js | 9 +++--- app/client/src/components/nav/MainNavbar.js | 2 +- .../src/components/utils/LandingRoute.js | 13 ++++---- app/client/src/views/Profile.js | 2 +- 5 files changed, 30 insertions(+), 27 deletions(-) diff --git a/app/client/src/App.js b/app/client/src/App.js index 9f4196eb..ac6c3fb2 100644 --- a/app/client/src/App.js +++ b/app/client/src/App.js @@ -50,22 +50,25 @@ export default function App() { + {/* "/" is not a page of its own: it forwards to whichever page the + administrator put at the top of the sidebar. The Videos page it + used to hold now lives at "/videos" so that the sidebar can link + to it without passing back through that decision. */} + } /> - - - - - - + + + + + } /> - From ebd8bea5473eb65e6a64b3f9a6c4e57e8030c685 Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Tue, 22 Sep 2026 21:05:37 -0600 Subject: [PATCH 07/10] Let a tool ask about an image before uploading it, too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /api/upload/token/exists took only video_id. Images needed it more: a video at least gets a 409 once its bytes are on disk, but an image is never rejected at all — the upload is accepted and scan_image quietly folds it into the existing row, so a client pays for the whole transfer and is told it succeeded. Pass exactly one of video_id or image_id; both or neither is a 400. An image_id on an instance with images turned off is a 503. The video response shape is unchanged. The docs claimed images were not deduplicated. They are — scan_image has always matched on image_id — it just never said so to the caller. --- app/server/fireshare/api/upload_tokens.py | 70 ++++++++++++++++------- docs/UploadTokens.md | 33 +++++++---- 2 files changed, 71 insertions(+), 32 deletions(-) diff --git a/app/server/fireshare/api/upload_tokens.py b/app/server/fireshare/api/upload_tokens.py index a7d0b62b..9fc7d8b8 100644 --- a/app/server/fireshare/api/upload_tokens.py +++ b/app/server/fireshare/api/upload_tokens.py @@ -43,7 +43,8 @@ from .. import permissions as P from ..constants import SUPPORTED_FILE_TYPES from ..ip_whitelist import get_client_ip -from ..models import FolderRule, GameMetadata, ImageFolderRule, UploadToken, User, Video +from ..models import (FolderRule, GameMetadata, Image, ImageFolderRule, UploadToken, User, + Video) from . import api from .decorators import json_body, require_perm from .helpers import sanitize_upload_folder, secure_filename @@ -750,33 +751,62 @@ def rules_json(rules): @api.route('/api/upload/token/exists', methods=['GET']) @upload_token_required def token_upload_exists(token_user): - """Whether a video is already in the library, before anything is uploaded. + """Whether a file is already in the library, before anything is uploaded. - The duplicate rejection on the upload routes only fires once the file is on - disk, which for a chunked upload means the whole thing has crossed the network - before the 409 comes back. A tool that can hash its own file locally can ask - first and skip the transfer entirely — which is what makes re-scanning a folder - that was already uploaded tolerable rather than a full re-send. + Pass exactly one of `video_id` or `image_id`. Both are the same identity the + rest of Fireshare uses: an xxh3_128 hexdigest of the first 16 MB of the file, + as `util.video_id` and `util.image_id` compute it — so a tool that can hash + its own file locally can ask first and skip the transfer entirely. - video_id is the same identity the rest of Fireshare uses: an xxh3_128 hexdigest - of the first 16 MB of the file, as util.video_id computes it. + That saving is worth more than it looks, and differs by media type: + + * For a video, the duplicate rejection on the upload routes only fires once + the file is on disk, which for a chunked upload means the whole thing has + crossed the network before the 409 comes back. + * For an image there is no rejection at all. The upload is accepted and the + scan quietly folds it into the existing row, so the client is never told — + it simply pays for the transfer and sees a success. + + Either way, re-scanning a folder that was already uploaded should not mean + re-sending it. """ video_id = (request.args.get('video_id') or '').strip().lower() - if not re.fullmatch(r'[0-9a-f]{32}', video_id): + image_id = (request.args.get('image_id') or '').strip().lower() + + if bool(video_id) == bool(image_id): return jsonify({ - 'error': 'bad_video_id', - 'message': 'video_id must be a 32-character xxh3_128 hex digest.', + 'error': 'bad_request', + 'message': 'Pass exactly one of video_id or image_id.', }), 400 - existing = Video.query.filter_by(video_id=video_id).first() + kind = 'video' if video_id else 'image' + media_id = video_id or image_id + if not re.fullmatch(r'[0-9a-f]{32}', media_id): + return jsonify({ + 'error': f'bad_{kind}_id', + 'message': f'{kind}_id must be a 32-character xxh3_128 hex digest.', + }), 400 + + if kind == 'video': + existing = Video.query.filter_by(video_id=media_id).first() + root = current_app.config['PATHS']['video'] + viewer = 'w' + else: + image_directory = current_app.config.get('IMAGE_DIRECTORY') + if not image_directory: + return jsonify({ + 'error': 'images_disabled', + 'message': 'This instance is not configured for images.', + }), 503 + existing = Image.query.filter_by(image_id=media_id).first() + root = Path(image_directory) + viewer = 'i' # A row whose own file is missing from disk does not count, exactly as in - # _reject_duplicate: that upload is a restore, and scan-video flips the row + # _reject_duplicate: that upload is a restore, and the scan flips the row # back to available once it lands. - if existing: - paths = current_app.config['PATHS'] - if not (paths['video'] / existing.path).is_file(): - existing = None + if existing and not (root / existing.path).is_file(): + existing = None if not existing: return jsonify({'exists': False}) @@ -784,7 +814,7 @@ def token_upload_exists(token_user): title = existing.info.title if existing.info and existing.info.title else Path(existing.path).stem return jsonify({ 'exists': True, - 'video_id': video_id, + f'{kind}_id': media_id, 'title': title, - 'url': f'/w/{video_id}', + 'url': f'/{viewer}/{media_id}', }) diff --git a/docs/UploadTokens.md b/docs/UploadTokens.md index 43653047..4397708e 100644 --- a/docs/UploadTokens.md +++ b/docs/UploadTokens.md @@ -222,18 +222,26 @@ them. ## Asking before you upload -The duplicate rejection on the upload routes only fires once the file is on disk, -which for a chunked upload means the whole thing has crossed the network before -the `409` comes back. A tool that can hash its own file first can skip the -transfer entirely: +A tool that can hash its own file first can skip the transfer entirely: ``` GET /api/upload/token/exists?video_id= +GET /api/upload/token/exists?image_id= ``` -`video_id` is the identity Fireshare uses everywhere else: an **xxh3_128 hexdigest -of the first 16 MB** of the file, 32 hex characters, exactly as `util.video_id` -computes it. Anything else is a `400`. +Pass exactly one of the two; passing both or neither is a `400`. Either id is the +identity Fireshare uses everywhere else: an **xxh3_128 hexdigest of the first +16 MB** of the file, 32 hex characters, exactly as `util.video_id` and +`util.image_id` compute it. Anything else is a `400`. + +This is worth more than it looks, and for different reasons per media type: + +* **Videos** are rejected on upload, but only once the file is on disk — which + for a chunked upload means the whole thing has crossed the network before the + `409` comes back. +* **Images** are never rejected. The upload is accepted and the scan folds it + into the existing row, so a caller that does not ask first pays for the + transfer and is told it succeeded. Asking is the only way to know. ```bash curl "https://fireshare.example.com/api/upload/token/exists?video_id=$ID" \ @@ -249,12 +257,13 @@ curl "https://fireshare.example.com/api/upload/token/exists?video_id=$ID" \ } ``` -A video whose file is missing from disk answers `exists: false`: that upload is a -restore, and Fireshare wants it. This only applies to videos — images are not -deduplicated. +An image answers the same shape with `image_id` and an `/i/` url. Either kind +answers `{"exists": false}` when nothing matches, and also when the row exists but +its file is missing from disk: that upload is a restore, and Fireshare wants it. +Asking for an `image_id` on an instance with images turned off is a `503`. -Worth doing before a large upload and before re-scanning a folder you may have -sent already; there is no point paying for the transfer to be told at the end. +Worth doing before every upload, not just a large one, and especially before +re-scanning a folder you may have sent already. ## Checking a token From 8742cb2b5619391792ca15128b36247638f54ea4 Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Tue, 22 Sep 2026 21:33:06 -0600 Subject: [PATCH 08/10] feat: point people at Firesync from the Upload Tokens pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A token on its own does nothing: someone who wants their clips uploaded automatically still has to go and write the thing that does the uploading, and writing it yourself reads as the only option when nothing says otherwise. Firesync is that thing, already built, and this is the page a person is looking at when the question occurs to them. The card sits between the explanation and the token list rather than below it, so it is also there for the account that has no tokens yet — the one most likely to be deciding how to approach this at all. It links to the releases page, since what they want from here is the download. Trimmed the pane's opening copy while nearby. The permission semantics it spelled out are in docs/UploadTokens.md, which the paragraph already links to, and saying it twice cost more attention than it earned. --- .../src/components/settings/UploadTokens.js | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/app/client/src/components/settings/UploadTokens.js b/app/client/src/components/settings/UploadTokens.js index b680726c..81be4dd8 100644 --- a/app/client/src/components/settings/UploadTokens.js +++ b/app/client/src/components/settings/UploadTokens.js @@ -15,6 +15,8 @@ import { Typography, } from '@mui/material' import TerminalIcon from '@mui/icons-material/Terminal' +import CloudSyncIcon from '@mui/icons-material/CloudSync' +import OpenInNewIcon from '@mui/icons-material/OpenInNew' import ContentCopyIcon from '@mui/icons-material/ContentCopy' import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline' import AutorenewIcon from '@mui/icons-material/Autorenew' @@ -25,6 +27,7 @@ import { dialogPaperSx, dialogTitleSx, inputSx, helperTextSx, rowBoxSx } from '. const NAME_MAX = 64 const DOCS_URL = 'https://github.com/fireshare-app/fireshare/blob/main/docs/UploadTokens.md' +const FIRESYNC_URL = 'https://github.com/fireshare-app/firesync/releases' // Matches the external links in the Settings panes. const docsLinkStyle = { color: '#2684FF', textDecoration: 'none' } @@ -130,15 +133,49 @@ const UploadTokens = () => { - Let a script or another tool upload videos and images to Fireshare on your behalf, without your password. - Uploads made with a token are credited to you and obey the permissions you hold right now — if your upload - access is removed, every token stops working with it.{' '} + Let a script or another tool upload videos and images on your behalf, without your password.{' '} Read the documentation - {' '} - for the full list of upload options. + + . + {/* A token is only half of what someone wanting automatic uploads needs, and + writing the other half yourself is the assumed default until told + otherwise. Said here because this is the page they are on when the + question occurs to them. */} + + + + + Firesync + + + Rather not write the script yourself? Firesync is a companion app for Windows and Linux that watches + folders on your machine and uploads new clips and screenshots here on its own, with per-folder rules for + where they land. It signs in with a token from this page, never your password. + + + Download Firesync + + + + + {tokens === null ? ( ) : ( From b03f5d004ea434044a4658559972967dc1f56526 Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Wed, 23 Sep 2026 00:10:33 -0600 Subject: [PATCH 09/10] fix: capture the video id for derived assets so nginx stops warning The /_content/derived/ location ran auth_request without capturing an id from its regex, so $video_id was unset when the auth subrequest built its X-Fireshare-Video-Id header. Every derived asset request logged "using uninitialized video_id" and the gate fell back to re-parsing X-Original-URI. Capture the first path segment like the video locations already do, so the gate checks the same id nginx serves the file from. Also initialise the variable in the dev config's prefix location, which had the same warning. --- app/nginx/dev.template.conf | 4 ++++ app/nginx/prod.conf | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/nginx/dev.template.conf b/app/nginx/dev.template.conf index 1383ade8..858dbae5 100644 --- a/app/nginx/dev.template.conf +++ b/app/nginx/dev.template.conf @@ -51,6 +51,10 @@ http { } location /_content/video/ { + # This prefix location has no regex to capture an id from, so initialise the + # variable the auth subrequest reads; otherwise nginx warns about an + # uninitialized "video_id" on every request. + set $video_id ""; auth_request /internal/video-auth; alias /processed/video_links/; try_files $uri =404; diff --git a/app/nginx/prod.conf b/app/nginx/prod.conf index 0bffcc28..cc5f19ce 100644 --- a/app/nginx/prod.conf +++ b/app/nginx/prod.conf @@ -124,7 +124,12 @@ http { try_files /derived/$video_id/custom_poster.webp /derived/$video_id/poster.jpg =404; } - location ~ ^/_content/derived/ { + # The video id is the first path segment under /derived/. Capturing it here gives + # the auth subrequest the same id this location serves from, and initialises + # $video_id for it: without the capture every derived asset logged an + # "uninitialized video_id" warning and the gate had to re-parse the URI. + location ~ ^/_content/derived/([^/]+) { + set $video_id $1; auth_request /internal/video-auth; rewrite ^/_content/(.*)$ /$1 break; root /processed/; From 99357e4a25037dbd59ce5770da67a7ecba5791a7 Mon Sep 17 00:00:00 2001 From: Shane Israel Date: Wed, 23 Sep 2026 00:29:54 -0600 Subject: [PATCH 10/10] chore: bump version to 1.8.3 --- app/client/package-lock.json | 4 ++-- app/client/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/client/package-lock.json b/app/client/package-lock.json index b83da6c3..7b825519 100644 --- a/app/client/package-lock.json +++ b/app/client/package-lock.json @@ -1,12 +1,12 @@ { "name": "fireshare", - "version": "1.8.2", + "version": "1.8.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fireshare", - "version": "1.8.2", + "version": "1.8.3", "dependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", diff --git a/app/client/package.json b/app/client/package.json index 14fb5811..5cd0f99c 100644 --- a/app/client/package.json +++ b/app/client/package.json @@ -1,6 +1,6 @@ { "name": "fireshare", - "version": "1.8.2", + "version": "1.8.3", "private": true, "dependencies": { "@emotion/react": "^11.9.0",