api: five reads that were declared as writes are now GET
The permission model being built reads the HTTP method to decide whether a
non-owner may make a call: safe methods are reads, everything else is a write.
That only works if the method tells the truth. These five read something and
returned it while announcing themselves as writes, so a member would have been
denied a read they are entitled to because of a habit in how the route was
declared.
/api/file-browser/video-info POST {url} -> GET ?url=
/api/file-browser/video-playlist POST {url} -> GET ?url=
/api/server-settings/ocr/models POST {url} -> GET ?url=
/api/transmission/_officer/port-test POST -> GET
/api/jellyfin/_config/:id/test POST|GET -> GET only
The last one already answered to both, which is worse than either: a method that
means nothing cannot be the thing authorisation reads.
Deliberately stops at five. A sweep of all 100 mutating routes found many more
reads wearing POST, and they are staying, for two reasons that are not going
away: some need a request body GET cannot carry (/stt takes multipart audio;
/tts, /ocr, /transcribe take payloads), and some carry a credential, where a
query string is the wrong place — access logs, shell history and Referer headers
all capture those, request bodies do not (/tts/voices takes an apiKey, the four
/test endpoints take connection secrets, /local-providers/probe takes auth).
So the method alone can never carry the permission model, and the registry will
need an explicit per-route classification regardless. Converting these five is
worth it because it is free; converting the rest would be a breaking change
across 117 mobile call sites that buys nothing.
Web callers updated in the same commit; the sidecar contract comments now match.
Mobile has exactly one caller to change — transmissionPortTest in
packages/core/src/services/transmission.ts — and no shim was added, because an
endpoint answering to both methods is the problem this commit exists to fix.
docs/api-method-changes-2026-08-06.md is the handoff for the mobile team: what
changed, the one line to edit, what deliberately did NOT change and why, and how
to verify.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1331,8 +1331,10 @@ router.get('/download-video/:jobId', (ctx) => {
|
||||
// Prefetch a single video's metadata (title / thumbnail / duration / uploader) — proxied straight to
|
||||
// ReClip's /api/info so the download dialog can show a preview card before committing. Errors (private
|
||||
// video, timeout, …) come back as { error } with a 200 so the client can render them inline.
|
||||
router.post('/video-info', async (ctx) => {
|
||||
const { url } = ctx.get('body') as { url?: string };
|
||||
// GET, not POST: it reads and writes nothing. The method is not cosmetic — the permission model reads
|
||||
// it to decide whether a non-owner may call this, and a read filed as a write is a read they lose.
|
||||
router.get('/video-info', async (ctx) => {
|
||||
const url = ctx.req.query('url');
|
||||
if (!url) throw errors.BAD_REQUEST('url is required');
|
||||
const res = await fetch(`${RECLIP_BASE}/api/info`, {
|
||||
method: 'POST',
|
||||
@@ -1347,8 +1349,9 @@ router.post('/video-info', async (ctx) => {
|
||||
|
||||
// Expand a playlist URL into its individual video URLs (ReClip's /api/playlist → { urls }). The client
|
||||
// then prefetches /video-info per url to build the per-entry cards.
|
||||
router.post('/video-playlist', async (ctx) => {
|
||||
const { url } = ctx.get('body') as { url?: string };
|
||||
// GET for the same reason as /video-info above.
|
||||
router.get('/video-playlist', async (ctx) => {
|
||||
const url = ctx.req.query('url');
|
||||
if (!url) throw errors.BAD_REQUEST('url is required');
|
||||
const res = await fetch(`${RECLIP_BASE}/api/playlist`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -27,8 +27,11 @@ ocrRouter.put('/', async (ctx) => {
|
||||
return ctx.json({ success: true });
|
||||
});
|
||||
|
||||
ocrRouter.post('/models', async (ctx) => {
|
||||
const body = await ctx.req.json<{ url: string }>();
|
||||
// GET: it asks a provider what models it has and returns the answer. Nothing is written, and the only
|
||||
// input is a URL — no credential, so a query string is the right place for it. Kept POST until
|
||||
// 2026-08-06 purely by habit, and the permission model reads the method.
|
||||
ocrRouter.get('/models', async (ctx) => {
|
||||
const body = { url: ctx.req.query('url') ?? '' };
|
||||
if (!body.url) return ctx.json({ error: 'URL required' }, 400);
|
||||
|
||||
try {
|
||||
|
||||
@@ -226,8 +226,11 @@ export async function handleConfigRoute(req: Request, userId: number, subpath: s
|
||||
return serverList(userId);
|
||||
}
|
||||
|
||||
// GET only. It probes one server and reports whether it answered — nothing is written, and nothing is
|
||||
// switched (that is `activate`, below). It accepted POST as well until 2026-08-06, which made the
|
||||
// method meaningless for deciding whether this is a read; the permission model needs that answer.
|
||||
if (action === 'test') {
|
||||
if (req.method !== 'POST' && req.method !== 'GET') return bad('method not allowed', 405);
|
||||
if (req.method !== 'GET') return bad('method not allowed', 405);
|
||||
return testServer(userId, id);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { getConfig, probe } from './upstream';
|
||||
// for an access token and never stored
|
||||
// PATCH /_config/:id same fields, all optional; no password keeps the stored token
|
||||
// POST /_config/:id/activate switch to that server
|
||||
// POST /_config/:id/test probe one server without switching to it
|
||||
// GET /_config/:id/test probe one server without switching to it
|
||||
// DEL /_config/:id remove it; the newest survivor is promoted if it was active
|
||||
//
|
||||
// GET /_officer/home views + resume + next-up + latest-per-view, one round trip
|
||||
|
||||
@@ -31,7 +31,7 @@ import { getTransmissionConfig } from './upstream';
|
||||
// POST /_officer/torrents/rename {id, path, name}
|
||||
// POST /_officer/torrents/remove {ids, deleteLocalData}
|
||||
// GET /_officer/free-space?path= free space at a path
|
||||
// POST /_officer/port-test is the peer port reachable from outside
|
||||
// GET /_officer/port-test is the peer port reachable from outside
|
||||
// POST /_officer/blocklist-update refresh the blocklist, returns the new size
|
||||
// anything else 404
|
||||
//
|
||||
|
||||
@@ -198,8 +198,11 @@ async function handleFreeSpace(ctx: OfficerContext): Promise<Response> {
|
||||
return Response.json({ path: result.path, bytes: result['size-bytes'] });
|
||||
}
|
||||
|
||||
// GET: it asks the daemon whether the peer port is reachable and returns the answer. Nothing changes,
|
||||
// on this side or Transmission's. Blocklist-update below stays POST because it genuinely refetches and
|
||||
// replaces the list — the two look alike and are not.
|
||||
async function handlePortTest(ctx: OfficerContext): Promise<Response> {
|
||||
if (ctx.req.method !== 'POST') return methodNotAllowed();
|
||||
if (ctx.req.method !== 'GET') return methodNotAllowed();
|
||||
const result = await rpc<{ 'port-is-open': boolean }>(ctx.userId, 'port-test');
|
||||
return Response.json({ open: result['port-is-open'] });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user