mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
v26.10.3 (#1692)
* feat: shell controller, TabStage, layout and theme plugins with repair view * feat: move the classic layout into its own bundled plugin * feat: move color themes into a bundled themes plugin * fix: plugin tests and type checks work with a linked sdk * fix: home tab, layout switch and missing layout fallbacks for looks * chore: add classic layout and themes to the readme * chore: drop example plugin links from the plugin guide * feat: plugin filters by type, paged browse, hide plugins needing newer termix * feat: move desktop, status, import, audit and branding features into plugins * feat: host lock api so a plugin can load hosts from a config file (#1581) * chore: add hosts config to the plugin table * feat: login policy hook for plugins and optional https redirect (#1584) * chore: add login security to the plugin table * feat: api key socket tickets so a master can open remote sessions (#1654) * feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654) * chore: add sync to the plugin table * feat: move the sync screens and wizard out of core into the plugin (#1580) * fix: add a neutral linked desktop hook for plugins (#1580) * fix: show the linked account's name and admin flag in the app (#1580) * feat: ssh transport providers, core ssh relay and plugin folders api * feat: support FIDO2 security keys through ssh agent * feat: add companion panels beside host tabs * feat: mTLS client certificates for desktop server links * feat: native web view for plugins in the desktop app * fix: detect the desktop link window by Electron's default user agent Fixes #1675 * fix: changelog for desktop link fix * fix: keep text primary keys in the plugin data export * fix: ship sharp binaries for Windows arm64 builds * feat: client cert on the sync link types (sdk 1.0.18) * chore: update translations * feat: themed title bar for the desktop app (#1690) * chore: changelog for the themed title bar * fix: draw the title bar in the sidebar color * feat: per host layout for the terminal panel bar * chore: sdk 1.0.19 for the panel bar layout * chore: bundle all 43 plugins for 26.10.3 * chore: update translations --------- Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com> Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
This commit is contained in:
577 files changed
+76479
-101831
No files matched your search
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Accounts on a desktop. Each local user is one account: the plain local one,
|
||||
* or the replica of a linked server. Exactly one is active, and the app signs
|
||||
* in as it. Switching only changes which user the desktop auto session picks,
|
||||
* so no data is shared between accounts.
|
||||
*/
|
||||
|
||||
import crypto from "crypto";
|
||||
import {
|
||||
createCurrentRoleRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { resolveDesktopAutoSessionUser } from "../database/routes/desktop-auto-session.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
|
||||
const ACTIVE_KEY = "desktop_active_user";
|
||||
|
||||
/** The user the desktop signs in as. Falls back to the usual single-user pick. */
|
||||
export async function getActiveUserId(): Promise<string | null> {
|
||||
const users = await createCurrentUserRepository().listAll();
|
||||
if (users.length === 0) return null;
|
||||
const saved = await createCurrentSettingsRepository().get(ACTIVE_KEY);
|
||||
return resolveDesktopAutoSessionUser(users, saved)?.id ?? null;
|
||||
}
|
||||
|
||||
export async function setActiveUserId(userId: string): Promise<void> {
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
if (!user) throw new Error("No such account");
|
||||
await createCurrentSettingsRepository().set(ACTIVE_KEY, userId);
|
||||
}
|
||||
|
||||
/** Saved choice only, for the auto session route. */
|
||||
export async function getSavedActiveUserId(): Promise<string | null> {
|
||||
return createCurrentSettingsRepository().get(ACTIVE_KEY);
|
||||
}
|
||||
|
||||
export interface DesktopAccount {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export async function listAccounts(): Promise<DesktopAccount[]> {
|
||||
const users = await createCurrentUserRepository().listAll();
|
||||
const active = await getActiveUserId();
|
||||
return users.map((user) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
isAdmin: !!user.isAdmin,
|
||||
active: user.id === active,
|
||||
}));
|
||||
}
|
||||
|
||||
/** A new empty account for another server to be linked to. */
|
||||
export async function createAccount(): Promise<string> {
|
||||
const users = createCurrentUserRepository();
|
||||
const id = crypto.randomUUID();
|
||||
await users.create({
|
||||
id,
|
||||
username: `server-${id.slice(0, 8)}`,
|
||||
passwordHash: "",
|
||||
isAdmin: true,
|
||||
});
|
||||
try {
|
||||
await createCurrentRoleRepository().assignRoleNameToUser({
|
||||
userId: id,
|
||||
roleName: "admin",
|
||||
grantedBy: id,
|
||||
});
|
||||
} catch {
|
||||
// The account still works; admin is also set on the user row.
|
||||
}
|
||||
await AuthManager.getInstance().registerUser(
|
||||
id,
|
||||
crypto.randomBytes(32).toString("hex"),
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
/** Deletes an account and everything it owns. The last account stays. */
|
||||
export async function removeAccount(userId: string): Promise<void> {
|
||||
const users = await createCurrentUserRepository().listAll();
|
||||
if (users.length <= 1) throw new Error("The last account cannot be removed");
|
||||
const active = await getActiveUserId();
|
||||
const { deleteUserAndRelatedData } =
|
||||
await import("../database/routes/delete-user-data.js");
|
||||
await deleteUserAndRelatedData(userId);
|
||||
if (active === userId) {
|
||||
const next = users.find((user) => user.id !== userId);
|
||||
if (next) await setActiveUserId(next.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import os from "os";
|
||||
|
||||
export function defaultDeviceName(): string {
|
||||
return os.hostname() || "Termix Desktop";
|
||||
}
|
||||
@@ -14,7 +14,11 @@ export async function applyElectronProxyConfig(
|
||||
link:
|
||||
| (Pick<
|
||||
SyncLink,
|
||||
"serverUrl" | "customHeaders" | "basicAuth" | "allowInvalidCertificate"
|
||||
| "serverUrl"
|
||||
| "customHeaders"
|
||||
| "basicAuth"
|
||||
| "allowInvalidCertificate"
|
||||
| "clientCert"
|
||||
> & { sessionToken?: string | null; status?: string })
|
||||
| null,
|
||||
): Promise<void> {
|
||||
@@ -30,6 +34,7 @@ export async function applyElectronProxyConfig(
|
||||
headers: link.customHeaders ?? [],
|
||||
basicAuth: link.basicAuth ?? null,
|
||||
allowInvalidCertificate: !!link.allowInvalidCertificate,
|
||||
clientCert: link.clientCert ?? null,
|
||||
}
|
||||
: null,
|
||||
);
|
||||
|
||||
@@ -1,780 +0,0 @@
|
||||
/**
|
||||
* The linked desktop's side of sync.
|
||||
*
|
||||
* A pass pushes local edits, then pulls what changed on the server after the
|
||||
* saved cursor, then backfills any entity type this device has never pulled
|
||||
* (first link, a feature switched on, a category turned back on). Local
|
||||
* edits are found the same way the server finds changes: by comparing each
|
||||
* row's hash with the one recorded at the last sync.
|
||||
*/
|
||||
|
||||
import os from "os";
|
||||
import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
||||
import {
|
||||
getEntity,
|
||||
listEntities,
|
||||
onEntitiesChanged,
|
||||
type RegisteredSyncEntity,
|
||||
} from "../../plugins/sync-registry.js";
|
||||
import { syncLogger } from "../../utils/logger.js";
|
||||
import { sendCoreAlert } from "../../notify/core-notify.js";
|
||||
import { registerCoreSyncEntities } from "../entities.js";
|
||||
import {
|
||||
createResolvers,
|
||||
deleteStoredRow,
|
||||
loadWireRow,
|
||||
loadWireRows,
|
||||
writeWireRow,
|
||||
} from "../store.js";
|
||||
import {
|
||||
listRecords,
|
||||
putRecord,
|
||||
recordKey,
|
||||
saveConflict,
|
||||
setRecordError,
|
||||
type SyncRecord,
|
||||
} from "../records.js";
|
||||
import { orderSelfReferences } from "../wire.js";
|
||||
import { onLocalWrite } from "../server/change-watcher.js";
|
||||
import { getLink, updateLink, type SyncLink } from "./link-store.js";
|
||||
import { RemoteError, remoteFetch, remoteJson } from "./http.js";
|
||||
import { mirrorPlugins } from "./plugins.js";
|
||||
import { applyElectronProxyConfig } from "./electron-proxy.js";
|
||||
|
||||
export interface RemoteChange {
|
||||
seq: number;
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
revision: number;
|
||||
deleted: boolean;
|
||||
row: SyncRow | null;
|
||||
}
|
||||
|
||||
interface ChangesPage {
|
||||
changes: RemoteChange[];
|
||||
nextCursor: number;
|
||||
hasMore: boolean;
|
||||
entityTypes: Array<{
|
||||
type: string;
|
||||
owner: string;
|
||||
order: number;
|
||||
readOnly: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface PushOp {
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
baseRevision: number;
|
||||
op: "upsert" | "delete";
|
||||
row?: SyncRow;
|
||||
}
|
||||
|
||||
type PushResult =
|
||||
| { syncId: string; entityType: string; status: "ok"; revision: number }
|
||||
| {
|
||||
syncId: string;
|
||||
entityType: string;
|
||||
status: "conflict";
|
||||
revision: number;
|
||||
deleted: boolean;
|
||||
row: SyncRow | null;
|
||||
}
|
||||
| { syncId: string; entityType: string; status: "rejected"; reason: string };
|
||||
|
||||
const MAX_BATCH_OPS = 200;
|
||||
const MAX_BATCH_BYTES = 1_000_000;
|
||||
const MAX_PAGES = 500;
|
||||
const SAFETY_INTERVAL_MS = 5 * 60_000;
|
||||
const REFRESH_CHECK_MS = 24 * 60 * 60_000;
|
||||
const DELETED_HASH = "deleted";
|
||||
|
||||
export interface EngineState {
|
||||
running: boolean;
|
||||
/** Local changes found in the last pass that the server has not taken. */
|
||||
pending: number;
|
||||
/** Entity types the server runs, from the last pull. */
|
||||
serverTypes: Array<{ type: string; owner: string; readOnly: boolean }>;
|
||||
lastPassAt: string | null;
|
||||
}
|
||||
|
||||
/** An entity this device could sync: registered here, and on the server. */
|
||||
function syncable(
|
||||
entity: RegisteredSyncEntity,
|
||||
link: SyncLink,
|
||||
serverTypes: Set<string> | null,
|
||||
): boolean {
|
||||
if (link.disabledTypes.includes(entity.type)) return false;
|
||||
if (serverTypes && !serverTypes.has(entity.type)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function sortChanges(changes: RemoteChange[]): RemoteChange[] {
|
||||
const byType = new Map<string, RemoteChange[]>();
|
||||
for (const change of changes) {
|
||||
const list = byType.get(change.entityType) ?? [];
|
||||
list.push(change);
|
||||
byType.set(change.entityType, list);
|
||||
}
|
||||
const types = [...byType.keys()].sort(
|
||||
(a, b) => (getEntity(a)?.order ?? 1e6) - (getEntity(b)?.order ?? 1e6),
|
||||
);
|
||||
const out: RemoteChange[] = [];
|
||||
for (const type of types) {
|
||||
const list = byType.get(type)!;
|
||||
const entity = getEntity(type);
|
||||
// Only the latest record of each row matters.
|
||||
const latest = new Map<string, RemoteChange>();
|
||||
for (const change of list) latest.set(change.syncId, change);
|
||||
const live = [...latest.values()].filter((change) => !change.deleted);
|
||||
const gone = [...latest.values()].filter((change) => change.deleted);
|
||||
if (entity) {
|
||||
const bySyncId = new Map(live.map((change) => [change.syncId, change]));
|
||||
const ordered = orderSelfReferences(
|
||||
entity,
|
||||
live.map((change) => ({
|
||||
...(change.row ?? {}),
|
||||
syncId: change.syncId,
|
||||
})),
|
||||
);
|
||||
out.push(...ordered.map((row) => bySyncId.get(row.syncId as string)!));
|
||||
} else {
|
||||
out.push(...live);
|
||||
}
|
||||
out.push(...gone);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class SyncEngine {
|
||||
private running = false;
|
||||
private again = false;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private safety: NodeJS.Timeout | null = null;
|
||||
private backoffMs = 0;
|
||||
private events: AbortController | null = null;
|
||||
private eventsFor: string | null = null;
|
||||
private lastRefreshCheck = 0;
|
||||
private serverTypes: Set<string> | null = null;
|
||||
private stopFns: Array<() => void> = [];
|
||||
private alerted = false;
|
||||
readonly state: EngineState = {
|
||||
running: false,
|
||||
pending: 0,
|
||||
serverTypes: [],
|
||||
lastPassAt: null,
|
||||
};
|
||||
|
||||
start(): void {
|
||||
if (this.safety) return;
|
||||
registerCoreSyncEntities();
|
||||
this.safety = setInterval(() => this.request(0), SAFETY_INTERVAL_MS);
|
||||
this.safety.unref?.();
|
||||
this.stopFns.push(onLocalWrite(() => this.request(2000)));
|
||||
// A plugin that came up may bring entity types to backfill.
|
||||
this.stopFns.push(onEntitiesChanged(() => this.request(3000)));
|
||||
this.request(3000);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.safety) clearInterval(this.safety);
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
this.safety = this.timer = null;
|
||||
this.closeEvents();
|
||||
for (const stop of this.stopFns.splice(0)) stop();
|
||||
}
|
||||
|
||||
/** Runs a pass after `delayMs`, folding requests that arrive meanwhile. */
|
||||
request(delayMs = 0): void {
|
||||
if (this.timer) {
|
||||
if (delayMs > 0) return;
|
||||
clearTimeout(this.timer);
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
void this.run();
|
||||
}, delayMs);
|
||||
this.timer.unref?.();
|
||||
}
|
||||
|
||||
/** Runs a pass now and waits for it. */
|
||||
async syncNow(): Promise<void> {
|
||||
if (this.running) {
|
||||
this.again = true;
|
||||
while (this.running) await new Promise((r) => setTimeout(r, 100));
|
||||
return;
|
||||
}
|
||||
await this.run();
|
||||
}
|
||||
|
||||
/** Forgets what the server runs, after a relink or unlink. */
|
||||
reset(): void {
|
||||
this.serverTypes = null;
|
||||
this.state.serverTypes = [];
|
||||
this.state.pending = 0;
|
||||
this.alerted = false;
|
||||
this.backoffMs = 0;
|
||||
this.closeEvents();
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
if (this.running) {
|
||||
this.again = true;
|
||||
return;
|
||||
}
|
||||
this.running = this.state.running = true;
|
||||
try {
|
||||
await this.pass();
|
||||
} finally {
|
||||
this.running = this.state.running = false;
|
||||
this.state.lastPassAt = new Date().toISOString();
|
||||
if (this.again) {
|
||||
this.again = false;
|
||||
this.request(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async pass(): Promise<void> {
|
||||
const link = await getLink();
|
||||
if (!link?.sessionToken) {
|
||||
this.closeEvents();
|
||||
return;
|
||||
}
|
||||
await updateLink({ status: "syncing" });
|
||||
|
||||
try {
|
||||
await this.refreshSession(link);
|
||||
const fresh = (await getLink())!;
|
||||
await mirrorPlugins(fresh);
|
||||
|
||||
// A first pass pulls before it pushes, so rows both sides already
|
||||
// have are matched up instead of sent back as edits.
|
||||
const firstPass = fresh.knownTypes.length === 0;
|
||||
if (!firstPass) {
|
||||
await this.push(fresh);
|
||||
await this.pull(fresh);
|
||||
}
|
||||
await this.backfill((await getLink())!);
|
||||
if (firstPass) await this.push((await getLink())!);
|
||||
|
||||
this.backoffMs = 0;
|
||||
this.alerted = false;
|
||||
await updateLink({
|
||||
status: "idle",
|
||||
lastError: null,
|
||||
lastSyncAt: new Date().toISOString(),
|
||||
});
|
||||
const done = (await getLink())!;
|
||||
await applyElectronProxyConfig(done).catch(() => {});
|
||||
this.openEvents(done);
|
||||
} catch (error) {
|
||||
await this.fail(error);
|
||||
}
|
||||
}
|
||||
|
||||
private async fail(error: unknown): Promise<void> {
|
||||
const remote = error instanceof RemoteError ? error : null;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
syncLogger.warn("Sync pass failed", {
|
||||
operation: "sync_pass",
|
||||
kind: remote?.kind ?? "internal",
|
||||
error: message,
|
||||
});
|
||||
|
||||
if (remote?.kind === "signed_out") {
|
||||
const link = await updateLink({
|
||||
status: "signed_out",
|
||||
lastError: message,
|
||||
});
|
||||
await applyElectronProxyConfig(link).catch(() => {});
|
||||
this.closeEvents();
|
||||
if (!this.alerted) {
|
||||
this.alerted = true;
|
||||
await sendCoreAlert({
|
||||
title: "Sync is paused",
|
||||
body: "This device was signed out of the server it syncs with. Sign in again from Sync to keep going.",
|
||||
severity: "warning",
|
||||
category: "termix.sync.signed_out",
|
||||
dedupeKey: "sync:signed_out",
|
||||
audience: "admins",
|
||||
link: { tab: "sync" },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const offline = remote?.kind === "offline";
|
||||
await updateLink({
|
||||
status: offline ? "offline" : "error",
|
||||
lastError: message,
|
||||
});
|
||||
this.backoffMs = Math.min(
|
||||
this.backoffMs ? this.backoffMs * 2 : 15_000,
|
||||
10 * 60_000,
|
||||
);
|
||||
this.request(this.backoffMs);
|
||||
}
|
||||
|
||||
private async refreshSession(link: SyncLink): Promise<void> {
|
||||
if (Date.now() - this.lastRefreshCheck < REFRESH_CHECK_MS) return;
|
||||
const result = await remoteJson<{ token: string | null }>(
|
||||
link,
|
||||
"/sync/v2/session/refresh",
|
||||
{ method: "POST", body: {} },
|
||||
);
|
||||
this.lastRefreshCheck = Date.now();
|
||||
if (result.token) await updateLink({ sessionToken: result.token });
|
||||
}
|
||||
|
||||
/** Local rows the server does not have in this form yet. */
|
||||
async outbox(link: SyncLink): Promise<{
|
||||
ops: PushOp[];
|
||||
hashes: Map<string, string>;
|
||||
}> {
|
||||
const records = await listRecords(link.userId);
|
||||
const resolvers = createResolvers(link.userId);
|
||||
const ops: PushOp[] = [];
|
||||
const hashes = new Map<string, string>();
|
||||
|
||||
for (const entity of listEntities()) {
|
||||
if (entity.readOnly) continue;
|
||||
if (!link.knownTypes.includes(entity.type)) continue;
|
||||
if (!syncable(entity, link, this.serverTypes)) continue;
|
||||
|
||||
const { rows, failed } = await loadWireRows(
|
||||
entity,
|
||||
link.userId,
|
||||
resolvers,
|
||||
);
|
||||
for (const row of rows.values()) {
|
||||
const key = recordKey(entity.type, row.syncId);
|
||||
const record = records.get(key);
|
||||
if (record && !record.deleted && record.hash === row.hash) continue;
|
||||
// Unavailable server rows can recover without another local edit.
|
||||
if (record?.errorHash === row.hash && record.error !== "unavailable") {
|
||||
continue;
|
||||
}
|
||||
ops.push({
|
||||
entityType: entity.type,
|
||||
syncId: row.syncId,
|
||||
baseRevision: record?.revision ?? 0,
|
||||
op: "upsert",
|
||||
row: row.wire,
|
||||
});
|
||||
hashes.set(key, row.hash);
|
||||
}
|
||||
|
||||
for (const record of records.values()) {
|
||||
if (record.entityType !== entity.type || record.deleted) continue;
|
||||
if (record.revision === 0) continue;
|
||||
if (rows.has(record.syncId) || failed.has(record.syncId)) continue;
|
||||
if (entity.covers && !entity.covers(record.syncId)) continue;
|
||||
if (
|
||||
record.errorHash === DELETED_HASH &&
|
||||
record.error !== "unavailable"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
ops.push({
|
||||
entityType: entity.type,
|
||||
syncId: record.syncId,
|
||||
baseRevision: record.revision,
|
||||
op: "delete",
|
||||
});
|
||||
}
|
||||
}
|
||||
return { ops, hashes };
|
||||
}
|
||||
|
||||
private async push(link: SyncLink): Promise<void> {
|
||||
const { ops, hashes } = await this.outbox(link);
|
||||
this.state.pending = ops.length;
|
||||
if (ops.length === 0) return;
|
||||
|
||||
for (const batch of batches(ops)) {
|
||||
const { results } = await remoteJson<{ results: PushResult[] }>(
|
||||
link,
|
||||
"/sync/v2/push",
|
||||
{ method: "POST", body: { ops: batch }, timeoutMs: 120_000 },
|
||||
);
|
||||
const byKey = new Map(
|
||||
batch.map((op) => [recordKey(op.entityType, op.syncId), op]),
|
||||
);
|
||||
for (const result of results) {
|
||||
const key = recordKey(result.entityType, result.syncId);
|
||||
const op = byKey.get(key);
|
||||
if (!op) continue;
|
||||
await this.settle(link, op, result, hashes.get(key) ?? null);
|
||||
}
|
||||
this.state.pending = Math.max(0, this.state.pending - batch.length);
|
||||
}
|
||||
}
|
||||
|
||||
private async settle(
|
||||
link: SyncLink,
|
||||
op: PushOp,
|
||||
result: PushResult,
|
||||
hash: string | null,
|
||||
): Promise<void> {
|
||||
const userId = link.userId;
|
||||
const entity = getEntity(op.entityType);
|
||||
if (!entity) return;
|
||||
|
||||
if (result.status === "ok") {
|
||||
await putRecord(userId, op.entityType, op.syncId, {
|
||||
revision: result.revision,
|
||||
hash: op.op === "delete" ? null : hash,
|
||||
deleted: op.op === "delete",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === "rejected") {
|
||||
await setRecordError(
|
||||
userId,
|
||||
op.entityType,
|
||||
op.syncId,
|
||||
result.reason,
|
||||
hash ?? DELETED_HASH,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// A live row the server could not send says nothing; keep ours to retry.
|
||||
if (!result.deleted && !result.row) return;
|
||||
|
||||
// Conflict: the server moved on since this device last saw the row. Its
|
||||
// version wins here, and a local edit it replaced is kept for the user.
|
||||
if (op.op === "upsert" && op.row) {
|
||||
await saveConflict(
|
||||
userId,
|
||||
op.entityType,
|
||||
op.syncId,
|
||||
op.row,
|
||||
result.revision,
|
||||
);
|
||||
}
|
||||
const resolvers = createResolvers(userId);
|
||||
if (result.deleted) {
|
||||
await deleteStoredRow(entity, userId, op.syncId);
|
||||
await putRecord(userId, op.entityType, op.syncId, {
|
||||
revision: result.revision,
|
||||
hash: null,
|
||||
deleted: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await writeWireRow(
|
||||
entity,
|
||||
userId,
|
||||
{ ...result.row, syncId: op.syncId },
|
||||
resolvers,
|
||||
);
|
||||
const written = await loadWireRow(entity, userId, op.syncId, resolvers);
|
||||
await putRecord(userId, op.entityType, op.syncId, {
|
||||
revision: result.revision,
|
||||
hash: written?.hash ?? null,
|
||||
deleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchChanges(
|
||||
link: SyncLink,
|
||||
cursor: number,
|
||||
types?: string[],
|
||||
): Promise<{ changes: RemoteChange[]; cursor: number }> {
|
||||
const all: RemoteChange[] = [];
|
||||
let next = cursor;
|
||||
for (let page = 0; page < MAX_PAGES; page++) {
|
||||
const query = new URLSearchParams({
|
||||
cursor: String(next),
|
||||
limit: "1000",
|
||||
});
|
||||
if (types?.length) query.set("types", types.join(","));
|
||||
const result = await remoteJson<ChangesPage>(
|
||||
link,
|
||||
`/sync/v2/changes?${query.toString()}`,
|
||||
{ timeoutMs: 120_000 },
|
||||
);
|
||||
all.push(...result.changes);
|
||||
next = result.nextCursor;
|
||||
this.learnTypes(result);
|
||||
if (!result.hasMore) break;
|
||||
}
|
||||
return { changes: all, cursor: next };
|
||||
}
|
||||
|
||||
private learnTypes(page: ChangesPage): void {
|
||||
this.serverTypes = new Set(page.entityTypes.map((entry) => entry.type));
|
||||
this.state.serverTypes = page.entityTypes.map((entry) => ({
|
||||
type: entry.type,
|
||||
owner: entry.owner,
|
||||
readOnly: entry.readOnly,
|
||||
}));
|
||||
}
|
||||
|
||||
private async pull(link: SyncLink): Promise<void> {
|
||||
const { changes, cursor } = await this.fetchChanges(link, link.cursor);
|
||||
const known = new Set(link.knownTypes);
|
||||
await this.apply(
|
||||
link,
|
||||
changes.filter(
|
||||
(change) =>
|
||||
known.has(change.entityType) &&
|
||||
!link.disabledTypes.includes(change.entityType),
|
||||
),
|
||||
false,
|
||||
);
|
||||
await updateLink({ cursor });
|
||||
}
|
||||
|
||||
/** Pulls every row of types this device has not pulled before. */
|
||||
private async backfill(link: SyncLink): Promise<void> {
|
||||
if (!this.serverTypes) {
|
||||
// Only the entity types are wanted, so a one-row page is enough.
|
||||
this.learnTypes(
|
||||
await remoteJson<ChangesPage>(
|
||||
link,
|
||||
`/sync/v2/changes?cursor=${link.cursor}&limit=1`,
|
||||
{ timeoutMs: 120_000 },
|
||||
),
|
||||
);
|
||||
}
|
||||
const fresh = listEntities()
|
||||
.map((entity) => entity.type)
|
||||
.filter(
|
||||
(type) =>
|
||||
!link.knownTypes.includes(type) &&
|
||||
!link.disabledTypes.includes(type) &&
|
||||
this.serverTypes?.has(type),
|
||||
);
|
||||
if (fresh.length === 0) return;
|
||||
|
||||
const { changes, cursor } = await this.fetchChanges(link, 0, fresh);
|
||||
await this.apply(link, changes, true);
|
||||
const current = (await getLink())!;
|
||||
await updateLink({
|
||||
knownTypes: [...new Set([...current.knownTypes, ...fresh])],
|
||||
// On a first pass the backfill is the whole pull, so it sets the cursor.
|
||||
...(link.knownTypes.length === 0 ? { cursor } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes pulled changes. `adopt` is for a first pull of a type: a local row
|
||||
* with no record yet takes the server's version without a conflict.
|
||||
*/
|
||||
private async apply(
|
||||
link: SyncLink,
|
||||
changes: RemoteChange[],
|
||||
adopt: boolean,
|
||||
): Promise<void> {
|
||||
if (changes.length === 0) return;
|
||||
const userId = link.userId;
|
||||
const records = await listRecords(userId);
|
||||
let missed = false;
|
||||
const resolvers = createResolvers(userId, () => {
|
||||
missed = true;
|
||||
});
|
||||
const retry: RemoteChange[] = [];
|
||||
|
||||
for (const change of sortChanges(changes)) {
|
||||
const entity = getEntity(change.entityType);
|
||||
if (!entity) continue;
|
||||
const key = recordKey(change.entityType, change.syncId);
|
||||
const record = records.get(key);
|
||||
if (record && record.revision >= change.revision) continue;
|
||||
|
||||
try {
|
||||
if (!entity.readOnly) {
|
||||
await this.keepLocalEdit(
|
||||
entity,
|
||||
userId,
|
||||
change,
|
||||
record,
|
||||
adopt,
|
||||
resolvers,
|
||||
);
|
||||
}
|
||||
if (change.deleted) {
|
||||
await deleteStoredRow(entity, userId, change.syncId);
|
||||
await putRecord(userId, change.entityType, change.syncId, {
|
||||
revision: change.revision,
|
||||
hash: null,
|
||||
deleted: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
missed = false;
|
||||
await writeWireRow(
|
||||
entity,
|
||||
userId,
|
||||
{ ...(change.row ?? {}), syncId: change.syncId },
|
||||
resolvers,
|
||||
);
|
||||
if (missed) retry.push(change);
|
||||
await this.recordApplied(entity, userId, change, resolvers);
|
||||
} catch (error) {
|
||||
syncLogger.warn("Could not apply a synced change", {
|
||||
operation: "sync_apply",
|
||||
entityType: change.entityType,
|
||||
syncId: change.syncId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Rows that pointed at something written later in the same pull.
|
||||
for (const change of retry) {
|
||||
const entity = getEntity(change.entityType)!;
|
||||
try {
|
||||
await writeWireRow(
|
||||
entity,
|
||||
userId,
|
||||
{ ...(change.row ?? {}), syncId: change.syncId },
|
||||
resolvers,
|
||||
);
|
||||
await this.recordApplied(entity, userId, change, resolvers);
|
||||
} catch {
|
||||
// The first write stands.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async recordApplied(
|
||||
entity: RegisteredSyncEntity,
|
||||
userId: string,
|
||||
change: RemoteChange,
|
||||
resolvers: ReturnType<typeof createResolvers>,
|
||||
): Promise<void> {
|
||||
const written = entity.readOnly
|
||||
? null
|
||||
: await loadWireRow(entity, userId, change.syncId, resolvers);
|
||||
await putRecord(userId, change.entityType, change.syncId, {
|
||||
revision: change.revision,
|
||||
hash: written?.hash ?? null,
|
||||
deleted: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Saves a local edit the incoming change is about to replace. */
|
||||
private async keepLocalEdit(
|
||||
entity: RegisteredSyncEntity,
|
||||
userId: string,
|
||||
change: RemoteChange,
|
||||
record: SyncRecord | undefined,
|
||||
adopt: boolean,
|
||||
resolvers: ReturnType<typeof createResolvers>,
|
||||
): Promise<void> {
|
||||
if (!record && adopt) return;
|
||||
const local = await loadWireRow(
|
||||
entity,
|
||||
userId,
|
||||
change.syncId,
|
||||
resolvers,
|
||||
).catch(() => null);
|
||||
if (!local) return;
|
||||
if (record && !record.deleted && record.hash === local.hash) return;
|
||||
await saveConflict(
|
||||
userId,
|
||||
change.entityType,
|
||||
change.syncId,
|
||||
local.wire,
|
||||
change.revision,
|
||||
);
|
||||
}
|
||||
|
||||
private openEvents(link: SyncLink): void {
|
||||
const key = `${link.serverUrl}|${link.sessionToken}`;
|
||||
if (this.events && this.eventsFor === key) return;
|
||||
this.closeEvents();
|
||||
const controller = new AbortController();
|
||||
this.events = controller;
|
||||
this.eventsFor = key;
|
||||
void this.listen(link, controller);
|
||||
}
|
||||
|
||||
private closeEvents(): void {
|
||||
this.events?.abort();
|
||||
this.events = null;
|
||||
this.eventsFor = null;
|
||||
}
|
||||
|
||||
private async listen(
|
||||
link: SyncLink,
|
||||
controller: AbortController,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await remoteFetch(link, "/sync/v2/events", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok || !response.body)
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
for await (const chunk of response.body) {
|
||||
buffer += decoder.decode(chunk as Uint8Array, { stream: true });
|
||||
let index;
|
||||
while ((index = buffer.indexOf("\n\n")) >= 0) {
|
||||
const block = buffer.slice(0, index);
|
||||
buffer = buffer.slice(index + 2);
|
||||
if (/^event: change$/m.test(block)) this.request(500);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Dropped or refused: the safety interval still syncs, and the next
|
||||
// successful pass reopens the stream.
|
||||
}
|
||||
if (this.events === controller) {
|
||||
this.events = null;
|
||||
this.eventsFor = null;
|
||||
if (!controller.signal.aborted) {
|
||||
setTimeout(() => this.request(0), 30_000).unref?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the link's proxy settings have to reach outside the backend. */
|
||||
async applyProxyConfig(): Promise<void> {
|
||||
await applyElectronProxyConfig(await getLink());
|
||||
}
|
||||
}
|
||||
|
||||
function* batches(ops: PushOp[]): Generator<PushOp[]> {
|
||||
let batch: PushOp[] = [];
|
||||
let bytes = 0;
|
||||
for (const op of ops) {
|
||||
const size = JSON.stringify(op).length;
|
||||
if (
|
||||
batch.length > 0 &&
|
||||
(batch.length >= MAX_BATCH_OPS || bytes + size > MAX_BATCH_BYTES)
|
||||
) {
|
||||
yield batch;
|
||||
batch = [];
|
||||
bytes = 0;
|
||||
}
|
||||
batch.push(op);
|
||||
bytes += size;
|
||||
}
|
||||
if (batch.length > 0) yield batch;
|
||||
}
|
||||
|
||||
let engine: SyncEngine | null = null;
|
||||
|
||||
export function getSyncEngine(): SyncEngine {
|
||||
if (!engine) engine = new SyncEngine();
|
||||
return engine;
|
||||
}
|
||||
|
||||
/** Starts syncing on a desktop. A server install never links. */
|
||||
export function startDesktopSync(): void {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") return;
|
||||
const instance = getSyncEngine();
|
||||
instance.start();
|
||||
void instance.applyProxyConfig().catch(() => {});
|
||||
}
|
||||
|
||||
export function defaultDeviceName(): string {
|
||||
return os.hostname() || "Termix Desktop";
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
import { Agent, ProxyAgent, fetch as undiciFetch } from "undici";
|
||||
import type { Dispatcher } from "undici";
|
||||
import type { BasicAuth, ProxyHeader } from "./link-store.js";
|
||||
import type { BasicAuth, ClientCert, ProxyHeader } from "./link-store.js";
|
||||
|
||||
export interface RemoteTarget {
|
||||
serverUrl: string;
|
||||
@@ -17,6 +17,7 @@ export interface RemoteTarget {
|
||||
customHeaders?: ProxyHeader[];
|
||||
basicAuth?: BasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
clientCert?: ClientCert | null;
|
||||
}
|
||||
|
||||
export type RemoteErrorKind =
|
||||
@@ -24,6 +25,8 @@ export type RemoteErrorKind =
|
||||
| "offline"
|
||||
/** TLS failed and the certificate is not allowed. */
|
||||
| "tls"
|
||||
/** The server wants a client certificate and none was accepted. */
|
||||
| "client_cert"
|
||||
/** A proxy answered instead of Termix (its login page, a 404 page). */
|
||||
| "proxy"
|
||||
/** The proxy wants basic auth. */
|
||||
@@ -53,6 +56,13 @@ const TLS_CODES = new Set([
|
||||
"CERT_UNTRUSTED",
|
||||
]);
|
||||
|
||||
const CLIENT_CERT_CODES = new Set([
|
||||
"ERR_SSL_TLSV13_ALERT_CERTIFICATE_REQUIRED",
|
||||
"ERR_SSL_SSLV3_ALERT_BAD_CERTIFICATE",
|
||||
"ERR_SSL_TLSV1_ALERT_UNKNOWN_CA",
|
||||
"ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE",
|
||||
]);
|
||||
|
||||
function envProxy(url: string): string | null {
|
||||
const proxy =
|
||||
process.env.https_proxy ||
|
||||
@@ -84,10 +94,16 @@ const dispatchers = new Map<string, Dispatcher>();
|
||||
function dispatcherFor(target: RemoteTarget): Dispatcher {
|
||||
const insecure = !!target.allowInvalidCertificate;
|
||||
const proxy = envProxy(target.serverUrl);
|
||||
const key = `${insecure}|${proxy ?? ""}`;
|
||||
const cert = target.clientCert;
|
||||
const key = `${insecure}|${proxy ?? ""}|${cert ? `${cert.passphrase}|${cert.pfx}` : ""}`;
|
||||
let dispatcher = dispatchers.get(key);
|
||||
if (!dispatcher) {
|
||||
const tls = insecure ? { rejectUnauthorized: false } : {};
|
||||
const tls = {
|
||||
...(insecure ? { rejectUnauthorized: false } : {}),
|
||||
...(cert
|
||||
? { pfx: Buffer.from(cert.pfx, "base64"), passphrase: cert.passphrase }
|
||||
: {}),
|
||||
};
|
||||
dispatcher = proxy
|
||||
? new ProxyAgent({ uri: proxy, requestTls: tls })
|
||||
: new Agent({ connect: tls });
|
||||
@@ -141,10 +157,22 @@ export function requestHeaders(target: RemoteTarget): Record<string, string> {
|
||||
return headers;
|
||||
}
|
||||
|
||||
function describeNetworkError(error: unknown): RemoteError {
|
||||
// A server that requires a client certificate often just drops the
|
||||
// connection when it gets none, or one it does not accept.
|
||||
const DROPPED_CODES = new Set(["UND_ERR_SOCKET", "ECONNRESET"]);
|
||||
|
||||
function describeNetworkError(
|
||||
error: unknown,
|
||||
target?: RemoteTarget,
|
||||
): RemoteError {
|
||||
const cause = (error as { cause?: { code?: string; message?: string } })
|
||||
?.cause;
|
||||
const code = cause?.code ?? (error as { code?: string })?.code ?? "";
|
||||
const droppedOverTls =
|
||||
DROPPED_CODES.has(code) && target?.serverUrl.startsWith("https:");
|
||||
if (CLIENT_CERT_CODES.has(code) || droppedOverTls) {
|
||||
return new RemoteError("client_cert", cause?.message || code);
|
||||
}
|
||||
if (TLS_CODES.has(code) || /certificate/i.test(cause?.message ?? "")) {
|
||||
return new RemoteError("tls", cause?.message || code);
|
||||
}
|
||||
@@ -193,13 +221,45 @@ export async function remoteFetch(
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (error) {
|
||||
throw describeNetworkError(error);
|
||||
throw describeNetworkError(error, target);
|
||||
} finally {
|
||||
if (timeout) clearTimeout(timeout);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export interface RemoteStreamRequest {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
/** A string, a buffer or a Node stream. */
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request with the link's proxy headers and certificate rules and a
|
||||
* body that may be a stream. The response body is left unread for the caller.
|
||||
*/
|
||||
export async function remoteStreamFetch(
|
||||
target: RemoteTarget,
|
||||
pathname: string,
|
||||
request: RemoteStreamRequest = {},
|
||||
) {
|
||||
const headers = { ...request.headers, ...requestHeaders(target) };
|
||||
try {
|
||||
return await undiciFetch(remoteUrl(target.serverUrl, pathname), {
|
||||
method: request.method ?? "GET",
|
||||
headers,
|
||||
body: request.body as never,
|
||||
duplex: "half",
|
||||
dispatcher: dispatcherFor(target),
|
||||
signal: request.signal,
|
||||
} as never);
|
||||
} catch (error) {
|
||||
throw describeNetworkError(error, target);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends a request and parses a JSON answer from Termix. */
|
||||
export async function remoteJson<T>(
|
||||
target: RemoteTarget,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/**
|
||||
* The desktop's link to a server, one row in sync_link. Secrets (the session
|
||||
* token, proxy headers, basic auth) are stored encrypted with the system key.
|
||||
* The desktop's links to servers, one row each in sync_link and at most one
|
||||
* per local user, since each link has its own replica user. Secrets (the
|
||||
* session token, proxy headers, basic auth) are stored encrypted with the
|
||||
* system key. Without a user, a call means the active account's link.
|
||||
*/
|
||||
|
||||
import { createSecureContext } from "node:tls";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getActiveUserId } from "../accounts.js";
|
||||
import { syncLink } from "../../database/db/schema.js";
|
||||
import { createCurrentRepositoryContext } from "../../database/repositories/factory.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
@@ -33,6 +37,14 @@ export interface BasicAuth {
|
||||
password: string;
|
||||
}
|
||||
|
||||
/** A PFX/P12 client certificate for servers that require mutual TLS. */
|
||||
export interface ClientCert {
|
||||
/** Base64 of the PFX/P12 file. */
|
||||
pfx: string;
|
||||
passphrase: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export interface SyncLink {
|
||||
id: number;
|
||||
userId: string;
|
||||
@@ -43,11 +55,14 @@ export interface SyncLink {
|
||||
customHeaders: ProxyHeader[];
|
||||
basicAuth: BasicAuth | null;
|
||||
allowInvalidCertificate: boolean;
|
||||
clientCert: ClientCert | null;
|
||||
remoteUserId: string | null;
|
||||
remoteUsername: string | null;
|
||||
account: LinkAccount | null;
|
||||
/** Entity types switched off on this device. */
|
||||
disabledTypes: string[];
|
||||
/** Extra connection options for this server, kept open for new ones. */
|
||||
options: Record<string, unknown>;
|
||||
/** Entity types this device has fully pulled at least once. */
|
||||
knownTypes: string[];
|
||||
cursor: number;
|
||||
@@ -90,7 +105,11 @@ async function fromRow(row: Row): Promise<SyncLink> {
|
||||
sessionToken = null;
|
||||
}
|
||||
}
|
||||
const scope = parseJson<{ disabled?: string[] }>(row.scope, {});
|
||||
const scope = parseJson<{
|
||||
disabled?: string[];
|
||||
options?: Record<string, unknown>;
|
||||
clientCert?: string;
|
||||
}>(row.scope, {});
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
@@ -101,10 +120,16 @@ async function fromRow(row: Row): Promise<SyncLink> {
|
||||
customHeaders: await decryptJson<ProxyHeader[]>(row.customHeaders, []),
|
||||
basicAuth: await decryptJson<BasicAuth | null>(row.basicAuth, null),
|
||||
allowInvalidCertificate: !!row.allowInvalidCertificate,
|
||||
clientCert: await decryptJson<ClientCert | null>(
|
||||
scope.clientCert ?? null,
|
||||
null,
|
||||
),
|
||||
remoteUserId: row.remoteUserId,
|
||||
remoteUsername: row.remoteUsername,
|
||||
account: parseJson<LinkAccount | null>(row.account, null),
|
||||
disabledTypes: Array.isArray(scope.disabled) ? scope.disabled : [],
|
||||
options:
|
||||
scope.options && typeof scope.options === "object" ? scope.options : {},
|
||||
knownTypes: parseJson<string[]>(row.knownTypes, []),
|
||||
cursor: Number(row.cursor) || 0,
|
||||
status: (row.status as LinkStatus) || "idle",
|
||||
@@ -114,8 +139,20 @@ async function fromRow(row: Row): Promise<SyncLink> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLink(): Promise<SyncLink | null> {
|
||||
const [row] = await db().select().from(syncLink).limit(1);
|
||||
export async function listLinks(): Promise<SyncLink[]> {
|
||||
const rows = await db().select().from(syncLink);
|
||||
return Promise.all(rows.map(fromRow));
|
||||
}
|
||||
|
||||
/** The link of one local user, or of the active account when none is given. */
|
||||
export async function getLink(userId?: string): Promise<SyncLink | null> {
|
||||
const owner = userId ?? (await getActiveUserId());
|
||||
if (!owner) return null;
|
||||
const [row] = await db()
|
||||
.select()
|
||||
.from(syncLink)
|
||||
.where(eq(syncLink.userId, owner))
|
||||
.limit(1);
|
||||
return row ? fromRow(row) : null;
|
||||
}
|
||||
|
||||
@@ -128,12 +165,40 @@ export interface NewLink {
|
||||
customHeaders?: ProxyHeader[];
|
||||
basicAuth?: BasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
clientCert?: ClientCert | null;
|
||||
remoteUserId?: string | null;
|
||||
remoteUsername?: string | null;
|
||||
}
|
||||
|
||||
/** Throws when the PFX is unreadable or the passphrase is wrong. */
|
||||
export function assertClientCert(cert: ClientCert): void {
|
||||
try {
|
||||
createSecureContext({
|
||||
pfx: Buffer.from(cert.pfx, "base64"),
|
||||
passphrase: cert.passphrase,
|
||||
});
|
||||
} catch {
|
||||
throw new Error("The client certificate or its passphrase is not valid");
|
||||
}
|
||||
}
|
||||
|
||||
async function sealScope(
|
||||
disabled: string[],
|
||||
options: Record<string, unknown>,
|
||||
clientCert: ClientCert | null,
|
||||
): Promise<string> {
|
||||
return JSON.stringify({
|
||||
disabled,
|
||||
options,
|
||||
...(clientCert
|
||||
? { clientCert: await encryptSystemSecret(JSON.stringify(clientCert)) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createLink(link: NewLink): Promise<SyncLink> {
|
||||
await db().delete(syncLink);
|
||||
if (link.clientCert) assertClientCert(link.clientCert);
|
||||
await db().delete(syncLink).where(eq(syncLink.userId, link.userId));
|
||||
await db()
|
||||
.insert(syncLink)
|
||||
.values({
|
||||
@@ -149,6 +214,7 @@ export async function createLink(link: NewLink): Promise<SyncLink> {
|
||||
? await encryptSystemSecret(JSON.stringify(link.basicAuth))
|
||||
: null,
|
||||
allowInvalidCertificate: !!link.allowInvalidCertificate,
|
||||
scope: link.clientCert ? await sealScope([], {}, link.clientCert) : null,
|
||||
remoteUserId: link.remoteUserId ?? null,
|
||||
remoteUsername: link.remoteUsername ?? null,
|
||||
cursor: 0,
|
||||
@@ -157,7 +223,7 @@ export async function createLink(link: NewLink): Promise<SyncLink> {
|
||||
linkedAt: new Date().toISOString(),
|
||||
});
|
||||
await DatabaseSaveTrigger.forceSave("sync_link");
|
||||
return (await getLink())!;
|
||||
return (await getLink(link.userId))!;
|
||||
}
|
||||
|
||||
export interface LinkPatch {
|
||||
@@ -167,10 +233,12 @@ export interface LinkPatch {
|
||||
customHeaders?: ProxyHeader[];
|
||||
basicAuth?: BasicAuth | null;
|
||||
allowInvalidCertificate?: boolean;
|
||||
clientCert?: ClientCert | null;
|
||||
remoteUserId?: string | null;
|
||||
remoteUsername?: string | null;
|
||||
account?: LinkAccount | null;
|
||||
disabledTypes?: string[];
|
||||
options?: Record<string, unknown>;
|
||||
knownTypes?: string[];
|
||||
cursor?: number;
|
||||
status?: LinkStatus;
|
||||
@@ -178,9 +246,13 @@ export interface LinkPatch {
|
||||
lastSyncAt?: string | null;
|
||||
}
|
||||
|
||||
export async function updateLink(patch: LinkPatch): Promise<SyncLink | null> {
|
||||
const current = await getLink();
|
||||
export async function updateLink(
|
||||
patch: LinkPatch,
|
||||
userId?: string,
|
||||
): Promise<SyncLink | null> {
|
||||
const current = await getLink(userId);
|
||||
if (!current) return null;
|
||||
if (patch.clientCert) assertClientCert(patch.clientCert);
|
||||
const set: Partial<Row> = {};
|
||||
if (patch.serverName !== undefined) set.serverName = patch.serverName;
|
||||
if (patch.serverVersion !== undefined)
|
||||
@@ -210,8 +282,16 @@ export async function updateLink(patch: LinkPatch): Promise<SyncLink | null> {
|
||||
if (patch.account !== undefined) {
|
||||
set.account = patch.account ? JSON.stringify(patch.account) : null;
|
||||
}
|
||||
if (patch.disabledTypes !== undefined) {
|
||||
set.scope = JSON.stringify({ disabled: patch.disabledTypes });
|
||||
if (
|
||||
patch.disabledTypes !== undefined ||
|
||||
patch.options !== undefined ||
|
||||
patch.clientCert !== undefined
|
||||
) {
|
||||
set.scope = await sealScope(
|
||||
patch.disabledTypes ?? current.disabledTypes,
|
||||
patch.options ?? current.options,
|
||||
patch.clientCert === undefined ? current.clientCert : patch.clientCert,
|
||||
);
|
||||
}
|
||||
if (patch.knownTypes !== undefined) {
|
||||
set.knownTypes = JSON.stringify(patch.knownTypes);
|
||||
@@ -224,10 +304,12 @@ export async function updateLink(patch: LinkPatch): Promise<SyncLink | null> {
|
||||
await db().update(syncLink).set(set).where(eq(syncLink.id, current.id));
|
||||
DatabaseSaveTrigger.triggerSave("sync_link");
|
||||
}
|
||||
return getLink();
|
||||
return getLink(current.userId);
|
||||
}
|
||||
|
||||
export async function deleteLink(): Promise<void> {
|
||||
await db().delete(syncLink);
|
||||
export async function deleteLink(userId?: string): Promise<void> {
|
||||
const owner = userId ?? (await getActiveUserId());
|
||||
if (!owner) return;
|
||||
await db().delete(syncLink).where(eq(syncLink.userId, owner));
|
||||
await DatabaseSaveTrigger.forceSave("sync_link");
|
||||
}
|
||||
@@ -11,7 +11,10 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import semver from "semver";
|
||||
import type { PluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import {
|
||||
resolvePluginDesktopMode,
|
||||
type PluginDesktopMode,
|
||||
} from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { syncLogger } from "../../utils/logger.js";
|
||||
import { sendCoreAlert } from "../../notify/core-notify.js";
|
||||
import {
|
||||
@@ -227,7 +230,9 @@ async function mirrorOne(link: SyncLink, plugin: RemotePlugin): Promise<void> {
|
||||
}
|
||||
const { loader } = getPluginRuntime();
|
||||
let local = loader.get(plugin.id);
|
||||
const mode = local?.manifest.desktop ?? plugin.desktop ?? "mirror";
|
||||
const mode = local
|
||||
? resolvePluginDesktopMode(local.manifest)
|
||||
: (plugin.desktop ?? "mirror");
|
||||
|
||||
if (mode === "server") {
|
||||
if (local && (await isEnabledLocally(plugin.id))) {
|
||||
@@ -328,7 +333,7 @@ export async function mirrorPlugins(link: SyncLink): Promise<void> {
|
||||
// A plugin the server no longer has is switched off here. Its data stays.
|
||||
for (const local of getPluginRuntime().loader.list()) {
|
||||
if (remoteIds.has(local.id) || local.source !== "user") continue;
|
||||
if ((local.manifest.desktop ?? "mirror") !== "mirror") continue;
|
||||
if (resolvePluginDesktopMode(local.manifest) !== "mirror") continue;
|
||||
if (await isEnabledLocally(local.id)) {
|
||||
await setPluginEnabled(local.id, false).catch(() => {});
|
||||
}
|
||||
@@ -341,7 +346,7 @@ export async function mirrorPlugins(link: SyncLink): Promise<void> {
|
||||
*/
|
||||
export function isServerManaged(pluginId: string): boolean {
|
||||
const local = getPluginRuntime().loader.get(pluginId);
|
||||
const mode = local?.manifest.desktop ?? "mirror";
|
||||
const mode = local ? resolvePluginDesktopMode(local.manifest) : "mirror";
|
||||
if (mode !== "mirror") return mode === "server";
|
||||
return local?.source !== "user" || vouchedHere(local);
|
||||
}
|
||||
@@ -1,761 +0,0 @@
|
||||
/**
|
||||
* The desktop's own sync routes, under /sync/link. They only exist in the
|
||||
* embedded desktop backend; on a server they answer 404.
|
||||
*/
|
||||
|
||||
import express, {
|
||||
type NextFunction,
|
||||
type Request,
|
||||
type Response,
|
||||
} from "express";
|
||||
import { and, eq, isNotNull } from "drizzle-orm";
|
||||
import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { syncLogger } from "../../utils/logger.js";
|
||||
import { hosts, sshCredentials } from "../../database/db/schema.js";
|
||||
import { createCurrentRepositoryContext } from "../../database/repositories/factory.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { getEntity, listEntities } from "../../plugins/sync-registry.js";
|
||||
import { registerCoreSyncEntities } from "../entities.js";
|
||||
import {
|
||||
createResolvers,
|
||||
deleteStoredRow,
|
||||
listStoredRows,
|
||||
regenerateSyncIds,
|
||||
writeWireRow,
|
||||
} from "../store.js";
|
||||
import {
|
||||
clearRecordErrors,
|
||||
deleteConflict,
|
||||
deleteRecordsForUser,
|
||||
getConflict,
|
||||
listConflicts,
|
||||
listRecordErrors,
|
||||
} from "../records.js";
|
||||
import { SYNC_PROTOCOL_VERSION } from "../protocol.js";
|
||||
import {
|
||||
createLink,
|
||||
deleteLink,
|
||||
getLink,
|
||||
updateLink,
|
||||
type BasicAuth,
|
||||
type ProxyHeader,
|
||||
type SyncLink,
|
||||
} from "./link-store.js";
|
||||
import {
|
||||
normalizeServerUrl,
|
||||
RemoteError,
|
||||
remoteJson,
|
||||
type RemoteTarget,
|
||||
} from "./http.js";
|
||||
import { defaultDeviceName, getSyncEngine } from "./engine.js";
|
||||
import { applyElectronProxyConfig } from "./electron-proxy.js";
|
||||
import { lastRemotePlugins } from "./plugins.js";
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
function desktopOnly(_req: Request, res: Response, next: NextFunction) {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
const guard = [desktopOnly, authenticateJWT];
|
||||
|
||||
function userOf(req: Request): string {
|
||||
return (req as AuthenticatedRequest).userId;
|
||||
}
|
||||
|
||||
function readHeaders(value: unknown): ProxyHeader[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.filter(
|
||||
(header): header is ProxyHeader =>
|
||||
!!header &&
|
||||
typeof header.name === "string" &&
|
||||
typeof header.value === "string" &&
|
||||
/^[A-Za-z0-9-]{1,100}$/.test(header.name.trim()),
|
||||
)
|
||||
.map((header) => ({ name: header.name.trim(), value: header.value }))
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
function readBasicAuth(value: unknown): BasicAuth | null {
|
||||
if (!value || typeof value !== "object") return null;
|
||||
const { username, password } = value as Record<string, unknown>;
|
||||
if (typeof username !== "string" || !username) return null;
|
||||
return {
|
||||
username,
|
||||
password: typeof password === "string" ? password : "",
|
||||
};
|
||||
}
|
||||
|
||||
function readTarget(body: Record<string, unknown>): RemoteTarget | null {
|
||||
const serverUrl =
|
||||
typeof body.serverUrl === "string"
|
||||
? normalizeServerUrl(body.serverUrl)
|
||||
: null;
|
||||
if (!serverUrl) return null;
|
||||
return {
|
||||
serverUrl,
|
||||
customHeaders: readHeaders(body.customHeaders),
|
||||
basicAuth: readBasicAuth(body.basicAuth),
|
||||
allowInvalidCertificate: body.allowInvalidCertificate === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** What the sync panel can show about each entity type. */
|
||||
function describeEntities(link: SyncLink | null) {
|
||||
registerCoreSyncEntities();
|
||||
const names = new Map<string, string>();
|
||||
for (const plugin of getPluginRuntime().loader.list()) {
|
||||
names.set(plugin.id, plugin.manifest.name);
|
||||
}
|
||||
for (const plugin of lastRemotePlugins()) {
|
||||
if (!names.has(plugin.id)) names.set(plugin.id, plugin.name);
|
||||
}
|
||||
const serverTypes = getSyncEngine().state.serverTypes;
|
||||
const seen = new Set<string>();
|
||||
const entries = [
|
||||
...listEntities().map((entity) => ({
|
||||
type: entity.type,
|
||||
owner: entity.owner,
|
||||
readOnly: !!entity.readOnly,
|
||||
})),
|
||||
...serverTypes,
|
||||
];
|
||||
return entries
|
||||
.filter((entry) => {
|
||||
if (seen.has(entry.type) || entry.type === "accountProfile") return false;
|
||||
seen.add(entry.type);
|
||||
return true;
|
||||
})
|
||||
.map((entry) => ({
|
||||
type: entry.type,
|
||||
owner: entry.owner,
|
||||
ownerName:
|
||||
entry.owner === "core" ? null : (names.get(entry.owner) ?? entry.owner),
|
||||
readOnly: entry.readOnly,
|
||||
onServer: serverTypes.some((server) => server.type === entry.type),
|
||||
onDevice: !!getEntity(entry.type),
|
||||
enabled: !link?.disabledTypes.includes(entry.type),
|
||||
}));
|
||||
}
|
||||
|
||||
async function statusBody() {
|
||||
const link = await getLink();
|
||||
const engine = getSyncEngine();
|
||||
if (!link) {
|
||||
return { linked: false, entities: describeEntities(null) };
|
||||
}
|
||||
return {
|
||||
linked: true,
|
||||
serverUrl: link.serverUrl,
|
||||
serverName: link.serverName,
|
||||
serverVersion: link.serverVersion,
|
||||
account: link.account ?? {
|
||||
username: link.remoteUsername ?? undefined,
|
||||
},
|
||||
status: engine.state.running ? "syncing" : link.status,
|
||||
lastError: link.lastError,
|
||||
linkedAt: link.linkedAt,
|
||||
lastSyncAt: link.lastSyncAt,
|
||||
pending: engine.state.pending,
|
||||
conflicts: (await listConflicts(link.userId)).length,
|
||||
errors: (await listRecordErrors(link.userId)).length,
|
||||
firstSyncDone: link.knownTypes.length > 0,
|
||||
allowInvalidCertificate: link.allowInvalidCertificate,
|
||||
customHeaders: link.customHeaders.map((header) => ({
|
||||
name: header.name,
|
||||
value: "",
|
||||
set: true,
|
||||
})),
|
||||
basicAuth: link.basicAuth
|
||||
? { username: link.basicAuth.username, password: "", set: true }
|
||||
: null,
|
||||
entities: describeEntities(link),
|
||||
};
|
||||
}
|
||||
|
||||
/** Removes read-only copies of what other users shared with the account. */
|
||||
async function removeSharedCopies(userId: string): Promise<void> {
|
||||
const db = createCurrentRepositoryContext().drizzle;
|
||||
const sharedHosts = await db
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.userId, userId), isNotNull(hosts.sharedSource)));
|
||||
const { deleteOwnedHost } = await import("../../hosts/delete-host.js");
|
||||
for (const host of sharedHosts) await deleteOwnedHost(userId, host.id);
|
||||
|
||||
const sharedCredentials = await db
|
||||
.select({ id: sshCredentials.id })
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
isNotNull(sshCredentials.sharedSource),
|
||||
),
|
||||
);
|
||||
const { deleteOwnedCredential } =
|
||||
await import("../../hosts/delete-credential.js");
|
||||
for (const credential of sharedCredentials) {
|
||||
await deleteOwnedCredential(userId, credential.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes every row that syncs, keeping local-only ones. */
|
||||
async function wipeSyncedData(userId: string): Promise<void> {
|
||||
const entities = [...listEntities()].reverse();
|
||||
for (const entity of entities) {
|
||||
if (entity.readOnly) continue;
|
||||
try {
|
||||
if (entity.load) {
|
||||
for (const row of await entity.load(userId)) {
|
||||
await entity.erase?.(userId, String(row.syncId));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!entity.table || entity.singleton) continue;
|
||||
for (const row of await listStoredRows(entity, userId)) {
|
||||
if (entity.shouldSync && entity.shouldSync(row) === false) continue;
|
||||
if (typeof row.syncId !== "string") continue;
|
||||
await deleteStoredRow(entity, userId, row.syncId);
|
||||
}
|
||||
} catch (error) {
|
||||
syncLogger.warn("Could not clear synced data", {
|
||||
operation: "sync_wipe",
|
||||
entityType: entity.type,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function problemFor(error: unknown) {
|
||||
if (!(error instanceof RemoteError)) {
|
||||
return { problem: "unreachable", message: String(error) };
|
||||
}
|
||||
switch (error.kind) {
|
||||
case "tls":
|
||||
return { problem: "tls_untrusted", message: error.message };
|
||||
case "basic_auth":
|
||||
return { problem: "basic_auth_required", message: error.message };
|
||||
case "proxy":
|
||||
return error.status === 404
|
||||
? { problem: "not_termix", message: error.message }
|
||||
: { problem: "proxy_login", message: error.message };
|
||||
case "signed_out":
|
||||
case "server":
|
||||
// A Termix older than 2.9 has no /sync/v2 and answers with an error.
|
||||
return { problem: "version_mismatch", message: error.message };
|
||||
default:
|
||||
return { problem: "unreachable", message: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/status:
|
||||
* get:
|
||||
* summary: This desktop's link to a server
|
||||
* description: Desktop only. Whether the app is linked, to which server and account, how syncing is going, and which kinds of data sync.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The link status.
|
||||
*/
|
||||
router.get("/link/status", ...guard, async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json(await statusBody());
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to read sync status", error, {
|
||||
operation: "sync_status",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to read sync status" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/probe:
|
||||
* post:
|
||||
* summary: Check a server address before linking
|
||||
* description: Desktop only. Tries the server with the given proxy settings and says what is in the way, if anything. On success the proxy settings are also applied to the desktop's browser session so the sign-in page loads.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The server's name and version, or the problem found.
|
||||
*/
|
||||
router.post("/link/probe", ...guard, async (req: Request, res: Response) => {
|
||||
const target = readTarget(req.body ?? {});
|
||||
if (!target) return res.json({ ok: false, problem: "invalid_url" });
|
||||
try {
|
||||
const info = await remoteJson<{
|
||||
termix?: boolean;
|
||||
name?: string;
|
||||
version?: string;
|
||||
protocol?: number;
|
||||
}>(target, "/sync/v2/info", { timeoutMs: 15_000 });
|
||||
if (!info?.termix) return res.json({ ok: false, problem: "not_termix" });
|
||||
if (info.protocol !== SYNC_PROTOCOL_VERSION) {
|
||||
return res.json({
|
||||
ok: false,
|
||||
problem: "version_mismatch",
|
||||
version: info.version,
|
||||
});
|
||||
}
|
||||
await applyElectronProxyConfig({
|
||||
serverUrl: target.serverUrl,
|
||||
customHeaders: target.customHeaders ?? [],
|
||||
basicAuth: target.basicAuth ?? null,
|
||||
allowInvalidCertificate: !!target.allowInvalidCertificate,
|
||||
}).catch(() => {});
|
||||
res.json({
|
||||
ok: true,
|
||||
serverUrl: target.serverUrl,
|
||||
name: info.name ?? "Termix",
|
||||
version: info.version ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
res.json({ ok: false, ...problemFor(error) });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/preview:
|
||||
* get:
|
||||
* summary: How much this desktop has that would sync
|
||||
* description: Desktop only. Row counts per kind of data, shown before choosing whether to merge this desktop's data into the account or replace it.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Counts per entity type.
|
||||
*/
|
||||
router.get("/link/preview", ...guard, async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
registerCoreSyncEntities();
|
||||
const counts: Record<string, number> = {};
|
||||
for (const entity of listEntities()) {
|
||||
if (entity.readOnly) continue;
|
||||
try {
|
||||
if (entity.load) {
|
||||
counts[entity.type] = (await entity.load(userId)).length;
|
||||
continue;
|
||||
}
|
||||
if (!entity.table) continue;
|
||||
counts[entity.type] = (await listStoredRows(entity, userId)).filter(
|
||||
(row) => !entity.shouldSync || entity.shouldSync(row) !== false,
|
||||
).length;
|
||||
} catch {
|
||||
counts[entity.type] = 0;
|
||||
}
|
||||
}
|
||||
res.json({ counts });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/complete:
|
||||
* post:
|
||||
* summary: Link this desktop to a server account
|
||||
* description: Desktop only. Takes the session from signing in to the server, trades it for a long-lived desktop session, and starts syncing. "merge" uploads this desktop's data into the account; "replace" deletes it here first and takes the account's.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Linked; the new status.
|
||||
* 400:
|
||||
* description: Missing server or session.
|
||||
* 502:
|
||||
* description: The server refused the session.
|
||||
*/
|
||||
router.post("/link/complete", ...guard, async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
const body = req.body ?? {};
|
||||
const target = readTarget(body);
|
||||
const token = typeof body.token === "string" ? body.token : "";
|
||||
const mode = body.mode === "replace" ? "replace" : "merge";
|
||||
if (!target || !token) {
|
||||
return res.status(400).json({ error: "Missing server or session" });
|
||||
}
|
||||
|
||||
try {
|
||||
const deviceName =
|
||||
typeof body.deviceName === "string" && body.deviceName.trim()
|
||||
? body.deviceName.trim()
|
||||
: defaultDeviceName();
|
||||
const linked = await remoteJson<{
|
||||
token: string;
|
||||
user: { id: string; username: string };
|
||||
server?: { version?: string | null };
|
||||
}>({ ...target, sessionToken: token }, "/sync/v2/link", {
|
||||
method: "POST",
|
||||
body: { deviceName },
|
||||
});
|
||||
|
||||
const previous = await getLink();
|
||||
if (previous) {
|
||||
await removeSharedCopies(previous.userId);
|
||||
await deleteRecordsForUser(previous.userId);
|
||||
}
|
||||
if (mode === "replace") {
|
||||
await wipeSyncedData(userId);
|
||||
}
|
||||
await deleteRecordsForUser(userId);
|
||||
|
||||
const link = await createLink({
|
||||
userId,
|
||||
serverUrl: target.serverUrl,
|
||||
serverName: typeof body.serverName === "string" ? body.serverName : null,
|
||||
serverVersion: linked.server?.version ?? null,
|
||||
sessionToken: linked.token,
|
||||
customHeaders: target.customHeaders,
|
||||
basicAuth: target.basicAuth,
|
||||
allowInvalidCertificate: target.allowInvalidCertificate,
|
||||
remoteUserId: linked.user.id,
|
||||
remoteUsername: linked.user.username,
|
||||
});
|
||||
const engine = getSyncEngine();
|
||||
engine.reset();
|
||||
await applyElectronProxyConfig(link).catch(() => {});
|
||||
engine.request(0);
|
||||
res.json(await statusBody());
|
||||
} catch (error) {
|
||||
syncLogger.warn("Linking to a server failed", {
|
||||
operation: "sync_link_complete",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(502).json({ ...problemFor(error) });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/relogin:
|
||||
* post:
|
||||
* summary: Sign this desktop back in after its session ended
|
||||
* description: Desktop only. Takes a new sign-in session for the same account and resumes syncing where it stopped.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Signed in again; the new status.
|
||||
* 409:
|
||||
* description: The sign-in was for a different account.
|
||||
*/
|
||||
router.post("/link/relogin", ...guard, async (req: Request, res: Response) => {
|
||||
const token = typeof req.body?.token === "string" ? req.body.token : "";
|
||||
const link = await getLink();
|
||||
if (!link || !token) return res.status(400).json({ error: "Not linked" });
|
||||
try {
|
||||
const linked = await remoteJson<{
|
||||
token: string;
|
||||
user: { id: string; username: string };
|
||||
}>({ ...link, sessionToken: token }, "/sync/v2/link", {
|
||||
method: "POST",
|
||||
body: { deviceName: defaultDeviceName() },
|
||||
});
|
||||
if (link.remoteUserId && linked.user.id !== link.remoteUserId) {
|
||||
return res.status(409).json({ error: "different_account" });
|
||||
}
|
||||
await updateLink({
|
||||
sessionToken: linked.token,
|
||||
status: "idle",
|
||||
lastError: null,
|
||||
});
|
||||
getSyncEngine().request(0);
|
||||
res.json(await statusBody());
|
||||
} catch (error) {
|
||||
res.status(502).json({ ...problemFor(error) });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/unlink:
|
||||
* post:
|
||||
* summary: Unlink this desktop from its server
|
||||
* description: Desktop only. Ends the desktop's session on the server when it can be reached. With keepData the synced data stays on this device as its own; otherwise it is deleted. Copies of what others shared with the account are always removed.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Unlinked.
|
||||
*/
|
||||
router.post("/link/unlink", ...guard, async (req: Request, res: Response) => {
|
||||
const keepData = req.body?.keepData !== false;
|
||||
const link = await getLink();
|
||||
if (!link) return res.json(await statusBody());
|
||||
const userId = link.userId;
|
||||
try {
|
||||
await remoteJson(link, "/sync/v2/unlink", {
|
||||
method: "POST",
|
||||
body: {},
|
||||
timeoutMs: 10_000,
|
||||
}).catch(() => {});
|
||||
|
||||
const engine = getSyncEngine();
|
||||
engine.reset();
|
||||
await removeSharedCopies(userId);
|
||||
if (keepData) {
|
||||
// New syncIds so this data reads as new if it is ever linked again.
|
||||
for (const entity of listEntities()) {
|
||||
if (!entity.readOnly) await regenerateSyncIds(entity, userId);
|
||||
}
|
||||
} else {
|
||||
await wipeSyncedData(userId);
|
||||
}
|
||||
await deleteRecordsForUser(userId);
|
||||
await deleteLink();
|
||||
await applyElectronProxyConfig(null).catch(() => {});
|
||||
res.json(await statusBody());
|
||||
} catch (error) {
|
||||
syncLogger.error("Unlinking failed", error, { operation: "sync_unlink" });
|
||||
res.status(500).json({ error: "Failed to unlink" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/sync:
|
||||
* post:
|
||||
* summary: Sync now
|
||||
* description: Desktop only. Runs a sync pass and waits for it.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The status after the pass.
|
||||
*/
|
||||
router.post("/link/sync", ...guard, async (_req: Request, res: Response) => {
|
||||
await getSyncEngine().syncNow();
|
||||
res.json(await statusBody());
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/settings:
|
||||
* put:
|
||||
* summary: Change what syncs and how the server is reached
|
||||
* description: Desktop only. disabledTypes lists the kinds of data that stay on this device. Proxy headers and basic auth left blank keep their saved values.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The new status.
|
||||
* 400:
|
||||
* description: Not linked.
|
||||
*/
|
||||
router.put("/link/settings", ...guard, async (req: Request, res: Response) => {
|
||||
const link = await getLink();
|
||||
if (!link) return res.status(400).json({ error: "Not linked" });
|
||||
const body = req.body ?? {};
|
||||
const patch: Parameters<typeof updateLink>[0] = {};
|
||||
|
||||
if (Array.isArray(body.disabledTypes)) {
|
||||
const disabled = body.disabledTypes.filter(
|
||||
(type: unknown): type is string =>
|
||||
typeof type === "string" && type !== "accountProfile",
|
||||
);
|
||||
const changed = new Set([
|
||||
...disabled.filter((type: string) => !link.disabledTypes.includes(type)),
|
||||
...link.disabledTypes.filter((type) => !disabled.includes(type)),
|
||||
]);
|
||||
patch.disabledTypes = disabled;
|
||||
// A type switched back on is pulled again in full.
|
||||
patch.knownTypes = link.knownTypes.filter((type) => !changed.has(type));
|
||||
}
|
||||
if (Array.isArray(body.customHeaders)) {
|
||||
const saved = new Map(link.customHeaders.map((h) => [h.name, h.value]));
|
||||
patch.customHeaders = (body.customHeaders as unknown[])
|
||||
.filter(
|
||||
(header): header is { name: string; value?: string } =>
|
||||
!!header && typeof (header as { name?: unknown }).name === "string",
|
||||
)
|
||||
.map((header) => ({
|
||||
name: header.name,
|
||||
value: header.value || saved.get(header.name) || "",
|
||||
}))
|
||||
.filter((header) => header.value);
|
||||
patch.customHeaders = readHeaders(patch.customHeaders);
|
||||
}
|
||||
if (body.basicAuth === null) {
|
||||
patch.basicAuth = null;
|
||||
} else if (body.basicAuth && typeof body.basicAuth === "object") {
|
||||
const next = readBasicAuth(body.basicAuth);
|
||||
if (next && !next.password && link.basicAuth) {
|
||||
next.password = link.basicAuth.password;
|
||||
}
|
||||
patch.basicAuth = next;
|
||||
}
|
||||
if (typeof body.allowInvalidCertificate === "boolean") {
|
||||
patch.allowInvalidCertificate = body.allowInvalidCertificate;
|
||||
}
|
||||
|
||||
const updated = await updateLink(patch);
|
||||
await applyElectronProxyConfig(updated).catch(() => {});
|
||||
getSyncEngine().request(0);
|
||||
res.json(await statusBody());
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/conflicts:
|
||||
* get:
|
||||
* summary: Local edits that lost to a newer edit on the server
|
||||
* description: Desktop only. The server's version was kept; each entry holds this device's version so it can be restored.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The conflicts, oldest first.
|
||||
*/
|
||||
router.get("/link/conflicts", ...guard, async (req: Request, res: Response) => {
|
||||
const conflicts = await listConflicts(userOf(req));
|
||||
res.json({
|
||||
conflicts: conflicts.map((conflict) => {
|
||||
let row: SyncRow = {};
|
||||
try {
|
||||
row = JSON.parse(conflict.localRow);
|
||||
} catch {
|
||||
row = {};
|
||||
}
|
||||
return {
|
||||
id: conflict.id,
|
||||
entityType: conflict.entityType,
|
||||
syncId: conflict.syncId,
|
||||
name: row.name ?? row.title ?? row.label ?? row.ip ?? conflict.syncId,
|
||||
createdAt: conflict.createdAt,
|
||||
};
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/conflicts/{id}:
|
||||
* post:
|
||||
* summary: Settle a conflict
|
||||
* description: Desktop only. keep "mine" puts this device's version back, and the next sync sends it to the server. keep "server" drops it.
|
||||
* tags:
|
||||
* - Sync
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Settled.
|
||||
* 404:
|
||||
* description: No such conflict.
|
||||
*/
|
||||
router.post(
|
||||
"/link/conflicts/:id",
|
||||
...guard,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
const conflict = await getConflict(userId, Number(req.params.id));
|
||||
if (!conflict) return res.status(404).json({ error: "Not found" });
|
||||
try {
|
||||
if (req.body?.keep === "mine") {
|
||||
const entity = getEntity(conflict.entityType);
|
||||
if (entity && !entity.readOnly) {
|
||||
const row = JSON.parse(conflict.localRow) as SyncRow;
|
||||
await writeWireRow(
|
||||
entity,
|
||||
userId,
|
||||
{ ...row, syncId: conflict.syncId },
|
||||
createResolvers(userId),
|
||||
);
|
||||
}
|
||||
}
|
||||
await deleteConflict(userId, conflict.id);
|
||||
getSyncEngine().request(0);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
syncLogger.warn("Could not settle a sync conflict", {
|
||||
operation: "sync_conflict",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
res.status(500).json({ error: "Failed to settle conflict" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/errors:
|
||||
* get:
|
||||
* summary: Changes the server refused
|
||||
* description: Desktop only. Each entry names the record and why the server refused it. A refused change is not sent again until it changes or is retried.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The refused changes.
|
||||
*/
|
||||
router.get("/link/errors", ...guard, async (req: Request, res: Response) => {
|
||||
const errors = await listRecordErrors(userOf(req));
|
||||
res.json({
|
||||
errors: errors.map((record) => ({
|
||||
entityType: record.entityType,
|
||||
syncId: record.syncId,
|
||||
reason: record.error,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/errors/retry:
|
||||
* post:
|
||||
* summary: Send refused changes again
|
||||
* description: Desktop only. Clears every refusal and syncs.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The status after the pass.
|
||||
*/
|
||||
router.post(
|
||||
"/link/errors/retry",
|
||||
...guard,
|
||||
async (req: Request, res: Response) => {
|
||||
await clearRecordErrors(userOf(req));
|
||||
await getSyncEngine().syncNow();
|
||||
res.json(await statusBody());
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/link/session:
|
||||
* get:
|
||||
* summary: The linked server and session, for connecting to it directly
|
||||
* description: Desktop only. Used when a host is set to connect through the server rather than from this device.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The server address and session token, or nulls when not linked.
|
||||
*/
|
||||
router.get("/link/session", ...guard, async (_req: Request, res: Response) => {
|
||||
const link = await getLink();
|
||||
if (!link?.sessionToken || link.status === "signed_out") {
|
||||
return res.json({ serverUrl: null, token: null });
|
||||
}
|
||||
res.json({ serverUrl: link.serverUrl, token: link.sessionToken });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,544 @@
|
||||
/**
|
||||
* What the sync provider plugin gets from core through ctx.sync.host: the
|
||||
* replica store, the change feed, the linked server registry, sessions and
|
||||
* a few helpers. Core stays neutral; the plugin decides what to do with it.
|
||||
*/
|
||||
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import * as tar from "tar";
|
||||
import { and, eq, isNotNull } from "drizzle-orm";
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import type { PluginSyncHost, SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
||||
import { resolvePluginDesktopMode } from "@termix-ssh/plugin-sdk/manifest";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { getLocalVersion } from "../utils/app-version.js";
|
||||
import { syncLogger } from "../utils/logger.js";
|
||||
import { sendCoreAlert } from "../notify/core-notify.js";
|
||||
import { getPluginRuntime } from "../plugins/index.js";
|
||||
import {
|
||||
getEntity,
|
||||
listEntities,
|
||||
onEntitiesChanged,
|
||||
type RegisteredSyncEntity,
|
||||
} from "../plugins/sync-registry.js";
|
||||
import {
|
||||
createCurrentHostRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentPluginPermissionGrantRepository,
|
||||
createCurrentPluginRepository,
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentSessionRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserRepository,
|
||||
} from "../database/repositories/factory.js";
|
||||
import { resolveBrandingSettings } from "../database/routes/branding-settings.js";
|
||||
import { hosts, sshCredentials } from "../database/db/schema.js";
|
||||
import {
|
||||
createResolvers,
|
||||
deleteStoredRow,
|
||||
listStoredRows,
|
||||
loadWireRow,
|
||||
loadWireRows,
|
||||
regenerateSyncIds,
|
||||
writeWireRow,
|
||||
type Resolvers,
|
||||
} from "./store.js";
|
||||
import {
|
||||
clearRecordErrors,
|
||||
deleteConflict,
|
||||
deleteRecordsForUser,
|
||||
getConflict,
|
||||
listChanges,
|
||||
listConflicts,
|
||||
listRecordErrors,
|
||||
listRecords,
|
||||
putRecord,
|
||||
recordKey,
|
||||
saveConflict,
|
||||
setRecordError,
|
||||
} from "./records.js";
|
||||
import { orderSelfReferences } from "./wire.js";
|
||||
import { onLocalWrite } from "./server/write-listeners.js";
|
||||
import {
|
||||
createLink,
|
||||
deleteLink,
|
||||
getLink,
|
||||
listLinks,
|
||||
updateLink,
|
||||
} from "./client/link-store.js";
|
||||
import {
|
||||
createAccount,
|
||||
getActiveUserId,
|
||||
listAccounts,
|
||||
removeAccount,
|
||||
setActiveUserId,
|
||||
} from "./accounts.js";
|
||||
import {
|
||||
normalizeServerUrl,
|
||||
RemoteError,
|
||||
remoteFetch,
|
||||
remoteJson,
|
||||
} from "./client/http.js";
|
||||
import { applyElectronProxyConfig } from "./client/electron-proxy.js";
|
||||
import { lastRemotePlugins, mirrorPlugins } from "./client/plugins.js";
|
||||
import { defaultDeviceName } from "./client/device.js";
|
||||
|
||||
function entityOf(type: string): RegisteredSyncEntity {
|
||||
const entity = getEntity(type);
|
||||
if (!entity) throw new Error(`Unknown sync entity "${type}"`);
|
||||
return entity;
|
||||
}
|
||||
|
||||
function info(entity: RegisteredSyncEntity) {
|
||||
return {
|
||||
type: entity.type,
|
||||
owner: entity.owner,
|
||||
order: entity.order,
|
||||
readOnly: !!entity.readOnly,
|
||||
covers: entity.covers,
|
||||
};
|
||||
}
|
||||
|
||||
/** Deletes every row that syncs, keeping local-only ones. */
|
||||
async function wipeSyncedData(userId: string): Promise<void> {
|
||||
const entities = [...listEntities()].reverse();
|
||||
for (const entity of entities) {
|
||||
if (entity.readOnly) continue;
|
||||
try {
|
||||
if (entity.load) {
|
||||
for (const row of await entity.load(userId)) {
|
||||
await entity.erase?.(userId, String(row.syncId));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!entity.table || entity.singleton) continue;
|
||||
for (const row of await listStoredRows(entity, userId)) {
|
||||
if (entity.shouldSync && entity.shouldSync(row) === false) continue;
|
||||
if (typeof row.syncId !== "string") continue;
|
||||
await deleteStoredRow(entity, userId, row.syncId);
|
||||
}
|
||||
} catch (error) {
|
||||
syncLogger.warn("Could not clear synced data", {
|
||||
operation: "sync_wipe",
|
||||
entityType: entity.type,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes read-only copies of what other users shared with the account. */
|
||||
async function removeSharedCopies(userId: string): Promise<void> {
|
||||
const db = createCurrentRepositoryContext().drizzle;
|
||||
const sharedHosts = await db
|
||||
.select({ id: hosts.id })
|
||||
.from(hosts)
|
||||
.where(and(eq(hosts.userId, userId), isNotNull(hosts.sharedSource)));
|
||||
const { deleteOwnedHost } = await import("../hosts/delete-host.js");
|
||||
for (const host of sharedHosts) await deleteOwnedHost(userId, host.id);
|
||||
|
||||
const sharedCredentials = await db
|
||||
.select({ id: sshCredentials.id })
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.userId, userId),
|
||||
isNotNull(sshCredentials.sharedSource),
|
||||
),
|
||||
);
|
||||
const { deleteOwnedCredential } =
|
||||
await import("../hosts/delete-credential.js");
|
||||
for (const credential of sharedCredentials) {
|
||||
await deleteOwnedCredential(userId, credential.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCoreEntities(): Promise<void> {
|
||||
const { registerCoreSyncEntities } = await import("./entities.js");
|
||||
registerCoreSyncEntities();
|
||||
}
|
||||
|
||||
async function counts(userId: string): Promise<Record<string, number>> {
|
||||
await ensureCoreEntities();
|
||||
const result: Record<string, number> = {};
|
||||
for (const entity of listEntities()) {
|
||||
if (entity.readOnly) continue;
|
||||
try {
|
||||
if (entity.load) {
|
||||
result[entity.type] = (await entity.load(userId)).length;
|
||||
continue;
|
||||
}
|
||||
if (!entity.table) continue;
|
||||
result[entity.type] = (await listStoredRows(entity, userId)).filter(
|
||||
(row) => !entity.shouldSync || entity.shouldSync(row) !== false,
|
||||
).length;
|
||||
} catch {
|
||||
result[entity.type] = 0;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function listPluginsForDesktop() {
|
||||
const records = new Map(
|
||||
(await createCurrentPluginRepository().listAll()).map((record) => [
|
||||
record.id,
|
||||
record,
|
||||
]),
|
||||
);
|
||||
const grants = createCurrentPluginPermissionGrantRepository();
|
||||
const plugins = [];
|
||||
for (const plugin of getPluginRuntime().loader.list()) {
|
||||
plugins.push({
|
||||
id: plugin.id,
|
||||
name: plugin.manifest.name,
|
||||
version: plugin.manifest.version,
|
||||
source: plugin.source,
|
||||
// A bundled plugin updated from a signed .tmxplug can be mirrored.
|
||||
artifact: !!plugin.artifact,
|
||||
enabled: records.get(plugin.id)?.state === "enabled",
|
||||
active: plugin.state === "active",
|
||||
desktop: resolvePluginDesktopMode(plugin.manifest),
|
||||
syncEntities: plugin.manifest.contributes?.syncEntities ?? [],
|
||||
granted: (await grants.listByPlugin(plugin.id)).map(
|
||||
(grant) => grant.capability,
|
||||
),
|
||||
});
|
||||
}
|
||||
const { customRegistryKeys } = await import("../plugins/registries.js");
|
||||
return { plugins, registryKeys: await customRegistryKeys() };
|
||||
}
|
||||
|
||||
async function sendPackage(
|
||||
pluginId: string,
|
||||
target: unknown,
|
||||
): Promise<boolean> {
|
||||
const res = target as Response;
|
||||
const plugin = getPluginRuntime()
|
||||
.loader.list()
|
||||
.find((candidate) => candidate.id === pluginId);
|
||||
if (!plugin || (plugin.source !== "user" && !plugin.artifact)) return false;
|
||||
const artifact = plugin.artifact ?? "";
|
||||
res.setHeader("Content-Type", "application/gzip");
|
||||
if (fs.existsSync(artifact)) {
|
||||
const sig = `${artifact}.sig`;
|
||||
if (fs.existsSync(sig)) {
|
||||
res.setHeader(
|
||||
"X-Termix-Signature",
|
||||
Buffer.from(await fs.promises.readFile(sig, "utf8")).toString("base64"),
|
||||
);
|
||||
}
|
||||
fs.createReadStream(artifact).pipe(res);
|
||||
return true;
|
||||
}
|
||||
const temp = path.join(
|
||||
os.tmpdir(),
|
||||
`termix-plugin-${pluginId}-${Date.now()}.tmxplug`,
|
||||
);
|
||||
await tar.c({ gzip: true, cwd: plugin.dir, file: temp }, ["."]);
|
||||
const stream = fs.createReadStream(temp);
|
||||
stream.on("close", () => void fs.promises.rm(temp, { force: true }));
|
||||
stream.pipe(res);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function createSyncHost(): PluginSyncHost {
|
||||
const authManager = AuthManager.getInstance();
|
||||
|
||||
return {
|
||||
embedded: process.env.ELECTRON_EMBEDDED === "true",
|
||||
info: {
|
||||
appName: async () => {
|
||||
try {
|
||||
return (
|
||||
await resolveBrandingSettings(createCurrentSettingsRepository())
|
||||
).appName;
|
||||
} catch {
|
||||
return "Termix";
|
||||
}
|
||||
},
|
||||
version: () => getLocalVersion(),
|
||||
},
|
||||
|
||||
entities: {
|
||||
list: () => listEntities().map(info),
|
||||
get: (type) => {
|
||||
const entity = getEntity(type);
|
||||
return entity ? info(entity) : undefined;
|
||||
},
|
||||
ensureCore: () => ensureCoreEntities(),
|
||||
onChanged: (listener) => onEntitiesChanged(listener),
|
||||
onLocalWrite: (listener) => onLocalWrite(listener),
|
||||
},
|
||||
|
||||
replica: {
|
||||
resolvers: (userId, onMissing) => createResolvers(userId, onMissing),
|
||||
loadRows: async (type, userId, resolvers) => {
|
||||
const result = await loadWireRows(
|
||||
entityOf(type),
|
||||
userId,
|
||||
resolvers as Resolvers,
|
||||
);
|
||||
return { rows: result.rows, failed: result.failed };
|
||||
},
|
||||
loadRow: (type, userId, syncId, resolvers) =>
|
||||
loadWireRow(entityOf(type), userId, syncId, resolvers as Resolvers),
|
||||
writeRow: async (type, userId, wire, resolvers) => {
|
||||
await writeWireRow(
|
||||
entityOf(type),
|
||||
userId,
|
||||
wire,
|
||||
resolvers as Resolvers,
|
||||
);
|
||||
},
|
||||
deleteRow: async (type, userId, syncId) => {
|
||||
await deleteStoredRow(entityOf(type), userId, syncId);
|
||||
},
|
||||
orderSelfReferences: (type, rows) =>
|
||||
orderSelfReferences(entityOf(type), rows as SyncRow[]),
|
||||
counts: (userId) => counts(userId),
|
||||
wipe: (userId) => wipeSyncedData(userId),
|
||||
regenerateSyncIds: async (userId) => {
|
||||
for (const entity of listEntities()) {
|
||||
if (!entity.readOnly) await regenerateSyncIds(entity, userId);
|
||||
}
|
||||
},
|
||||
removeSharedCopies: (userId) => removeSharedCopies(userId),
|
||||
records: {
|
||||
list: (userId) => listRecords(userId) as never,
|
||||
put: (userId, type, syncId, values) =>
|
||||
putRecord(userId, type, syncId, values),
|
||||
setError: (userId, type, syncId, reason, hash) =>
|
||||
setRecordError(userId, type, syncId, reason, hash),
|
||||
clearErrors: (userId) => clearRecordErrors(userId),
|
||||
listErrors: (userId) => listRecordErrors(userId) as never,
|
||||
deleteForUser: (userId) => deleteRecordsForUser(userId),
|
||||
key: (type, syncId) => recordKey(type, syncId),
|
||||
},
|
||||
conflicts: {
|
||||
save: (userId, type, syncId, row, revision) =>
|
||||
saveConflict(userId, type, syncId, row, revision),
|
||||
list: (userId) => listConflicts(userId) as never,
|
||||
get: (userId, id) => getConflict(userId, id) as never,
|
||||
delete: (userId, id) => deleteConflict(userId, id),
|
||||
},
|
||||
},
|
||||
|
||||
feed: {
|
||||
withUserLock: async (userId, run) => {
|
||||
const { withUserLock } = await import("./server/feed.js");
|
||||
return withUserLock(userId, run);
|
||||
},
|
||||
changes: async (userId, cursor, limit, types) => {
|
||||
const { withUserLock, reconcileUser } =
|
||||
await import("./server/feed.js");
|
||||
const page = await withUserLock(userId, async () => {
|
||||
const { wire } = await reconcileUser(userId);
|
||||
const records = await listChanges(userId, cursor, limit + 1, types);
|
||||
return { wire, records };
|
||||
});
|
||||
const hasMore = page.records.length > limit;
|
||||
const records = [];
|
||||
for (const record of page.records.slice(0, limit)) {
|
||||
if (!getEntity(record.entityType)) continue;
|
||||
const row = record.deleted
|
||||
? null
|
||||
: (page.wire.get(recordKey(record.entityType, record.syncId)) ??
|
||||
null);
|
||||
// A live record with no row failed to serialize; it comes again once
|
||||
// it changes, rather than arriving as a delete.
|
||||
if (!record.deleted && !row) continue;
|
||||
records.push({
|
||||
seq: record.seq,
|
||||
entityType: record.entityType,
|
||||
syncId: record.syncId,
|
||||
revision: record.revision,
|
||||
deleted: !!record.deleted,
|
||||
row,
|
||||
});
|
||||
}
|
||||
const slice = page.records.slice(0, limit);
|
||||
return {
|
||||
records,
|
||||
hasMore,
|
||||
nextCursor: slice.length ? slice[slice.length - 1].seq : cursor,
|
||||
entityTypes: listEntities().map((entity) => ({
|
||||
type: entity.type,
|
||||
owner: entity.owner,
|
||||
order: entity.order,
|
||||
readOnly: !!entity.readOnly,
|
||||
})),
|
||||
} as never;
|
||||
},
|
||||
parsePushOps: async (body) => {
|
||||
const { parsePushOps } = await import("./server/push.js");
|
||||
return parsePushOps(body) as never;
|
||||
},
|
||||
applyPush: async (userId, ops) => {
|
||||
const { withUserLock } = await import("./server/feed.js");
|
||||
const { applyPush } = await import("./server/push.js");
|
||||
return withUserLock(userId, () =>
|
||||
applyPush(userId, ops as never),
|
||||
) as never;
|
||||
},
|
||||
listen: async (userId, res) => {
|
||||
const { addListener } = await import("./server/feed.js");
|
||||
return addListener(userId, res as Response);
|
||||
},
|
||||
},
|
||||
|
||||
links: {
|
||||
list: () => listLinks() as never,
|
||||
get: (userId) => getLink(userId) as never,
|
||||
create: (link) => createLink(link) as never,
|
||||
update: (patch, userId) => updateLink(patch, userId) as never,
|
||||
remove: (userId) => deleteLink(userId),
|
||||
},
|
||||
|
||||
accounts: {
|
||||
list: () => listAccounts(),
|
||||
create: () => createAccount(),
|
||||
setActive: (userId) => setActiveUserId(userId),
|
||||
activeId: () => getActiveUserId(),
|
||||
remove: (userId) => removeAccount(userId),
|
||||
},
|
||||
|
||||
http: {
|
||||
json: (target, pathname, request) =>
|
||||
remoteJson(target, pathname, request),
|
||||
fetch: ((target, pathname, request) =>
|
||||
remoteFetch(target, pathname, request)) as never,
|
||||
normalizeServerUrl,
|
||||
isRemoteError: ((error: unknown) =>
|
||||
error instanceof RemoteError) as never,
|
||||
},
|
||||
|
||||
sessions: {
|
||||
issueDesktop: (userId, options) =>
|
||||
authManager.generateJWTToken(userId, {
|
||||
expiresIn: `${options.days}d`,
|
||||
deviceType: "desktop",
|
||||
deviceInfo: options.deviceInfo,
|
||||
}),
|
||||
revoke: async (sessionId) => {
|
||||
await authManager.revokeSession(sessionId);
|
||||
},
|
||||
find: async (sessionId) => {
|
||||
const session =
|
||||
await createCurrentSessionRepository().findById(sessionId);
|
||||
return session
|
||||
? { expiresAt: session.expiresAt, deviceInfo: session.deviceInfo }
|
||||
: null;
|
||||
},
|
||||
findUser: async (userId) => {
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
return user ? { id: user.id, username: user.username } : null;
|
||||
},
|
||||
verify: async (token) => {
|
||||
const payload = await authManager.verifyJWTToken(token);
|
||||
return payload
|
||||
? { userId: payload.userId, sessionId: payload.sessionId }
|
||||
: null;
|
||||
},
|
||||
setCookie: (req, res, value, options) => {
|
||||
(res as Response).cookie(
|
||||
options?.name ?? "jwt",
|
||||
value,
|
||||
authManager.getSecureCookieOptions(
|
||||
req as Request,
|
||||
(options?.days ?? 30) * 24 * 60 * 60 * 1000,
|
||||
),
|
||||
);
|
||||
},
|
||||
clearCookie: (req, res, name) => {
|
||||
(res as Response).clearCookie(
|
||||
name ?? "jwt",
|
||||
authManager.getClearCookieOptions(req as Request),
|
||||
);
|
||||
},
|
||||
middleware: () => authManager.createAuthMiddleware(),
|
||||
desktopOnly: () => (_req: Request, res: Response, next: NextFunction) => {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
},
|
||||
},
|
||||
|
||||
hosts: {
|
||||
findForSync: async (userId, syncId) => {
|
||||
const hostId =
|
||||
await createCurrentHostResolutionRepository().findHostIdBySyncId(
|
||||
syncId,
|
||||
);
|
||||
if (hostId === null) return null;
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"connect",
|
||||
);
|
||||
if (!access.hasAccess) return null;
|
||||
const row = await createCurrentHostRepository().findById(hostId);
|
||||
return { id: hostId, name: row?.name ?? row?.ip ?? null };
|
||||
},
|
||||
statusBySyncId: async (userId) => {
|
||||
const { hostStatus } =
|
||||
await import("../plugins/host-status-provider.js");
|
||||
const { PermissionManager } =
|
||||
await import("../utils/permission-manager.js");
|
||||
const statuses = await hostStatus.statusesFor(userId, null);
|
||||
const entries = [...statuses.entries()];
|
||||
const allowed =
|
||||
await PermissionManager.getInstance().filterAccessibleHostIds(
|
||||
userId,
|
||||
entries.map(([id]) => id),
|
||||
);
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [id, entry] of entries) {
|
||||
if (!allowed.has(id)) continue;
|
||||
const row = await hostRepository.findById(id);
|
||||
if (row?.syncId) result[row.syncId] = entry;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
},
|
||||
|
||||
plugins: {
|
||||
listForDesktop: listPluginsForDesktop as never,
|
||||
sendPackage,
|
||||
mirror: (link) => mirrorPlugins(link as never),
|
||||
lastRemote: () => lastRemotePlugins() as never,
|
||||
localNames: () => {
|
||||
const names = new Map<string, string>();
|
||||
for (const plugin of getPluginRuntime().loader.list()) {
|
||||
names.set(plugin.id, plugin.manifest.name);
|
||||
}
|
||||
return names;
|
||||
},
|
||||
},
|
||||
|
||||
desktop: {
|
||||
applyProxy: (link) => applyElectronProxyConfig(link as never),
|
||||
deviceName: defaultDeviceName,
|
||||
},
|
||||
|
||||
alerts: {
|
||||
send: (alert) =>
|
||||
sendCoreAlert({
|
||||
title: alert.title,
|
||||
body: alert.body,
|
||||
severity: alert.severity,
|
||||
category: alert.category,
|
||||
dedupeKey: alert.dedupeKey,
|
||||
audience: "admins",
|
||||
link: alert.link,
|
||||
} as never),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
/** Bumped when a desktop and server can no longer understand each other. */
|
||||
export const SYNC_PROTOCOL_VERSION = 1;
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* The sync provider: a plugin that answers /sync/v2 and /sync/link through
|
||||
* ctx.sync.provide. Core only forwards. With no provider the paths answer 404,
|
||||
* so a server without it looks like one that never had sync.
|
||||
*/
|
||||
|
||||
import type { NextFunction, Request, RequestHandler, Response } from "express";
|
||||
import type { PluginSyncProvider } from "@termix-ssh/plugin-sdk/backend";
|
||||
import { syncLogger } from "../utils/logger.js";
|
||||
|
||||
interface Entry {
|
||||
pluginId: string;
|
||||
provider: PluginSyncProvider;
|
||||
}
|
||||
|
||||
const providers = new Map<symbol, Entry>();
|
||||
|
||||
export function provideSync(
|
||||
pluginId: string,
|
||||
provider: PluginSyncProvider,
|
||||
): () => void {
|
||||
const key = Symbol(pluginId);
|
||||
providers.set(key, { pluginId, provider });
|
||||
return () => {
|
||||
providers.delete(key);
|
||||
};
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearSyncProviders(): void {
|
||||
providers.clear();
|
||||
}
|
||||
|
||||
export function hasSyncProvider(): boolean {
|
||||
return providers.size > 0;
|
||||
}
|
||||
|
||||
/** Sends the request to the first provider that does not pass. */
|
||||
export const syncForwarder: RequestHandler = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
) => {
|
||||
const entries = [...providers.values()];
|
||||
const run = (index: number): void => {
|
||||
const entry = entries[index];
|
||||
if (!entry) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const pass = (error?: unknown): void => {
|
||||
if (error) next(error);
|
||||
else run(index + 1);
|
||||
};
|
||||
try {
|
||||
const result = entry.provider.handle(req, res, pass);
|
||||
if (result && typeof result.catch === "function") {
|
||||
result.catch((error: unknown) => fail(entry, error, res, next));
|
||||
}
|
||||
} catch (error) {
|
||||
fail(entry, error, res, next);
|
||||
}
|
||||
};
|
||||
run(0);
|
||||
};
|
||||
|
||||
function fail(
|
||||
entry: Entry,
|
||||
error: unknown,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
): void {
|
||||
syncLogger.warn("Sync provider failed", {
|
||||
operation: "sync_provider",
|
||||
pluginId: entry.pluginId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
if (res.headersSent) next(error);
|
||||
else res.status(500).json({ error: "Sync is not available right now" });
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { markChanged } from "./feed.js";
|
||||
import { notifyLocalWrite } from "./write-listeners.js";
|
||||
|
||||
const READS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
const writeListeners = new Set<() => void>();
|
||||
|
||||
/** Called after every successful write request, for a linked desktop's engine. */
|
||||
export function onLocalWrite(listener: () => void): () => void {
|
||||
writeListeners.add(listener);
|
||||
return () => writeListeners.delete(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Any successful write may change what a linked desktop should have, so it
|
||||
@@ -23,7 +17,7 @@ export function syncChangeWatcher(
|
||||
res.on("finish", () => {
|
||||
if (res.statusCode >= 400) return;
|
||||
markChanged();
|
||||
for (const listener of writeListeners) listener();
|
||||
notifyLocalWrite();
|
||||
});
|
||||
}
|
||||
next();
|
||||
|
||||
@@ -11,6 +11,12 @@ import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
||||
import { getEntity } from "../../plugins/sync-registry.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { syncLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
hasHostLocks,
|
||||
hostLock,
|
||||
manualCreateBlockedBy,
|
||||
} from "../../hosts/locks.js";
|
||||
import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js";
|
||||
import {
|
||||
createResolvers,
|
||||
deleteStoredRow,
|
||||
@@ -48,6 +54,22 @@ export type PushResult =
|
||||
|
||||
export const MAX_PUSH_OPS = 500;
|
||||
|
||||
/** A host a plugin manages can't be changed from a desktop either. */
|
||||
async function hostPushLocked(
|
||||
userId: string,
|
||||
syncId: string,
|
||||
exists: boolean,
|
||||
): Promise<boolean> {
|
||||
if (!hasHostLocks()) return false;
|
||||
if (!exists) return manualCreateBlockedBy() !== null;
|
||||
const hostId =
|
||||
await createCurrentHostResolutionRepository().findHostIdBySyncIdForUser(
|
||||
syncId,
|
||||
userId,
|
||||
);
|
||||
return hostId !== null && hostId !== undefined && hostLock(hostId) !== null;
|
||||
}
|
||||
|
||||
function isPushOp(value: unknown): value is PushOp {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const op = value as Record<string, unknown>;
|
||||
@@ -166,6 +188,13 @@ export async function applyPush(
|
||||
results.set(key, { ...base, status: "rejected", reason: "permission" });
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
op.entityType === "hosts" &&
|
||||
(await hostPushLocked(userId, op.syncId, exists))
|
||||
) {
|
||||
results.set(key, { ...base, status: "rejected", reason: "locked" });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (op.op === "delete") {
|
||||
|
||||
@@ -1,604 +0,0 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import * as tar from "tar";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { syncLogger } from "../../utils/logger.js";
|
||||
import { getLocalVersion } from "../../utils/app-version.js";
|
||||
import { getEntity, listEntities } from "../../plugins/sync-registry.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import {
|
||||
createCurrentSessionRepository,
|
||||
createCurrentSettingsRepository,
|
||||
createCurrentUserRepository,
|
||||
createCurrentPluginRepository,
|
||||
createCurrentPluginPermissionGrantRepository,
|
||||
} from "../../database/repositories/factory.js";
|
||||
import { resolveBrandingSettings } from "../../database/routes/branding-settings.js";
|
||||
import { registerCoreSyncEntities } from "../entities.js";
|
||||
import { listChanges, recordKey } from "../records.js";
|
||||
import { addListener, reconcileUser, withUserLock } from "./feed.js";
|
||||
import { applyPush, parsePushOps } from "./push.js";
|
||||
import { SYNC_PROTOCOL_VERSION } from "../protocol.js";
|
||||
|
||||
const SESSION_DAYS = 365;
|
||||
const REFRESH_WITHIN_MS = 60 * 24 * 60 * 60 * 1000;
|
||||
const MAX_PAGE = 1000;
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
registerCoreSyncEntities();
|
||||
|
||||
function userOf(req: Request): string {
|
||||
return (req as AuthenticatedRequest).userId;
|
||||
}
|
||||
|
||||
/** A non-negative integer query value, the fallback when absent, null when malformed. */
|
||||
function queryInt(value: unknown, fallback: number): number | null {
|
||||
if (value === undefined) return fallback;
|
||||
const parsed = Number(value);
|
||||
return typeof value === "string" &&
|
||||
/^\d+$/.test(value) &&
|
||||
Number.isSafeInteger(parsed)
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/info:
|
||||
* get:
|
||||
* summary: Describe this server to a desktop that wants to link to it
|
||||
* description: Public. Lets the desktop app confirm it reached Termix and not a proxy's login page, and that the sync protocol matches.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Server name, version and sync protocol version.
|
||||
*/
|
||||
router.get("/v2/info", async (_req: Request, res: Response) => {
|
||||
let name = "Termix";
|
||||
try {
|
||||
name = (await resolveBrandingSettings(createCurrentSettingsRepository()))
|
||||
.appName;
|
||||
} catch {
|
||||
// The default name is fine.
|
||||
}
|
||||
res.setHeader("X-Termix-Sync", String(SYNC_PROTOCOL_VERSION));
|
||||
res.json({
|
||||
termix: true,
|
||||
name,
|
||||
version: getLocalVersion(),
|
||||
protocol: SYNC_PROTOCOL_VERSION,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/link:
|
||||
* post:
|
||||
* summary: Link a desktop to this account
|
||||
* description: Trades the short login session the desktop just signed in with for a long-lived desktop session, which shows in the account's sessions list and is revoked like any other. The login session is ended.
|
||||
* tags:
|
||||
* - Sync
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* deviceName:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The desktop session token and the account it belongs to.
|
||||
* 401:
|
||||
* description: Not signed in.
|
||||
* 403:
|
||||
* description: API keys cannot link a desktop.
|
||||
*/
|
||||
router.post(
|
||||
"/v2/link",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
if ((req as AuthenticatedRequest).apiKeyId) {
|
||||
return res.status(403).json({ error: "API keys cannot link a desktop" });
|
||||
}
|
||||
try {
|
||||
const user = await createCurrentUserRepository().findById(userId);
|
||||
if (!user) return res.status(401).json({ error: "User not found" });
|
||||
const rawName =
|
||||
typeof req.body?.deviceName === "string" ? req.body.deviceName : "";
|
||||
const deviceName = rawName.trim().slice(0, 100) || "Termix Desktop";
|
||||
const token = await authManager.generateJWTToken(userId, {
|
||||
expiresIn: `${SESSION_DAYS}d`,
|
||||
deviceType: "desktop",
|
||||
deviceInfo: `${deviceName} (sync)`,
|
||||
});
|
||||
const loginSession = (req as AuthenticatedRequest).sessionId;
|
||||
if (loginSession) await authManager.revokeSession(loginSession);
|
||||
res.json({
|
||||
token,
|
||||
user: { id: user.id, username: user.username },
|
||||
server: { version: getLocalVersion() },
|
||||
});
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to link a desktop", error, {
|
||||
operation: "sync_link",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to link" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/session/refresh:
|
||||
* post:
|
||||
* summary: Keep a linked desktop's session from expiring
|
||||
* description: Returns a fresh desktop session when the current one ends within 60 days, and ends the old one. Otherwise returns null and the desktop keeps its token.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The new token, or null.
|
||||
* 401:
|
||||
* description: Session revoked or expired.
|
||||
*/
|
||||
router.post(
|
||||
"/v2/session/refresh",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
const sessionId = (req as AuthenticatedRequest).sessionId;
|
||||
try {
|
||||
if (!sessionId) return res.json({ token: null });
|
||||
const session =
|
||||
await createCurrentSessionRepository().findById(sessionId);
|
||||
if (!session) return res.status(401).json({ error: "Session not found" });
|
||||
const remaining = new Date(session.expiresAt).getTime() - Date.now();
|
||||
if (remaining > REFRESH_WITHIN_MS) return res.json({ token: null });
|
||||
const token = await authManager.generateJWTToken(userId, {
|
||||
expiresIn: `${SESSION_DAYS}d`,
|
||||
deviceType: "desktop",
|
||||
deviceInfo: session.deviceInfo,
|
||||
});
|
||||
await authManager.revokeSession(sessionId);
|
||||
res.json({ token });
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to refresh a desktop session", error, {
|
||||
operation: "sync_session_refresh",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to refresh session" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/unlink:
|
||||
* post:
|
||||
* summary: End a linked desktop's session
|
||||
* description: Called by a desktop when it unlinks, so its session stops working here right away.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The session was ended.
|
||||
*/
|
||||
router.post(
|
||||
"/v2/unlink",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const sessionId = (req as AuthenticatedRequest).sessionId;
|
||||
try {
|
||||
if (sessionId) await authManager.revokeSession(sessionId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to end a desktop session", error, {
|
||||
operation: "sync_unlink",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to unlink" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/changes:
|
||||
* get:
|
||||
* summary: Pull what changed for this account after a cursor
|
||||
* description: Returns records in change order. Each has the entity type, syncId, revision, whether it is deleted, and the row when it is not. Records of entity types this server does not currently run are skipped but still move the cursor.
|
||||
* tags:
|
||||
* - Sync
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: cursor
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: types
|
||||
* description: Comma-separated entity types, to backfill only those.
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: A page of changes, the next cursor and whether more remain.
|
||||
* 400:
|
||||
* description: The cursor or limit is not a non-negative integer.
|
||||
* 500:
|
||||
* description: Failed to read changes.
|
||||
*/
|
||||
router.get(
|
||||
"/v2/changes",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
const cursor = queryInt(req.query.cursor, 0);
|
||||
const pageSize = queryInt(req.query.limit, MAX_PAGE);
|
||||
if (cursor === null || pageSize === null) {
|
||||
return res.status(400).json({ error: "Invalid cursor or limit" });
|
||||
}
|
||||
const limit = Math.min(MAX_PAGE, Math.max(1, pageSize));
|
||||
const types =
|
||||
typeof req.query.types === "string" && req.query.types
|
||||
? req.query.types.split(",").filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const page = await withUserLock(userId, async () => {
|
||||
const { wire } = await reconcileUser(userId);
|
||||
const records = await listChanges(userId, cursor, limit + 1, types);
|
||||
return { wire, records };
|
||||
});
|
||||
const hasMore = page.records.length > limit;
|
||||
const records = page.records.slice(0, limit);
|
||||
const changes = [];
|
||||
for (const record of records) {
|
||||
if (!getEntity(record.entityType)) continue;
|
||||
const row = record.deleted
|
||||
? null
|
||||
: (page.wire.get(recordKey(record.entityType, record.syncId)) ??
|
||||
null);
|
||||
// A live record with no row failed to serialize; it comes again once it
|
||||
// changes, rather than arriving as a delete.
|
||||
if (!record.deleted && !row) continue;
|
||||
changes.push({
|
||||
seq: record.seq,
|
||||
entityType: record.entityType,
|
||||
syncId: record.syncId,
|
||||
revision: record.revision,
|
||||
deleted: !!record.deleted,
|
||||
row,
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
changes,
|
||||
nextCursor: records.length ? records[records.length - 1].seq : cursor,
|
||||
hasMore,
|
||||
entityTypes: listEntities().map((entity) => ({
|
||||
type: entity.type,
|
||||
owner: entity.owner,
|
||||
order: entity.order,
|
||||
readOnly: !!entity.readOnly,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to read sync changes", error, {
|
||||
operation: "sync_changes",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to read changes" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/push:
|
||||
* post:
|
||||
* summary: Push a desktop's changes
|
||||
* description: Each op carries the revision the desktop last saw. Ops are applied in dependency order and each gets its own result, ok with the new revision, conflict with the server's version, or rejected with a reason.
|
||||
* tags:
|
||||
* - Sync
|
||||
* requestBody:
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* ops:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: One result per op, in the order sent.
|
||||
* 400:
|
||||
* description: Malformed ops, or more than 500.
|
||||
* 500:
|
||||
* description: Failed to apply changes.
|
||||
*/
|
||||
router.post(
|
||||
"/v2/push",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
const ops = parsePushOps(req.body);
|
||||
if (!ops) return res.status(400).json({ error: "Invalid ops" });
|
||||
try {
|
||||
const results = await withUserLock(userId, () => applyPush(userId, ops));
|
||||
res.json({ results });
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to apply pushed changes", error, {
|
||||
operation: "sync_push",
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to apply changes" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/events:
|
||||
* get:
|
||||
* summary: Stream a notice whenever this account's data changes
|
||||
* description: Server-sent events. A "change" event means there is something new to pull. Comments are sent every 25 seconds to keep proxies from closing the stream.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: An event stream.
|
||||
*/
|
||||
router.get("/v2/events", authenticateJWT, (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.flushHeaders?.();
|
||||
res.write(`event: ready\ndata: {}\n\n`);
|
||||
const remove = addListener(userId, res);
|
||||
req.on("close", remove);
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/hosts/{syncId}:
|
||||
* get:
|
||||
* summary: This server's id for a host, by its sync id
|
||||
* description: For a linked desktop that asks the server to act on a host (a tunnel through it), which needs the server's own id. Only for hosts the caller can connect to.
|
||||
* tags:
|
||||
* - Sync
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: syncId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The host's id and name.
|
||||
* 404:
|
||||
* description: No such host the caller can connect to.
|
||||
*/
|
||||
router.get(
|
||||
"/v2/hosts/:syncId",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
try {
|
||||
const { createCurrentHostResolutionRepository } =
|
||||
await import("../../database/repositories/factory.js");
|
||||
const hostId =
|
||||
await createCurrentHostResolutionRepository().findHostIdBySyncId(
|
||||
String(req.params.syncId),
|
||||
);
|
||||
if (hostId === null) return res.status(404).json({ error: "Not found" });
|
||||
const { PermissionManager } =
|
||||
await import("../../utils/permission-manager.js");
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"connect",
|
||||
);
|
||||
if (!access.hasAccess)
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
const { createCurrentHostRepository } =
|
||||
await import("../../database/repositories/factory.js");
|
||||
const row = await createCurrentHostRepository().findById(hostId);
|
||||
res.json({ id: hostId, name: row?.name ?? row?.ip ?? null });
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to look up a host by sync id", error, {
|
||||
operation: "sync_host_lookup",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to look up host" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/host-status:
|
||||
* get:
|
||||
* summary: Status of the hosts this account can see, keyed by sync id
|
||||
* description: For a linked desktop whose hosts connect through this server, so it can show their status against its own copies.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Status entries by host sync id.
|
||||
*/
|
||||
router.get(
|
||||
"/v2/host-status",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = userOf(req);
|
||||
try {
|
||||
const { hostStatusService } =
|
||||
await import("../../hosts/status/host-status-service.js");
|
||||
const { PermissionManager } =
|
||||
await import("../../utils/permission-manager.js");
|
||||
const { createCurrentHostRepository } =
|
||||
await import("../../database/repositories/factory.js");
|
||||
const statuses = await hostStatusService.statusesFor(userId, null);
|
||||
const entries = [...statuses.entries()];
|
||||
const allowed =
|
||||
await PermissionManager.getInstance().filterAccessibleHostIds(
|
||||
userId,
|
||||
entries.map(([id]) => id),
|
||||
);
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [id, entry] of entries) {
|
||||
if (!allowed.has(id)) continue;
|
||||
const row = await hostRepository.findById(id);
|
||||
if (row?.syncId) result[row.syncId] = entry;
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to read host status for sync", error, {
|
||||
operation: "sync_host_status",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to read host status" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function listPluginsForDesktop() {
|
||||
return getPluginRuntime().loader.list();
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/plugins:
|
||||
* get:
|
||||
* summary: The features this server runs, for a linked desktop to mirror
|
||||
* description: Every installed plugin with its version, where it came from, whether it is on, how a desktop should treat it and which sync entities it owns. registryKeys lists the signing keys of the custom registries an admin added, so plugins from them load on the desktop.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The plugin list.
|
||||
*/
|
||||
router.get(
|
||||
"/v2/plugins",
|
||||
authenticateJWT,
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const records = new Map(
|
||||
(await createCurrentPluginRepository().listAll()).map((record) => [
|
||||
record.id,
|
||||
record,
|
||||
]),
|
||||
);
|
||||
const grants = createCurrentPluginPermissionGrantRepository();
|
||||
const plugins = [];
|
||||
for (const plugin of await listPluginsForDesktop()) {
|
||||
plugins.push({
|
||||
id: plugin.id,
|
||||
name: plugin.manifest.name,
|
||||
version: plugin.manifest.version,
|
||||
source: plugin.source,
|
||||
// A bundled plugin updated from a signed .tmxplug can be mirrored.
|
||||
artifact: !!plugin.artifact,
|
||||
enabled: records.get(plugin.id)?.state === "enabled",
|
||||
active: plugin.state === "active",
|
||||
desktop: plugin.manifest.desktop ?? "mirror",
|
||||
syncEntities: plugin.manifest.contributes?.syncEntities ?? [],
|
||||
granted: (await grants.listByPlugin(plugin.id)).map(
|
||||
(grant) => grant.capability,
|
||||
),
|
||||
});
|
||||
}
|
||||
const { customRegistryKeys } =
|
||||
await import("../../plugins/registries.js");
|
||||
res.json({ plugins, registryKeys: await customRegistryKeys() });
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to list plugins for sync", error, {
|
||||
operation: "sync_plugins",
|
||||
});
|
||||
res.status(500).json({ error: "Failed to list plugins" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/v2/plugins/{id}/package:
|
||||
* get:
|
||||
* summary: Download an installed plugin so a linked desktop can install it
|
||||
* description: Only for plugins installed on this server, or bundled ones replaced by a signed update. Sends the original .tmxplug when there is one, with its signature in the X-Termix-Signature header, otherwise a fresh archive of the plugin folder.
|
||||
* tags:
|
||||
* - Sync
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The plugin archive.
|
||||
* 404:
|
||||
* description: No installed plugin with that id.
|
||||
*/
|
||||
router.get(
|
||||
"/v2/plugins/:id/package",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const pluginId = String(req.params.id);
|
||||
try {
|
||||
const plugin = (await listPluginsForDesktop()).find(
|
||||
(candidate) => candidate.id === pluginId,
|
||||
);
|
||||
if (!plugin || (plugin.source !== "user" && !plugin.artifact)) {
|
||||
return res.status(404).json({ error: "Plugin not found" });
|
||||
}
|
||||
const artifact = plugin.artifact ?? "";
|
||||
res.setHeader("Content-Type", "application/gzip");
|
||||
if (fs.existsSync(artifact)) {
|
||||
const sig = `${artifact}.sig`;
|
||||
if (fs.existsSync(sig)) {
|
||||
res.setHeader(
|
||||
"X-Termix-Signature",
|
||||
Buffer.from(await fs.promises.readFile(sig, "utf8")).toString(
|
||||
"base64",
|
||||
),
|
||||
);
|
||||
}
|
||||
return fs.createReadStream(artifact).pipe(res);
|
||||
}
|
||||
const temp = path.join(
|
||||
os.tmpdir(),
|
||||
`termix-plugin-${pluginId}-${Date.now()}.tmxplug`,
|
||||
);
|
||||
await tar.c({ gzip: true, cwd: plugin.dir, file: temp }, ["."]);
|
||||
const stream = fs.createReadStream(temp);
|
||||
stream.on("close", () => void fs.promises.rm(temp, { force: true }));
|
||||
stream.pipe(res);
|
||||
} catch (error) {
|
||||
syncLogger.error("Failed to package a plugin for sync", error, {
|
||||
operation: "sync_plugin_package",
|
||||
pluginId,
|
||||
});
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ error: "Failed to package plugin" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,11 @@
|
||||
const writeListeners = new Set<() => void>();
|
||||
|
||||
/** Called after every successful write request, for a linked desktop's engine. */
|
||||
export function onLocalWrite(listener: () => void): () => void {
|
||||
writeListeners.add(listener);
|
||||
return () => writeListeners.delete(listener);
|
||||
}
|
||||
|
||||
export function notifyLocalWrite(): void {
|
||||
for (const listener of writeListeners) listener();
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* On a desktop linked to a server, the account it is signed in to there.
|
||||
* That account is who the user is; the local profile only mirrors it.
|
||||
*/
|
||||
export async function describeDesktopLink(userId: string) {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") return null;
|
||||
try {
|
||||
const { getLink } = await import("./client/link-store.js");
|
||||
const link = await getLink(userId);
|
||||
if (!link) return null;
|
||||
return {
|
||||
serverUrl: link.serverUrl,
|
||||
serverName: link.serverName,
|
||||
username: link.account?.username ?? link.remoteUsername,
|
||||
isAdmin: !!link.account?.isAdmin,
|
||||
roles: link.account?.roles ?? [],
|
||||
permissions: link.account?.permissions ?? [],
|
||||
status: link.status,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Who the app shows. On a desktop linked to a server that is the account it
|
||||
* is signed in to there, not the local user that holds the copy of its data.
|
||||
*/
|
||||
export async function shownIdentity(user: {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin: boolean | null;
|
||||
}) {
|
||||
const linked = await describeDesktopLink(user.id);
|
||||
return {
|
||||
linked,
|
||||
username: linked?.username || user.username,
|
||||
isAdmin: linked ? linked.isAdmin : !!user.isAdmin,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user