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:
@@ -282,7 +282,7 @@ export type AddServerInput = { label: string; url: string; username: string; pas
|
||||
export type EditServerInput = { id: number; label?: string; url?: string; username?: string; password?: string };
|
||||
|
||||
export function useJellyfinServerActions() {
|
||||
const { post, patch, delete: del } = useClient();
|
||||
const { get, post, patch, delete: del } = useClient();
|
||||
const qc = useQueryClient();
|
||||
// Adding, editing, switching and removing all change what every other query here can even answer — a switch
|
||||
// in particular changes the answer to all of them without changing any of their inputs.
|
||||
@@ -309,11 +309,12 @@ export function useJellyfinServerActions() {
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
// GET — probes one server and reports whether it answered. It does not switch to it (that is
|
||||
// `activate`) and writes nothing. useMutation is still right: it is fired by a button, not rendered.
|
||||
const test = useMutation({
|
||||
mutationFn: (id: number) =>
|
||||
post<{ ok: boolean; version?: string | null; serverName?: string | null; ms: number }>(
|
||||
get<{ ok: boolean; version?: string | null; serverName?: string | null; ms: number }>(
|
||||
`/jellyfin/_config/${id}/test`,
|
||||
{},
|
||||
),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
@@ -197,11 +197,14 @@ export function useTorrentMutations() {
|
||||
}
|
||||
|
||||
export function useMaintenance() {
|
||||
const { post } = useClient();
|
||||
const { get, post } = useClient();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const portTest = useMutation({
|
||||
mutationFn: () => post<{ open: boolean }>('/transmission/_officer/port-test'),
|
||||
// GET — it asks the daemon a question and changes nothing on either side. Still a useMutation
|
||||
// because it is fired by a button rather than rendered from cache: that is a UI concern, not an
|
||||
// HTTP one. blocklistUpdate below stays POST, because it really does refetch and replace the list.
|
||||
mutationFn: () => get<{ open: boolean }>('/transmission/_officer/port-test'),
|
||||
onSuccess: (data) => (data.open ? toast.success('Peer port is open') : toast.error('Peer port is closed')),
|
||||
onError: (err) => toast.error(errorMessage(err, 'Port test failed')),
|
||||
});
|
||||
|
||||
@@ -55,11 +55,14 @@ export const useFilesAPI = (root: string = 'home') => {
|
||||
client.get<DownloadVideoStatus>(withRoot(`/file-browser/download-video/${jobId}`)),
|
||||
|
||||
// Prefetch one video's metadata (ReClip /api/info via the platform proxy). Returns { error } inline.
|
||||
videoInfo: (url: string) => client.post<VideoInfo>(withRoot('/file-browser/video-info'), { url }),
|
||||
videoInfo: (url: string) =>
|
||||
client.get<VideoInfo>(withRoot(`/file-browser/video-info?url=${encodeURIComponent(url)}`)),
|
||||
|
||||
// Expand a playlist URL into its individual video URLs (ReClip /api/playlist).
|
||||
videoPlaylist: (url: string) =>
|
||||
client.post<{ urls?: string[]; error?: string }>(withRoot('/file-browser/video-playlist'), { url }),
|
||||
client.get<{ urls?: string[]; error?: string }>(
|
||||
withRoot(`/file-browser/video-playlist?url=${encodeURIComponent(url)}`),
|
||||
),
|
||||
|
||||
tts: (path: string, opts?: { saveNextTo?: boolean }) =>
|
||||
client.post<{ audioPath: string; audioRoot: string }>('/file-browser/tts', { path, root, ...opts }),
|
||||
|
||||
Reference in New Issue
Block a user