Files
verdande/web/src/lib/api.js
T
Kristian WindandClaude Opus 5 dcb71f4c14
Release / Build and publish image (push) Failing after 10m3s
feat(calendar): abonnement på flere kalendere, ICS og webcal
Bedt om som "abonnering af flere kalendere (ics og webcal?)". Det er også den
eneste vej for en privat konto: instansens OAuth-klient er Internal, og Google
afviser en konto udefra med `org_internal`, uanset hvad der ellers er sat op.

**Samme tabeller.** Et abonnement er én kalender med begivenheder i, og det er
præcis det, `calendars` og `calendar_events` allerede beskriver — med vinduet,
farven, `shown` og hele fejemaskinen omkring sig. En tabel til ville være en anden
vej til det samme sted, og to veje til ét sted er to steder at rette den dag noget
om begivenheder ændrer sig.

Det, der skulle laves om, var identiteten. En OAuth-konto er én pr. person og
udbyder — tokenet er identiteten — mens et abonnement er én pr. adresse, og man har
flere. `calendar_accounts_once` er derfor delvist nu: det holder for rækkerne uden
adresse, og et andet indeks holder adresserne unikke. Delvise indekser er den ene
ting, SQLite kan her, som ellers krævede en tabelombygning.

**`webcal://` skrives om til `https://`.** Det er ikke en protokol, noget taler —
det er en konvention, der får et skrivebord til at give adressen til
kalenderprogrammet i stedet for til browseren, og alle udgivere skriver deres links
sådan. At afvise den ville være at afvise folk på en teknikalitet.

**Adressen gennem `safedial`, begge halvdele.** Afvist hvor den skrives ned, så
den, der står med feltet, får svaret med det samme — og igen på den *opslåede*
adresse hver gang den hentes, fordi et navn, der blev godkendt ved indtastningen,
kan svare 127.0.0.1, når fejemaskinen kører klokken tre om natten. Det er den
sætning, `internal/safedial` selv står og advarer om, og den blev skrevet efter et
SSRF-fund i morges.

**Hentet én gang, før den gemmes.** En adresse, der ikke kan læses, er en tastefejl
langt oftere end en server, der tilfældigvis er nede, og at sige det, mens nogen
står med feltet, er de to sekunder værd. Et abonnement, der lydløst aldrig fyldes,
er den samme tavshed, notifikationerne havde.

Læseren er ny i `internal/ics`, som hidtil kun kunne skrive. To ting, den med vilje
ikke gør, står skrevet ved den: den udfolder ikke gentagelser, og den opløser ikke
tidszoner. Begge dele er større end de ser ud, og begge har ét sted at høre til
den dag de skal.

En ting, formatet lærte: DTEND er eksklusiv for en heldagsbegivenhed. Gemmes den,
som filen skriver den, dækker hver eneste helligdag også dagen efter sig.

Én adresse, der fejler, stopper ikke de andre. Et abonnement er en anden persons
server, og den ordinære tilstand for en anden persons server er, at den er i orden
indtil en morgen hvor den ikke er.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-20 21:26:13 +02:00

440 lines
17 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',
// And for a calendar read from Google. Its own code, because the sentence this
// is wrapped in names where it came from — and "Gmail said no" about a calendar
// sends somebody to the wrong panel.
calendar_failed: 'error.calendarRefused',
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 });
}
if (err.code === 'calendar_failed' && err.message) {
return t('error.calendarSaid', { 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: (password) => post('/auth/totp/setup', { password }),
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}`),
// The whole list, the same as projects and groups: a project has a handful of
// sections, and an order that cannot land half-applied is worth more than the
// bytes a single move would save.
reorderSections: (projectId, ids) => post(`/projects/${projectId}/sections/reorder`, { ids }),
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),
uploadNoteFile: (noteId, file) => upload(`/notes/${noteId}/attachments`, file),
noteBulk: (body) => post('/notes/bulk', body),
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: {} }),
// Teksten kommer herfra, fordi ordbøgerne gør. Serveren har ingen.
testPush: (title, body) => post('/push/test', { title, body }),
// --- 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'),
// The Google Calendar connection. Read-only: there is nothing here that writes
// an event, only which calendars to look at.
getCalendar: () => get('/calendar'),
setCalendars: (shown) => put('/calendar/calendars', { shown }),
// En kalender, man kun har en adresse til. Flere pr. person, hvor Google er
// én: adressen er identiteten, som tokenet er det for OAuth.
subscribeCalendar: (url, name) => post('/calendar/subscriptions', { url, name }),
unsubscribeCalendar: (id) => del(`/calendar/subscriptions/${id}`),
disconnectCalendar: () => del('/calendar'),
authorizeCalendar: () => post('/calendar/authorize'),
syncCalendar: () => post('/calendar/sync'),
calendarEvents: (from, to) =>
get(`/calendar/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`),
// 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)}`)
};