Files
verdande/web/src/lib/api.js
T
Kristian WindandClaude Opus 5 78891bae6f feat(beacon): tæl installationer, sig præcis hvad der sendes, og lad folk sige nej
Spørgsmålet er ét: hvor mange bruger det her. Svaret er to værdier én gang i
døgnet — et tilfældigt id, installationen selv fandt på, og versionen. Ikke andet.
Beskeden er lille nok til at stå i fuld længde på indstillingssiden, og den gør
det, fordi et løfte om telemetri er præcis så meget værd som læserens mulighed
for at efterprøve det.

**Ikke obligatorisk, og grunden er praktisk frem for principiel:** det kan ikke
håndhæves. Domænet kan blokeres, binæren kan patches, og en fork fjerner den i
første commit — så obligatorisk køber intet over slået-til-som-udgangspunkt og
koster den tillid, resten af programmet arbejder for. Hemmeligheder forsegles
her, nøglen holdes uden for databasen, og sikkerhedskopisiden advarer om, hvad en
kopi indeholder. Telemetri, man ikke må sige nej til, er den ene sætning i den
historie, der ikke passer.

Uenigheden med `config.UpdateCheck`, som er slået fra med den modsatte
begrundelse, står skrevet i pakkens egen dokumentation frem for at blive glattet
ud. Den er reel, og læseren fortjener at se begge halvdele.

Id'et findes på og gemmes ved første brug frem for at blive udledt af værtsnavn
eller adresse. En hash af noget om maskinen ville være stabil, bekvem og
samtidig en værdi, modtageren kunne holde op mod et gæt — og det er forskellen
mellem anonym og pseudonym.

Modtagerens side er den samme binary med en kontakt: `POST /api/v1/beacon` uden
login, fordi den, der ringer, er en fremmed installation. Den svarer 404, når
instansen ikke er tælleren, så en almindelig installation ikke er en spamspand,
der svarer 200 på alt. Ratebegrænset som login-ruterne, og tabellen har et loft:
endepunktet indsætter en række på et id, den, der ringer, har fundet på, og uden
et loft kunne enhver fylde disken.

Tallet er "set inden for tredive dage" og ikke et løbende total. Et total stiger
kun og holder op med at betyde "hvor mange bruger det" og begynder at betyde
"hvor mange har prøvet det".

`internal/safedial` kom til undervejs: modtagerens adresse kan skrives i fladen,
og en adresse, en bruger angiver, må ikke kunne pege indad. Kontrollen ligger på
den opslåede adresse og ikke på URL'en, fordi et navn kan svare 127.0.0.1 — og
svare noget andet anden gang, efter en kontrol ved parsning er gået godt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 06:59:50 +02:00

411 lines
16 KiB
JavaScript

