Files
TREK/server/src/nest/doc-sync/doc-sync.controller.ts
T

446 lines
19 KiB
TypeScript

import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpException,
Logger,
Param,
Patch,
Post,
Put,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import type { Request } from 'express';
import type { User } from '../../types';
import { ADDON_IDS } from '../../addons';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { TripAccessGuard } from '../permissions/trip-access.guard';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import { docFailed } from './document-provider';
import { DocSyncConfigService, type LinkRow } from './doc-sync-config.service';
import { DocSyncService } from './doc-sync.service';
import { DocumentProviderRegistry } from './document-provider.registry';
import { PROVIDER_DISABLED } from './doc-sync.constants';
import { sameOrigin } from './doc-sync.helpers';
import {
DocsyncConnectionDto,
DocsyncConnectionTestDto,
DocsyncLinkDto,
DocsyncLinkUpdateDto,
DocsyncResolveConflictDto,
DocsyncScopeCreateDto,
DocsyncSyncNowDto,
} from './doc-sync.dto';
/**
* `/api/trips/:tripId/docsync`: a trip's document provider binding.
*
* Trip-scoped rather than user-scoped, and that is the whole point of the
* feature: one connection per trip, which every member reads through. The
* routes therefore sit under the trip, not under `/api/integrations`.
*
* Two shapes of answer, the split Dawarich already established: connection
* routes (`test`, `status`) answer 200 even when the provider is unreachable,
* with the reason in the body, because a form showing someone their own typo
* needs a field to render rather than an exception. Everything else answers the
* status the failure deserves.
*
* Only the trip owner may change a binding (plus an instance admin, who
* overrides trip permissions everywhere else too). It hands TREK a credential
* that usually reaches the owner's entire document archive, so widening this to
* every member would let any member point the trip at a folder the owner never
* meant to share. Reading the status is open to all members: they need to know
* where their documents are going.
*/
@Controller('api/trips/:tripId/docsync')
@UseGuards(AddonGuard, JwtAuthGuard, TripAccessGuard)
@RequireAddon(ADDON_IDS.DOCUMENTS, 'Documents')
export class DocSyncController {
private readonly logger = new Logger(DocSyncController.name);
constructor(
private readonly config: DocSyncConfigService,
private readonly sync: DocSyncService,
private readonly registry: DocumentProviderRegistry,
private readonly db: DatabaseService,
private readonly realtime: RealtimeService,
) {}
/**
* Tell the trip that a binding came or went.
*
* Whether a trip is bound decides whether its members see the sync button
* at all, and a run only pings when it moved a document. Binding two empty
* sides, or unbinding, moves nothing, so a member sitting on the Files tab
* kept the old answer until they left it. The whole room, the caller
* included: it is a ping to re-read, not a change to apply, and the owner's
* own button depends on it too once every provider is switched off.
*/
private announceBinding(link: Pick<LinkRow, 'id' | 'trip_id'>): void {
this.realtime.broadcast(link.trip_id, 'docsync:changed', { linkId: link.id, pulled: 0, pushed: 0 });
}
/**
* The trip owner or an instance admin, the same rule as `canManageDocSync`
* on the client. Checked here rather than through @RequirePermission: the
* permission table has no "manage integrations" action and inventing one
* would grant it to every trip_member by default, which is the opposite of
* what a credential this broad needs.
*
* The 403 still says "owner": an admin never sees it, and to everybody who
* does, the owner is the person to ask.
*/
private assertCanManage(tripId: string, user: User): void {
if (user.role === 'admin') return;
const trip = this.db.get<{ user_id: number }>('SELECT user_id FROM trips WHERE id = ?', tripId);
if (!trip) throw new HttpException('Trip not found', 404);
if (Number(trip.user_id) !== Number(user.id)) {
throw new HttpException('Only the trip owner can change document sync', 403);
}
}
// ── Providers and status ───────────────────────────────────────────────────
/** Which providers this instance has switched on, with their form fields. */
@Get('providers')
providers() {
const rows = this.db.connection
.prepare('SELECT id, name, description, icon FROM document_providers WHERE enabled = 1 ORDER BY sort_order')
.all() as Array<{ id: string; name: string; description: string | null; icon: string }>;
return rows.map((p) => ({
...p,
// A provider row with no registered adapter would render a form that
// cannot work, so it is reported rather than hidden.
available: !!this.registry.get(p.id),
fields: this.config.providerFields(p.id).map((f) => ({ ...f, secret: f.secret === 1, required: f.required === 1 })),
}));
}
@Get('status')
status(@Param('tripId') tripId: string) {
return this.sync.status(Number(tripId));
}
// ── Connections ────────────────────────────────────────────────────────────
@Get('connections')
listConnections(@Param('tripId') tripId: string) {
return this.config.listConnections(Number(tripId)).map((c) => this.config.publicConnection(c));
}
@Put('connections')
@HttpCode(200)
async upsertConnection(
@Param('tripId') tripId: string,
@CurrentUser() user: User,
@Body() body: DocsyncConnectionDto,
) {
this.assertCanManage(tripId, user);
if (!this.config.enabledProviderIds().includes(body.providerId)) {
throw new HttpException(`Provider: "${body.providerId}" is not enabled, contact server administrator`, 400);
}
const res = await this.config.upsertConnection(Number(tripId), Number(user.id), body);
if (docFailed(res)) throw new HttpException(res.error.detail || res.error.code, 400);
return this.config.publicConnection(res.data);
}
/**
* Probe form values. Always 200: the result is in the body, because this is
* a form field, not a failure of the request. The photo providers pin the
* same contract with a test their e2e suite marks CRITICAL.
*/
@Post('connections/test')
@HttpCode(200)
async testConnection(
@Param('tripId') tripId: string,
@CurrentUser() user: User,
@Body() body: DocsyncConnectionTestDto,
) {
this.assertCanManage(tripId, user);
const provider = this.registry.get(body.providerId);
if (!provider) return { connected: false, error: 'unknown_provider' };
const urlCheck = await this.config.validateBaseUrl(body.baseUrl);
if (docFailed(urlCheck)) return { connected: false, error: urlCheck.error.code };
// Probing with the values on screen, merged over whatever is stored, so a
// user testing an unchanged connection does not have to retype the secret.
const existing = this.config
.listConnections(Number(tripId))
.find((c) => c.provider_id === body.providerId);
// Only for the address the credential was stored against. Merging it into a
// probe of an arbitrary baseUrl turns this route into a way to have TREK
// post a stored API token at a server of the caller's choosing, which is
// exactly what somebody who inherited a trip but not its credentials would
// reach for. Same host, same scheme, same port, or the caller types it in.
const sameTarget = !!existing && sameOrigin(existing.base_url, urlCheck.data.url);
const stored = sameTarget && existing ? this.config.toRef(existing) : null;
const fields = this.config.providerFields(body.providerId);
const secrets: Record<string, string> = { ...(stored?.secrets ?? {}) };
const settings: Record<string, string> = { ...(stored?.settings ?? {}) };
for (const f of fields) {
const submitted = body.credentials[f.field_key];
if (submitted === undefined || submitted === '') continue;
if (f.secret === 1) secrets[f.field_key] = submitted;
else settings[f.field_key] = submitted;
}
const res = await provider.probe({
connectionId: existing?.id ?? 0,
createdAt: existing?.created_at ?? '',
ownerId: Number(user.id),
baseUrl: urlCheck.data.url,
secrets,
settings,
allowInsecureTls: body.allowInsecureTls,
});
if (docFailed(res)) return { connected: false, error: res.error.code, detail: res.error.detail };
if (existing) this.config.recordProbe(existing.id, 'ok', null, res.data.capabilities);
return { connected: true, account: res.data.account, capabilities: res.data.capabilities };
}
@Delete('connections/:connectionId')
@HttpCode(200)
async deleteConnection(
@Param('tripId') tripId: string,
@Param('connectionId') connectionId: string,
@CurrentUser() user: User,
) {
this.assertCanManage(tripId, user);
const conn = this.config.getConnection(Number(connectionId));
if (!conn || conn.trip_id !== Number(tripId)) throw new HttpException('Connection not found', 404);
// Its bindings go with it (ON DELETE CASCADE), so read them first. Every
// subscription TREK registered for one of them is taken down while the
// credential is still here to do it with, as unbinding one does: left
// standing, a Paperless workflow or a Nextcloud listener keeps posting to
// a token that answers nothing, for good.
const unbound = this.config.listLinks(conn.trip_id).filter((l) => l.connection_id === conn.id);
const provider = this.registry.get(conn.provider_id);
if (provider?.unregisterWebhook) {
const ref = this.config.toRef(conn);
for (const link of unbound) {
if (link.webhook_subscription_id) await provider.unregisterWebhook(ref, link.webhook_subscription_id);
}
}
this.config.deleteConnection(conn.id);
for (const link of unbound) this.announceBinding(link);
return { success: true };
}
// ── Scopes ─────────────────────────────────────────────────────────────────
/** Folders, tags or spaces the trip could be bound to. */
@Get('connections/:connectionId/scopes')
async listScopes(
@Param('tripId') tripId: string,
@Param('connectionId') connectionId: string,
@CurrentUser() user: User,
@Query('q') q?: string,
) {
this.assertCanManage(tripId, user);
const conn = this.config.getConnection(Number(connectionId));
if (!conn || conn.trip_id !== Number(tripId)) throw new HttpException('Connection not found', 404);
const provider = this.registry.get(conn.provider_id);
if (!provider) throw new HttpException('Provider not available', 400);
const res = await provider.listScopes(this.config.toRef(conn), q);
if (docFailed(res)) return { scopes: [], error: res.error.code };
return { scopes: res.data };
}
@Post('connections/:connectionId/scopes')
async createScope(
@Param('tripId') tripId: string,
@Param('connectionId') connectionId: string,
@CurrentUser() user: User,
@Body() body: DocsyncScopeCreateDto,
) {
this.assertCanManage(tripId, user);
const conn = this.config.getConnection(Number(connectionId));
if (!conn || conn.trip_id !== Number(tripId)) throw new HttpException('Connection not found', 404);
const provider = this.registry.get(conn.provider_id);
if (!provider) throw new HttpException('Provider not available', 400);
const res = await provider.createScope(this.config.toRef(conn), body.name);
if (docFailed(res)) throw new HttpException(res.error.code, 400);
return res.data;
}
// ── Bindings ───────────────────────────────────────────────────────────────
@Get('links')
listLinks(@Param('tripId') tripId: string, @Req() req: Request) {
const base = publicOrigin(req);
return this.config.listLinks(Number(tripId)).map((l) => this.config.publicLink(l, base));
}
@Post('links')
@HttpCode(200)
async createLink(
@Param('tripId') tripId: string,
@CurrentUser() user: User,
@Body() body: DocsyncLinkDto,
@Req() req: Request,
) {
this.assertCanManage(tripId, user);
const res = this.config.createLink(Number(tripId), Number(user.id), body);
if (docFailed(res)) throw new HttpException(res.error.detail || res.error.code, 400);
this.announceBinding(res.data);
// Subscribe where the provider lets TREK do it itself. A failure here is
// not a failure of the binding (polling still carries it), so it is logged
// rather than thrown at the user; the first run that follows would write
// over anything put into the link state.
const conn = this.config.getConnection(res.data.connection_id);
const provider = conn ? this.registry.get(conn.provider_id) : undefined;
if (conn && provider?.registerWebhook) {
const base = publicOrigin(req);
if (base) {
const hook = await provider.registerWebhook(
this.config.toRef(conn),
this.config.toScopeRef(res.data),
`${base}/api/docsync/webhook/${res.data.webhook_token}`,
this.config.webhookSecret(res.data),
);
if (docFailed(hook)) {
this.logger.warn(
`link ${res.data.id}: ${conn.provider_id} refused the webhook subscription (${hook.error.code}${hook.error.detail ? `: ${hook.error.detail}` : ''}), polling carries it`,
);
} else {
this.db.connection
.prepare('UPDATE trip_document_links SET webhook_subscription_id = ? WHERE id = ?')
.run(hook.data.subscriptionId, res.data.id);
}
}
}
// A first run right away, so the user sees something happen instead of
// waiting out a poll interval and wondering whether it worked.
void this.sync.syncLink(res.data, { full: true });
return this.config.publicLink(this.config.getLink(res.data.id) ?? res.data, publicOrigin(req));
}
@Patch('links/:linkId')
@HttpCode(200)
updateLink(
@Param('tripId') tripId: string,
@Param('linkId') linkId: string,
@CurrentUser() user: User,
@Body() body: DocsyncLinkUpdateDto,
@Req() req: Request,
) {
this.assertCanManage(tripId, user);
const link = this.config.getLink(Number(linkId));
if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404);
const updated = this.config.updateLink(link.id, body);
return this.config.publicLink(updated ?? link, publicOrigin(req));
}
@Delete('links/:linkId')
@HttpCode(200)
async deleteLink(
@Param('tripId') tripId: string,
@Param('linkId') linkId: string,
@CurrentUser() user: User,
) {
this.assertCanManage(tripId, user);
const link = this.config.getLink(Number(linkId));
if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404);
const conn = this.config.getConnection(link.connection_id);
const provider = conn ? this.registry.get(conn.provider_id) : undefined;
if (conn && provider?.unregisterWebhook && link.webhook_subscription_id) {
await provider.unregisterWebhook(this.config.toRef(conn), link.webhook_subscription_id);
}
this.config.deleteLink(link.id);
this.announceBinding(link);
// Unbinding keeps both copies. Nothing is deleted anywhere.
return { success: true, documentsKept: true };
}
@Post('links/:linkId/sync')
@HttpCode(200)
async syncNow(
@Param('tripId') tripId: string,
@Param('linkId') linkId: string,
@Body() body: DocsyncSyncNowDto,
) {
const link = this.config.getLink(Number(linkId));
if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404);
// An orphaned binding stays orphaned: its credential belongs to somebody who
// is no longer on this trip. The run itself would stand down as well; this
// says why, instead of answering with an empty run.
if (this.config.isOrphaned(link)) {
throw new HttpException({ error: 'This binding lost its owner and has to be reconnected' }, 409);
}
// Refused before the shelved rows are touched, so a binding an admin
// switched off stays exactly as it was and resumes where it stopped. A code
// rather than a sentence: the client says it in the reader's language.
if ((await this.sync.isSwitchedOff(link))) {
throw new HttpException({ error: PROVIDER_DISABLED }, 409);
}
// A person asking for a run is also asking for the rows that gave up to be
// tried once more; the scheduler gets no such reprieve.
this.sync.retryShelvedItems(link.id);
return this.sync.syncLink(link, { full: body.full });
}
// ── Documents and conflicts ────────────────────────────────────────────────
@Get('items')
items(@Param('tripId') tripId: string, @Query('state') state?: string) {
const rows = state
? this.db.connection
.prepare(
`SELECT i.*, f.original_name AS file_name FROM document_sync_items i
LEFT JOIN trip_files f ON f.id = i.file_id
WHERE i.trip_id = ? AND i.state = ? ORDER BY i.id DESC LIMIT 500`,
)
.all(Number(tripId), state)
: this.db.connection
.prepare(
`SELECT i.*, f.original_name AS file_name FROM document_sync_items i
LEFT JOIN trip_files f ON f.id = i.file_id
WHERE i.trip_id = ? ORDER BY i.id DESC LIMIT 500`,
)
.all(Number(tripId));
return rows;
}
@Post('items/:itemId/resolve')
@HttpCode(200)
async resolve(
@Param('tripId') tripId: string,
@Param('itemId') itemId: string,
@CurrentUser() user: User,
@Body() body: DocsyncResolveConflictDto,
) {
this.assertCanManage(tripId, user);
const ok = await this.sync.resolveConflict(Number(itemId), body.keep, Number(tripId));
if (!ok) throw new HttpException('Item is not in conflict', 400);
return { success: true };
}
}
/**
* The origin a provider has to call back on.
*
* Derived from the request rather than from configuration because a self-hosted
* TREK is reached under whatever name its operator chose, and asking them to
* maintain a second copy of it would get it wrong. Returns null behind a proxy
* that strips the host, in which case the webhook is simply not offered and
* polling carries the binding.
*/
function publicOrigin(req: Request): string | null {
const host = req.get('x-forwarded-host') || req.get('host');
if (!host) return null;
const proto = (req.get('x-forwarded-proto') || req.protocol || 'http').split(',')[0].trim();
return `${proto}://${host}`;
}