Files
platform/src/servers/api/server-settings/ocr.ts
T
pastilhasandClaude Opus 5 e54d71da71 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>
2026-08-06 13:38:49 +00:00

72 lines
2.5 KiB
TypeScript

import { createRouter } from '../../create-router';
import { readServerSettings, writeServerSettings } from 'officerdb';
type OcrConfig = {
url: string;
model: string;
};
export async function readOcrConfig(): Promise<OcrConfig | undefined> {
const settings = await readServerSettings();
return settings.ocr as OcrConfig | undefined;
}
export const ocrRouter = createRouter();
ocrRouter.get('/', async (ctx) => {
const ocr = await readOcrConfig();
if (!ocr) return ctx.json(null);
return ctx.json(ocr);
});
ocrRouter.put('/', async (ctx) => {
const body = await ctx.req.json<OcrConfig>();
const settings = await readServerSettings();
settings.ocr = body;
await writeServerSettings(settings);
return ctx.json({ success: true });
});
// 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 {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(`${body.url.replace(/\/+$/, '')}/v1/models`, {
signal: controller.signal,
});
clearTimeout(timeout);
if (!res.ok) return ctx.json({ error: `Server returned ${res.status}` }, 500);
const json = (await res.json()) as { data?: { id: string }[] };
const models = (json.data ?? []).map((m) => m.id);
return ctx.json({ models });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return ctx.json({ error: message }, 500);
}
});
ocrRouter.post('/test', async (ctx) => {
const body = await ctx.req.json<OcrConfig>();
if (!body.url) return ctx.json({ error: 'URL required' }, 400);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const res = await fetch(`${body.url.replace(/\/+$/, '')}/v1/models`, {
signal: controller.signal,
});
clearTimeout(timeout);
if (!res.ok) return ctx.json({ error: `Server returned ${res.status}` }, 500);
return ctx.json({ success: true });
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return ctx.json({ error: message }, 500);
}
});