/**
* The one place the app talks to the server.
*
* Every call goes through `request`, so authentication failures, error shapes and
* the CSRF header are handled once rather than at each of forty call sites.
*/
/** Thrown for any non-2xx response. Carries the server's stable `code` so callers
* can branch on the reason without matching on prose. */
export class ApiError extends Error {
constructor(status, code, message, fields) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.fields = fields ?? {};
}
/** True when the session has gone — the app should show the sign-in screen. */
get isAuthError() {
return this.status === 401;
}
}
import { t } from './i18n.svelte.js';
/**
* The key for each code the API can return.
*
* Keys rather than sentences, because the server's own prose is English and
* written for a log — what a person reads is decided here, where the locale is
* known. `errorcodes_test.go` walks this table against the codes Go can emit, so
* a code the server gained and this did not fails the build rather than falling
* through to that log prose.
*/
const MESSAGES = {
unauthorized: 'error.unauthorized',
totp_required: 'error.totpRequired',
forbidden: 'error.forbidden',
not_found: 'error.notFound',
conflict: 'error.conflict',
rate_limited: 'error.rateLimited',
validation_failed: 'error.validation',
payload_too_large: 'error.tooLarge',
internal_error: 'error.internal',
bad_request: 'error.badRequest',
// Client-only: a network failure is not an HTTP status, so the server has no
// code for it.
offline: 'error.offline',
// Situations that used to arrive as a plain `conflict` and were therefore
// shown as "that already exists" — which was not merely vague but wrong. An
// unconfigured Gmail is not a Gmail that is already connected.
gmail_not_configured: 'error.gmailNotConfigured',
ai_not_configured: 'error.aiNotConfigured',
totp_not_enabled: 'error.totpNotEnabled',
inbox_protected: 'error.inboxProtected',
// The fallback for when Gmail refused and said nothing usable. When it did say
// something, `humanMessage` quotes it instead — see below.
gmail_failed: 'error.gmailRefused',
// Same shape for a mailbox read over IMAP: quoted when the host said something,
// this when it did not.
mailbox_failed: 'error.mailboxRefused',
last_admin: 'error.lastAdmin'
};
/**
* What to show a person for a failed request.
*
* A code with no key here falls back to the server's prose, which is English and
* written for a log — not good, but better than swallowing the only explanation
* there is. A code that turns up in that fallback is a code this table is missing.
*/
export function humanMessage(err) {
if (!(err instanceof ApiError)) return t(MESSAGES.offline);
// Gmail's own words are the diagnosis — "invalid_grant", "insufficient
// authentication scopes" — and a generic sentence in their place throws away
// the only thing that says what to do. Wrapped so it reads as a quotation
// rather than as a stray English string in a Danish interface.
if (err.code === 'mailbox_failed' && err.message) {
return t('error.mailboxSaid', { what: err.message });
}
if (err.code === 'gmail_failed' && err.message) {
return t('error.gmailSaid', { what: err.message });
}
const key = MESSAGES[err.code];
if (key) return t(key);
return err.message ?? t(MESSAGES.internal_error);
}
async function request(method, path, body, options = {}) {
const init = {
method,
headers: {},
// The session cookie is httpOnly and same-origin; nothing here handles a token.
credentials: 'same-origin',
signal: options.signal
};
if (body !== undefined) {
init.headers['Content-Type'] = 'application/json';
init.body = JSON.stringify(body);
}
let res;
try {
res = await fetch(`/api/v1${path}`, init);
} catch (e) {
// A network failure is not an HTTP status; give it one the app can handle
// the same way as everything else.
throw new ApiError(0, 'offline', 'ingen forbindelse');
}
if (res.status === 204) return null;
let payload = null;
const text = await res.text();
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = null;
}
}
if (!res.ok) {
throw new ApiError(
res.status,
payload?.code ?? 'internal_error',
payload?.error ?? res.statusText,
payload?.fields
);
}
return payload;
}
const get = (path, options) => request('GET', path, undefined, options);
const post = (path, body, options) => request('POST', path, body ?? {}, options);
const patch = (path, body) => request('PATCH', path, body);
const put = (path, body) => request('PUT', path, body);
const del = (path) => request('DELETE', path);
/**
* Uploads a file. Not routed through `request`, which serialises JSON and sets a
* Content-Type — a multipart body has to set its own boundary, and the browser
* only does that when nothing has claimed the header.
*/
async function upload(path, file, field = 'file') {
const form = new FormData();
form.append(field, file);
let res;
try {
res = await fetch(`/api/v1${path}`, { method: 'POST', body: form, credentials: 'same-origin' });
} catch {
throw new ApiError(0, 'offline', 'ingen forbindelse');
}
const text = await res.text();
let payload = null;
if (text) {
try {
payload = JSON.parse(text);
} catch {
payload = null;
}
}
if (!res.ok) {
throw new ApiError(
res.status,
payload?.code ?? 'internal_error',
payload?.error ?? res.statusText,
payload?.fields
);
}
return payload;
}
export const api = {
// --- auth
setupState: () => get('/auth/setup'),
setup: (data) => post('/auth/setup', data),
login: (email, password) => post('/auth/login', { email, password }),
loginTOTP: (code) => post('/auth/login/totp', { code }),
logout: () => post('/auth/logout'),
me: () => get('/auth/me'),
updateProfile: (data) => patch('/auth/me', data),
// Its own route rather than a field on the profile: that one backs a form with
// a save button, and this writes on every click of a chevron.
setNavOrder: (order) => put('/auth/nav-order', { order }),
setSidebarSections: (sections) => put('/auth/sidebar-sections', { sections }),
signup: (data) => post('/auth/signup', data),
forgotPassword: (email) => post('/auth/password/forgot', { email }),
resetPassword: (token, password) => post('/auth/password/reset', { token, password }),
changePassword: (current_password, new_password) =>
post('/auth/password/change', { current_password, new_password }),
totpSetup: () => post('/auth/totp/setup'),
totpConfirm: (code) => post('/auth/totp/confirm', { code }),
totpDisable: (password) => post('/auth/totp/disable', { password }),
listSessions: () => get('/auth/sessions'),
// --- passkeys
listPasskeys: () => get('/auth/passkeys'),
beginPasskeyRegistration: () => post('/auth/passkeys/register/begin'),
finishPasskeyRegistration: (data) => post('/auth/passkeys/register/finish', data),
renamePasskey: (id, name) => patch(`/auth/passkeys/${id}`, { name }),
deletePasskey: (id) => del(`/auth/passkeys/${id}`),
beginPasskeyLogin: () => post('/auth/passkey/login/begin'),
finishPasskeyLogin: (data) => post('/auth/passkey/login/finish', data),
endSession: (id) => del(`/auth/sessions/${id}`),
// --- users, administrators only
listUsers: () => get('/users'),
inviteUser: (email) => post('/users', { email }),
setUserAdmin: (id, isAdmin) => patch(`/users/${id}`, { is_admin: isAdmin }),
deleteUser: (id) => del(`/users/${id}`),
revokeInvite: (id) => del(`/invites/${id}`),
listErrors: () => get('/errors'),
panelStatus: () => get('/panel'),
restartFromPanel: () => post('/panel/restart'),
// The nightly backup, which ran since the beginning with nothing to show it.
// The OAuth client itself, which is the instance's registration with Google
// rather than anybody's mailbox.
gmailClient: () => get('/gmail/client'),
setGmailClient: (data) => put('/gmail/client', data),
beacon: () => get('/beacon/settings'),
setBeacon: (body) => put('/beacon/settings', body),
listBackups: () => get('/backups'),
runBackup: () => post('/backups'),
// A link rather than a fetch: the browser's own download handles a file of
// this size, and a blob in memory of the whole database does not.
backupURL: (id) => `/api/v1/backups/${id}`,
// The instance-wide history, which is the other half of the error log: that
// one is what broke, this is what was done. Paged with the cursor the server
// hands back rather than an offset — see the OpenAPI note on /activity.
auditLog: ({ before, user_id, project_id, event, limit } = {}) => {
const q = new URLSearchParams();
if (before) q.set('before', before);
if (user_id) q.set('user_id', user_id);
if (project_id) q.set('project_id', project_id);
if (event) q.set('event', event);
if (limit) q.set('limit', limit);
const query = q.toString();
return get(`/activity${query ? `?${query}` : ''}`);
},
auditEvents: () => get('/activity/events'),
recoveryCodes: () => get('/auth/recovery-codes'),
regenerateRecoveryCodes: (password) => post('/auth/recovery-codes', { password }),
// --- projects
listProjects: (archived = false) => get(`/projects${archived ? '?archived=true' : ''}`),
getProject: (id) => get(`/projects/${id}`),
createProject: (data) => post('/projects', data),
updateProject: (id, data) => patch(`/projects/${id}`, data),
deleteProject: (id) => del(`/projects/${id}`),
reorderProjects: (ids) => post('/projects/reorder', { ids }),
listTrashedProjects: () => get('/trash/projects'),
restoreProject: (id) => post(`/trash/projects/${id}/restore`),
listProjectGroups: () => get('/project-groups'),
createProjectGroup: (name) => post('/project-groups', { name }),
getProjectGroup: (id) => get(`/project-groups/${id}`),
updateProjectGroup: (id, data) => patch(`/project-groups/${id}`, data),
uploadGroupAttachment: (id, file) => upload(`/project-groups/${id}/attachments`, file),
deleteProjectGroup: (id) => del(`/project-groups/${id}`),
reorderProjectGroups: (ids) => post('/project-groups/reorder', { ids }),
listSections: (projectId) => get(`/projects/${projectId}/sections`),
createSection: (projectId, name) => post(`/projects/${projectId}/sections`, { name }),
updateSection: (id, data) => patch(`/sections/${id}`, data),
deleteSection: (id) => del(`/sections/${id}`),
listMembers: (projectId) => get(`/projects/${projectId}/members`),
invite: (projectId, email, role) => post(`/projects/${projectId}/invites`, { email, role }),
setMemberRole: (projectId, userId, role) =>
patch(`/projects/${projectId}/members/${userId}`, { role }),
removeMember: (projectId, userId) => del(`/projects/${projectId}/members/${userId}`),
activity: (projectId) => get(`/projects/${projectId}/activity`),
// --- tasks
listTasks: (params = {}) => {
const query = new URLSearchParams(
Object.entries(params).filter(([, v]) => v !== undefined && v !== '')
);
const suffix = query.toString();
return get(`/tasks${suffix ? `?${suffix}` : ''}`);
},
getTask: (id) => get(`/tasks/${id}`),
createTask: (data) => post('/tasks', data),
updateTask: (id, data) => patch(`/tasks/${id}`, data),
deleteTask: (id) => del(`/tasks/${id}`),
completeTask: (id) => post(`/tasks/${id}/complete`),
reopenTask: (id) => post(`/tasks/${id}/reopen`),
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
quickAdd: (text, projectId, sectionId) =>
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
quickAddPreview: (text, signal) =>
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
// --- filters and labels
listFilters: () => get('/filters'),
createFilter: (data) => post('/filters', data),
updateFilter: (id, data) => patch(`/filters/${id}`, data),
deleteFilter: (id) => del(`/filters/${id}`),
runFilter: (id) => get(`/filters/${id}/tasks`),
previewFilter: (query) => get(`/filters/preview?query=${encodeURIComponent(query)}`),
listLabels: () => get('/labels'),
createLabel: (name) => post('/labels', { name }),
deleteLabel: (id) => del(`/labels/${id}`),
// --- reminders, feed and templates
listReminders: (taskId) => get(`/tasks/${taskId}/reminders`),
createReminder: (taskId, data) => post(`/tasks/${taskId}/reminders`, data),
deleteReminder: (id) => del(`/reminders/${id}`),
feed: () => get('/feed'),
rotateFeed: () => post('/feed/rotate'),
listTemplates: () => get('/templates'),
saveTemplate: (data) => post('/templates', data),
useTemplate: (id, data) => post(`/templates/${id}/use`, data),
deleteTemplate: (id) => del(`/templates/${id}`),
// --- comments and attachments
listComments: (taskId) => get(`/tasks/${taskId}/comments`),
createComment: (taskId, body) => post(`/tasks/${taskId}/comments`, { body }),
updateComment: (id, body) => patch(`/comments/${id}`, { body }),
deleteComment: (id) => del(`/comments/${id}`),
uploadAttachment: (taskId, file) => upload(`/tasks/${taskId}/attachments`, file),
deleteAttachment: (id) => del(`/attachments/${id}`),
attachmentURL: (id) => `/api/v1/attachments/${id}`,
// --- API tokens
listTokens: () => get('/tokens'),
createToken: (name, expiresInDays) =>
post('/tokens', { name, expires_in_days: expiresInDays ?? 0 }),
deleteToken: (id) => del(`/tokens/${id}`),
// --- notifications and push
listNotifications: () => get('/notifications'),
markNotificationsRead: (id) => post(id ? `/notifications/${id}/read` : '/notifications/read'),
pushKey: () => get('/push/key'),
subscribePush: (subscription) => post('/push/subscribe', subscription),
unsubscribePush: (endpoint) => post('/push/unsubscribe', { endpoint, keys: {} }),
// --- integrations
getMailAddress: () => get('/mail-address'),
rotateMailAddress: () => post('/mail-address/rotate'),
getGmail: () => get('/gmail'),
setGmail: (data) => put('/gmail', data),
disconnectGmail: () => del('/gmail'),
authorizeGmail: () => post('/gmail/authorize'),
syncGmail: () => post('/gmail/sync'),
// Notes. Listing and searching are one endpoint because they answer the same
// question with different words.
notes: (params = {}) => {
const q = new URLSearchParams(Object.entries(params).filter(([, v]) => v)).toString();
return get('/notes' + (q ? `?${q}` : ''));
},
note: (id) => get(`/notes/${id}`),
createNote: (body) => post('/notes', body),
updateNote: (id, body) => patch(`/notes/${id}`, body),
deleteNote: (id) => del(`/notes/${id}`),
notesLinking: (kind, targetId) => get(`/notes/linking/${kind}/${encodeURIComponent(targetId)}`),
// Mailboxes read over IMAP. Each belongs to the person who connected it, so
// there is no instance-wide registration behind these the way Gmail has one.
mailboxes: () => get('/mailboxes'),
addMailbox: (body) => post('/mailboxes', body),
deleteMailbox: (id) => del(`/mailboxes/${id}`),
syncMailbox: (id) => post(`/mailboxes/${id}/sync`),
getAISettings: () => get('/ai/settings'),
setAISettings: (data) => put('/ai/settings', data),
aiSummary: () => post('/ai/summary'),
aiSplit: (taskId) => post(`/ai/tasks/${taskId}/split`),
version: () => get('/version'),
// --- import and export
importTodoist: (file) => upload('/import/todoist', file),
importCSV: (data) => post('/import/csv', data),
exportAccountURL: () => '/api/v1/export/account',
exportNotesURL: () => '/api/v1/export/notes.zip',
// Through `upload`, not `request`: a multipart body has to set its own boundary,
// and the browser only does that when nothing has claimed the header.
importNotes: (file) => upload('/notes/import', file),
exportProjectCSVURL: (id) => `/api/v1/export/projects/${id}.csv`,
exportProjectICSURL: (id) => `/api/v1/export/projects/${id}.ics`,
// --- views
today: () => get('/today'),
upcoming: (days) => get(`/upcoming${days ? `?days=${days}` : ''}`),
delegated: () => get('/delegated'),
people: () => get('/people'),
search: (q) => get(`/search?q=${encodeURIComponent(q)}`)
};