Wanted shape: the owner signs in with GitHub, everyone invited later signs in with Google. That was previously a convention, not a boundary -- may_login checked only that a live row existed, so any enabled provider could satisfy any invite. Now an invite is bound to the provider it was issued for. friend@gmail.com invited for Google cannot be redeemed over GitHub, and there is never a second, weaker door onto an existing account. The per-request re-check still passes provider=None, because there the question is only whether the account is still live. Two related fixes fell out: - upsert_user no longer refreshes `provider` from the login. It is part of the identity an admin issued and may_login now enforces it, so overwriting it on sign-in would let a successful login silently rewrite the very rule that permitted it. - a Google invite must be a full email address. A bare username would authorise something Google can never return, and the invitee would see only "not authorised" with no way to work out why. INVITE EMAILS. Adding a user now mails them, when SMTP is configured and there is an address -- for Google there always is, since the identity IS one. The response reports whether the mail actually went and why not, because "Invited" alone reads as success while the person waits for an email nobody sent. The link carries NO token, deliberately. The row already names the exact identity permitted and Google will not hand us an unverified address, so possession of the mail is not what authorises anyone: a forwarded invite grants nothing, nothing expires, and resend is always safe. A signed expiring token would only buy the ability to withdraw an unused invitation, which deleting the row already does. Resend is offered in the panel on anyone who has an address and has never signed in -- the likeliest reason being that they missed it. tests/test_invites.py: 29 assertions driving a real SMTP conversation against a sink server, plus the full provider-binding matrix. 119 tests pass across four suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
345 lines
9.3 KiB
TypeScript
345 lines
9.3 KiB
TypeScript
/** Types mirror what app.py's track_row() and endpoints actually return. */
|
|
|
|
export type Track = {
|
|
deezer_id: number;
|
|
title: string;
|
|
artist: string;
|
|
artist_id: number | null;
|
|
artist_pic: string | null;
|
|
album: string;
|
|
album_id: number | null;
|
|
cover: string | null;
|
|
cover_big: string | null;
|
|
length: number;
|
|
preview: string | null;
|
|
explicit: boolean;
|
|
isrc: string | null;
|
|
rank: number;
|
|
year: string | null;
|
|
genre: string | null;
|
|
track: number | null;
|
|
disc?: number | null;
|
|
};
|
|
|
|
export type AlbumHeader = {
|
|
album_id: number;
|
|
title: string;
|
|
artist: string;
|
|
artist_id: number | null;
|
|
cover: string | null;
|
|
cover_big: string | null;
|
|
year: string | null;
|
|
genres: string[];
|
|
label: string | null;
|
|
nb_tracks: number | null;
|
|
record_type: string | null;
|
|
duration: number | null;
|
|
};
|
|
|
|
export type ArtistHeader = {
|
|
artist_id: number;
|
|
name: string;
|
|
picture: string | null;
|
|
nb_fan: number | null;
|
|
nb_album: number | null;
|
|
};
|
|
|
|
export type AlbumCard = {
|
|
album_id: number;
|
|
title: string;
|
|
cover: string | null;
|
|
cover_big: string | null;
|
|
year: string | null;
|
|
record_type: string | null;
|
|
nb_tracks: number | null;
|
|
};
|
|
|
|
export type Suggestions = {
|
|
tracks: {
|
|
deezer_id: number;
|
|
title: string;
|
|
artist: string;
|
|
cover: string | null;
|
|
preview: string | null;
|
|
}[];
|
|
albums: {
|
|
album_id: number;
|
|
title: string;
|
|
artist: string;
|
|
cover: string | null;
|
|
nb_tracks: number | null;
|
|
}[];
|
|
artists: {
|
|
artist_id: number;
|
|
name: string;
|
|
cover: string | null;
|
|
nb_album: number | null;
|
|
}[];
|
|
};
|
|
|
|
export type ReviewItem = {
|
|
id: string;
|
|
artist: string;
|
|
title: string;
|
|
album: string | null;
|
|
year: string | null;
|
|
cover: string | null;
|
|
/** 0..1. Below the auto threshold, which is why this is here at all. */
|
|
confidence: number | null;
|
|
/** Human-readable reasons behind the score, semicolon-joined. */
|
|
why: string | null;
|
|
/** Basename of the file sitting in downloads, awaiting a decision. */
|
|
file: string;
|
|
path: string | null;
|
|
size: number | null;
|
|
requested_by: string | null;
|
|
created_at: string;
|
|
};
|
|
|
|
export type Discover = {
|
|
seeds: string[];
|
|
artists: {
|
|
id: number;
|
|
name: string;
|
|
picture: string | null;
|
|
fans: number | null;
|
|
}[];
|
|
};
|
|
|
|
export type Recap = {
|
|
days: number;
|
|
tracks: number;
|
|
bytes: number;
|
|
artists: { name: string; n: number }[];
|
|
genres: { name: string; n: number }[];
|
|
decades: { decade: string; n: number }[];
|
|
busiest_day: { date: string; n: number } | null;
|
|
};
|
|
|
|
export type Job = {
|
|
id: string;
|
|
state: "queued" | "searching" | "downloading" | "review" | "imported" | "failed";
|
|
detail: string | null;
|
|
artist: string;
|
|
title: string;
|
|
album: string | null;
|
|
year: string | null;
|
|
cover: string | null;
|
|
genre: string | null;
|
|
duration: number | null;
|
|
requested_by: string | null;
|
|
created_at: string;
|
|
};
|
|
|
|
export type Usage = { requests: number; storage: number };
|
|
|
|
export type Me = {
|
|
user: string | null;
|
|
auth: string;
|
|
role?: string | null;
|
|
display?: string | null;
|
|
avatar?: string | null;
|
|
provider?: string;
|
|
quota?: number;
|
|
storage_quota?: number;
|
|
usage?: Usage;
|
|
providers?: string[];
|
|
email?: string | null;
|
|
notify?: boolean;
|
|
/** SMTP is configured server-side; without it the settings are inert. */
|
|
mail_configured?: boolean;
|
|
};
|
|
|
|
export type AdminUser = {
|
|
username: string;
|
|
provider: string;
|
|
role: string;
|
|
display: string | null;
|
|
avatar: string | null;
|
|
quota: number;
|
|
storage_quota: number;
|
|
disabled: boolean;
|
|
created_at: string;
|
|
last_seen: string | null;
|
|
invited_by: string | null;
|
|
usage: Usage;
|
|
email: string | null;
|
|
notify: boolean;
|
|
/** In MUSE_ALLOWED_USERS — cannot actually be locked out by disabling. */
|
|
bootstrap: boolean;
|
|
};
|
|
|
|
export type DiskStat = { total: number; used: number; free: number; pct: number | null };
|
|
|
|
export type SystemStats = {
|
|
system: {
|
|
cpu_pct: number | null;
|
|
cpus: number | null;
|
|
mem_total: number | null;
|
|
mem_used: number | null;
|
|
mem_pct: number | null;
|
|
load: number[] | null;
|
|
disks: Record<string, DiskStat | null>;
|
|
};
|
|
workers: number;
|
|
pending: number;
|
|
jobs: Record<string, number>;
|
|
library: { tracks: number; bytes: number };
|
|
users: number;
|
|
sessions: number;
|
|
slskd: {
|
|
ok: boolean;
|
|
connected?: boolean;
|
|
logged_in?: boolean;
|
|
version?: string;
|
|
shared_files?: number | null;
|
|
error?: string;
|
|
};
|
|
};
|
|
|
|
export type Activity = {
|
|
days: number;
|
|
series: { date: string; n: number; imported: number; failed: number }[];
|
|
per_user: { u: string; n: number; bytes: number }[];
|
|
top_artists: { artist: string; n: number }[];
|
|
};
|
|
|
|
/**
|
|
* Every response goes through here so a 401 is a typed failure rather than a
|
|
* JSON parse error thirty lines away. Sessions expire, and when one does the
|
|
* UI should say "signed out", not "Unexpected token < in JSON".
|
|
*/
|
|
export class ApiError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
message: string,
|
|
) {
|
|
super(message);
|
|
}
|
|
}
|
|
|
|
async function get<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const r = await fetch(path, { credentials: "same-origin", ...init });
|
|
if (r.status === 401) throw new ApiError(401, "signed out");
|
|
if (!r.ok) {
|
|
// FastAPI puts the useful part in `detail` — quota messages, "only active
|
|
// admin", and so on. Surfacing "HTTP 429" instead would throw away the
|
|
// only text that tells the user what to do about it.
|
|
let msg = `HTTP ${r.status}`;
|
|
try {
|
|
const body = await r.json();
|
|
if (typeof body?.detail === "string") msg = body.detail;
|
|
else if (typeof body?.error === "string") msg = body.error;
|
|
} catch {
|
|
/* not JSON */
|
|
}
|
|
throw new ApiError(r.status, msg);
|
|
}
|
|
return (await r.json()) as T;
|
|
}
|
|
|
|
function send<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
return get<T>(path, {
|
|
method,
|
|
headers: { "Content-Type": "application/json" },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
const post = <T,>(path: string, body?: unknown) => send<T>("POST", path, body);
|
|
|
|
export const api = {
|
|
me: () => get<Me>("/api/me"),
|
|
search: (q: string) =>
|
|
get<{ results: Track[] }>(`/api/search?q=${encodeURIComponent(q)}`),
|
|
suggest: (q: string, signal?: AbortSignal) =>
|
|
get<Suggestions>(`/api/suggest?q=${encodeURIComponent(q)}`, { signal }),
|
|
album: (id: number | string) =>
|
|
get<{ album: AlbumHeader; tracks: Track[] }>(
|
|
`/api/album/${encodeURIComponent(String(id))}`,
|
|
),
|
|
artist: (id: number | string) =>
|
|
get<{ artist: ArtistHeader; tracks: Track[]; albums: AlbumCard[] }>(
|
|
`/api/artist/${encodeURIComponent(String(id))}`,
|
|
),
|
|
request: (t: Track) => post<{ id: string }>("/api/request", t),
|
|
requestBatch: (tracks: Track[]) =>
|
|
post<{ queued: number; ids: string[]; pending: number }>(
|
|
"/api/request/batch",
|
|
{ tracks },
|
|
),
|
|
jobs: () =>
|
|
get<{ jobs: Job[]; counts: Record<string, number>; pending: number }>(
|
|
"/api/jobs",
|
|
),
|
|
retry: (id: string) => post<{ ok: boolean }>(`/api/retry/${id}`),
|
|
settings: (s: { email?: string | null; notify?: boolean }) =>
|
|
send<{ ok: boolean; mail_configured: boolean }>("PATCH", "/api/me", s),
|
|
review: () =>
|
|
get<{
|
|
items: ReviewItem[];
|
|
thresholds: { auto: number; review: number };
|
|
}>("/api/review"),
|
|
decide: (id: string, action: "approve" | "reject") =>
|
|
post<{ ok: boolean; state: string }>(`/api/review/${id}`, { action }),
|
|
discover: () => get<Discover>("/api/discover"),
|
|
recap: () => get<Recap>("/api/recap"),
|
|
importList: (text: string) =>
|
|
post<{
|
|
queued: { id: string; line: string; matched: string; cover: string | null }[];
|
|
skipped: { line: string; why: string }[];
|
|
pending: number;
|
|
}>("/api/import", { text }),
|
|
|
|
admin: {
|
|
users: () => get<{ users: AdminUser[]; roles: string[] }>("/api/admin/users"),
|
|
resend: (username: string) =>
|
|
post<{ ok: boolean; sent_to: string }>(
|
|
`/api/admin/users/${encodeURIComponent(username)}/resend`,
|
|
),
|
|
addUser: (u: {
|
|
username: string;
|
|
provider: string;
|
|
role: string;
|
|
quota: number;
|
|
storage_quota_gb: number;
|
|
email?: string;
|
|
}) =>
|
|
post<AdminUser & { invite_sent: boolean; invite_why: string | null }>(
|
|
"/api/admin/users",
|
|
u,
|
|
),
|
|
patchUser: (
|
|
username: string,
|
|
patch: Partial<{
|
|
role: string;
|
|
quota: number;
|
|
storage_quota_gb: number;
|
|
disabled: boolean;
|
|
}>,
|
|
) => send<AdminUser>("PATCH", `/api/admin/users/${encodeURIComponent(username)}`, patch),
|
|
deleteUser: (username: string) =>
|
|
send<{ ok: boolean }>("DELETE", `/api/admin/users/${encodeURIComponent(username)}`),
|
|
system: () => get<SystemStats>("/api/admin/system"),
|
|
activity: (days = 30) => get<Activity>(`/api/admin/activity?days=${days}`),
|
|
},
|
|
};
|
|
|
|
/** Bytes → human. Binary units, because that's what df and the OS report. */
|
|
export function bytes(n: number | null | undefined): string {
|
|
if (!n) return "0 B";
|
|
const u = ["B", "KB", "MB", "GB", "TB"];
|
|
let i = 0;
|
|
let v = n;
|
|
while (v >= 1024 && i < u.length - 1) {
|
|
v /= 1024;
|
|
i++;
|
|
}
|
|
return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${u[i]}`;
|
|
}
|
|
|
|
export const mmss = (ms: number | null | undefined) => {
|
|
const t = Math.round((ms || 0) / 1000);
|
|
return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, "0")}`;
|
|
};
|