mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: rebuild desktop sync as linked server accounts, fix packaged plugin bundles and UI scaling
This commit is contained in:
1 parent
7c02daccf4
commit
6cd9a2846d
242 files changed
+39127
-8558
No files matched your search
@@ -2,6 +2,7 @@ import { getErrorMessage } from "../../utils/error-message.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { listCredentialTypes } from "../../hosts/connect/auth-provider-registry.js";
|
||||
import { ensureCoreSshAuthProviders } from "../../hosts/connect/core-providers.js";
|
||||
import { deleteOwnedCredential } from "../../hosts/delete-credential.js";
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { authLogger } from "../../utils/logger.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
@@ -22,8 +23,9 @@ import {
|
||||
createCurrentRoleRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
|
||||
import { parseSharedSource } from "./host-normalizers.js";
|
||||
|
||||
/** Built-in password and key, plus any type a plugin offers for credentials. */
|
||||
function getCredentialTypes(): string[] {
|
||||
@@ -32,6 +34,7 @@ function getCredentialTypes(): string[] {
|
||||
}
|
||||
|
||||
const router = express.Router();
|
||||
router.use(rejectSharedCopyWrites("credential", /^\/(\d+)$/));
|
||||
|
||||
function isNonEmptyString(val: unknown): val is string {
|
||||
return typeof val === "string" && val.trim().length > 0;
|
||||
@@ -816,59 +819,11 @@ router.delete(
|
||||
|
||||
try {
|
||||
const credentialId = parseInt(id);
|
||||
const credentialToDelete =
|
||||
await createCurrentCredentialRepository().findDecryptedByIdForUser(
|
||||
userId,
|
||||
credentialId,
|
||||
);
|
||||
|
||||
if (!credentialToDelete) {
|
||||
const deleted = await deleteOwnedCredential(userId, credentialId);
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
}
|
||||
|
||||
const hostsUsingCredential =
|
||||
await createCurrentHostResolutionRepository().listHostsUsingCredentialForUser(
|
||||
userId,
|
||||
credentialId,
|
||||
);
|
||||
|
||||
if (hostsUsingCredential.length > 0) {
|
||||
await createCurrentHostRepository().updateManyForUser(
|
||||
userId,
|
||||
hostsUsingCredential.map((host) => host.id),
|
||||
{
|
||||
credentialId: null,
|
||||
password: null,
|
||||
key: null,
|
||||
keyPassword: null,
|
||||
authType: "password",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { SharedHostSecretsManager } =
|
||||
await import("../../utils/shared-host-secrets-manager.js");
|
||||
const sharedSecretsManager = SharedHostSecretsManager.getInstance();
|
||||
await sharedSecretsManager.deleteForCredential(credentialId);
|
||||
|
||||
await createCurrentCredentialRepository().deleteForUser(
|
||||
userId,
|
||||
credentialId,
|
||||
);
|
||||
if (credentialToDelete.syncId) {
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
"sshCredentials",
|
||||
credentialToDelete.syncId,
|
||||
);
|
||||
}
|
||||
|
||||
// Shares stay in place; re-snapshot so recipients fall back to whatever
|
||||
// auth the host still has (or lose the stale credential copy).
|
||||
for (const host of hostsUsingCredential) {
|
||||
await sharedSecretsManager.resyncHost(host.id);
|
||||
}
|
||||
|
||||
authLogger.success("SSH credential deleted", {
|
||||
operation: "credential_delete_success",
|
||||
userId,
|
||||
@@ -882,7 +837,7 @@ router.delete(
|
||||
action: "delete_credential",
|
||||
resourceType: "credential",
|
||||
resourceId: id,
|
||||
resourceName: String(credentialToDelete.name ?? id),
|
||||
resourceName: String(deleted.name ?? id),
|
||||
ipAddress: cdIp,
|
||||
userAgent: cdUa,
|
||||
success: true,
|
||||
@@ -1138,6 +1093,19 @@ function formatCredentialOutput(
|
||||
lastUsed: credential.lastUsed,
|
||||
createdAt: credential.createdAt,
|
||||
updatedAt: credential.updatedAt,
|
||||
...sharedCopyFields(credential.sharedSource),
|
||||
};
|
||||
}
|
||||
|
||||
/** A read-only copy of a credential shared with a linked desktop's account. */
|
||||
function sharedCopyFields(value: unknown): Record<string, unknown> {
|
||||
const shared = parseSharedSource(value);
|
||||
if (!shared) return {};
|
||||
return {
|
||||
isShared: true,
|
||||
sharedCopy: true,
|
||||
ownerUsername: shared.owner || null,
|
||||
permissionLevel: shared.permissionLevel === "manage" ? "manage" : "use",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,17 @@ export function isLoopbackRequest(req: Request): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this backend may hand out the desktop auto-session at all: the
|
||||
* embedded desktop backend, or a development backend the desktop app points
|
||||
* at. A production server never does, whoever asks.
|
||||
*/
|
||||
export function allowsDesktopAutoSession(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): boolean {
|
||||
return env.ELECTRON_EMBEDDED === "true" || env.NODE_ENV !== "production";
|
||||
}
|
||||
|
||||
export function extractBearerOrCookieToken(req: Request): string | undefined {
|
||||
const cookieToken = (req as Request & { cookies?: Record<string, string> })
|
||||
.cookies?.jwt;
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
createCurrentRecentActivityRepository,
|
||||
createCurrentRbacAccessRepository,
|
||||
createCurrentSshCredentialUsageRepository,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { isNonEmptyString } from "./host-normalizers.js";
|
||||
|
||||
@@ -178,6 +177,9 @@ export function registerHostFolderRoutes(
|
||||
* credentialId:
|
||||
* type: integer
|
||||
* nullable: true
|
||||
* localOnly:
|
||||
* type: boolean
|
||||
* description: Desktop only. Keeps the folder and its hosts on this device instead of syncing them.
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Folder metadata updated successfully.
|
||||
@@ -193,7 +195,7 @@ export function registerHostFolderRoutes(
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, color, icon, credentialId } = req.body;
|
||||
const { name, color, icon, credentialId, localOnly } = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !name) {
|
||||
return res.status(400).json({ error: "Folder name is required" });
|
||||
@@ -234,6 +236,13 @@ export function registerHostFolderRoutes(
|
||||
icon,
|
||||
normalizedCredentialId,
|
||||
);
|
||||
if (typeof localOnly === "boolean") {
|
||||
await createCurrentHostFolderRepository().setLocalOnly(
|
||||
userId,
|
||||
name,
|
||||
localOnly,
|
||||
);
|
||||
}
|
||||
|
||||
if (!created) {
|
||||
databaseLogger.info("Updating SSH folder", {
|
||||
@@ -411,17 +420,9 @@ export function registerHostFolderRoutes(
|
||||
);
|
||||
}
|
||||
|
||||
const { hostSyncIds, folderSyncIds } =
|
||||
await hostFolderRepository.deleteHostsAndFolderRecords(
|
||||
userId,
|
||||
folderName,
|
||||
);
|
||||
const tombstoneRepository = createCurrentSyncTombstoneRepository();
|
||||
await tombstoneRepository.recordMany(userId, "hosts", hostSyncIds);
|
||||
await tombstoneRepository.recordMany(
|
||||
await hostFolderRepository.deleteHostsAndFolderRecords(
|
||||
userId,
|
||||
"sshFolders",
|
||||
folderSyncIds,
|
||||
folderName,
|
||||
);
|
||||
|
||||
try {
|
||||
|
||||
@@ -383,11 +383,35 @@ export function sanitizeHostForRecipient(
|
||||
return reduced;
|
||||
}
|
||||
|
||||
/** Who shared a host that a linked desktop holds a read-only copy of. */
|
||||
export function parseSharedSource(
|
||||
value: unknown,
|
||||
): { owner?: string; permissionLevel?: string } | null {
|
||||
if (typeof value !== "string" || !value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function transformHostResponse(
|
||||
host: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const shared = parseSharedSource(host.sharedSource);
|
||||
return {
|
||||
...host,
|
||||
sharedSource: undefined,
|
||||
localOnly: !!host.localOnly,
|
||||
sharedCopy: !!shared,
|
||||
...(shared
|
||||
? {
|
||||
isShared: true,
|
||||
ownerUsername: shared.owner || undefined,
|
||||
permissionLevel: shared.permissionLevel || "connect",
|
||||
}
|
||||
: {}),
|
||||
tags:
|
||||
typeof host.tags === "string"
|
||||
? host.tags
|
||||
|
||||
@@ -67,8 +67,10 @@ import {
|
||||
requiresPersonalHostAuthentication,
|
||||
resolveRecipientSharedHostAuthentication,
|
||||
} from "../../utils/shared-host-auth-resolver.js";
|
||||
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
|
||||
|
||||
const router = express.Router();
|
||||
router.use(rejectSharedCopyWrites("host", /^\/db\/host\/(\d+)$/));
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
@@ -199,6 +201,7 @@ router.post(
|
||||
socks5Password,
|
||||
socks5ProxyChain,
|
||||
connectionOrigin,
|
||||
localOnly,
|
||||
portKnockSequence,
|
||||
overrideCredentialUsername,
|
||||
enableSsh,
|
||||
@@ -311,6 +314,7 @@ router.post(
|
||||
connectionOrigin === "local" || connectionOrigin === "remote"
|
||||
? connectionOrigin
|
||||
: null,
|
||||
...(typeof localOnly === "boolean" ? { localOnly } : {}),
|
||||
portKnockSequence: portKnockSequence
|
||||
? JSON.stringify(portKnockSequence)
|
||||
: null,
|
||||
@@ -795,6 +799,7 @@ router.put(
|
||||
socks5Password,
|
||||
socks5ProxyChain,
|
||||
connectionOrigin,
|
||||
localOnly,
|
||||
portKnockSequence,
|
||||
overrideCredentialUsername,
|
||||
enableSsh,
|
||||
@@ -908,6 +913,7 @@ router.put(
|
||||
connectionOrigin === "local" || connectionOrigin === "remote"
|
||||
? connectionOrigin
|
||||
: null,
|
||||
...(typeof localOnly === "boolean" ? { localOnly } : {}),
|
||||
portKnockSequence: portKnockSequence
|
||||
? JSON.stringify(portKnockSequence)
|
||||
: null,
|
||||
@@ -1454,14 +1460,16 @@ router.get(
|
||||
|
||||
const result = await Promise.all(
|
||||
data.map(async (row: Record<string, unknown>) => {
|
||||
const transformed = transformHostResponse(row);
|
||||
const baseHost = {
|
||||
...transformHostResponse(row),
|
||||
isShared: !!row.isShared,
|
||||
permissionLevel: row.permissionLevel || undefined,
|
||||
...transformed,
|
||||
isShared: !!row.isShared || !!transformed.sharedCopy,
|
||||
permissionLevel:
|
||||
row.permissionLevel || transformed.permissionLevel || undefined,
|
||||
sharedExpiresAt: row.expiresAt || undefined,
|
||||
ownerUsername: row.isShared
|
||||
? ownerUsernames.get(row.userId as string) || undefined
|
||||
: undefined,
|
||||
: transformed.ownerUsername,
|
||||
};
|
||||
|
||||
const resolved =
|
||||
|
||||
@@ -306,6 +306,14 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
async function isManagedByLinkedServer(pluginId: string): Promise<boolean> {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") return false;
|
||||
const { getLink } = await import("../../sync/client/link-store.js");
|
||||
if (!(await getLink())) return false;
|
||||
const { isServerManaged } = await import("../../sync/client/plugins.js");
|
||||
return isServerManaged(pluginId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugins/{id}/state:
|
||||
@@ -338,6 +346,8 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
* description: The caller lacks admin.plugins.manage.
|
||||
* 404:
|
||||
* description: No such plugin.
|
||||
* 409:
|
||||
* description: This desktop is linked to a server, which decides whether the plugin runs.
|
||||
*/
|
||||
router.patch(
|
||||
"/:id/state",
|
||||
@@ -360,6 +370,15 @@ router.patch(
|
||||
return;
|
||||
}
|
||||
|
||||
// A linked desktop runs what its server runs.
|
||||
if (await isManagedByLinkedServer(pluginId)) {
|
||||
res.status(409).json({
|
||||
error: "Managed by the linked server",
|
||||
code: "MANAGED_BY_SERVER",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Persist first: if the start or stop below throws, the recorded
|
||||
// intent still matches what the user asked for, and the next boot
|
||||
// acts on it rather than silently reverting.
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* Core's own sync entities, registered the same way a plugin registers one.
|
||||
*
|
||||
* These were a hardcoded ENTITY_CONFIG in sync.ts, a SyncEntityType union in
|
||||
* the tombstone repository, a REFERENCES map in sync-references.ts and a
|
||||
* frozen array in electron/. Four lists that had to agree and had no test
|
||||
* saying they did. Now there is one registration per entity and the rest is
|
||||
* derived.
|
||||
*
|
||||
* The order values reproduce the dependency order the Electron array encoded:
|
||||
* anything referenced by another entity syncs first.
|
||||
*/
|
||||
|
||||
import {
|
||||
hosts,
|
||||
sshCredentials,
|
||||
sshFolders,
|
||||
userPreferences,
|
||||
} from "../db/schema.js";
|
||||
import {
|
||||
CORE_OWNER,
|
||||
registerEntity,
|
||||
listEntities,
|
||||
} from "../../plugins/sync-registry.js";
|
||||
|
||||
const CREDENTIAL_REFERENCE = {
|
||||
field: "credentialId",
|
||||
syncField: "credentialSyncId",
|
||||
entityType: "sshCredentials",
|
||||
} as const;
|
||||
|
||||
const HOST_REFERENCES = [
|
||||
CREDENTIAL_REFERENCE,
|
||||
{
|
||||
field: "rdpCredentialId",
|
||||
syncField: "rdpCredentialSyncId",
|
||||
entityType: "sshCredentials",
|
||||
},
|
||||
{
|
||||
field: "vncCredentialId",
|
||||
syncField: "vncCredentialSyncId",
|
||||
entityType: "sshCredentials",
|
||||
},
|
||||
{
|
||||
field: "telnetCredentialId",
|
||||
syncField: "telnetCredentialSyncId",
|
||||
entityType: "sshCredentials",
|
||||
},
|
||||
{
|
||||
field: "parentHostId",
|
||||
syncField: "parentHostSyncId",
|
||||
entityType: "hosts",
|
||||
},
|
||||
] as const;
|
||||
|
||||
let registered = false;
|
||||
|
||||
/**
|
||||
* Registers core's entities. Idempotent, because the sync routes and the
|
||||
* Electron entity endpoint both need the registry primed and neither can
|
||||
* assume it ran first.
|
||||
*/
|
||||
export function registerCoreSyncEntities(): void {
|
||||
if (registered && listEntities().length > 0) return;
|
||||
registered = true;
|
||||
|
||||
// 10, 20, 30... so a plugin can slot an entity between two core ones
|
||||
// without every order value having to be renumbered.
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "sshCredentials",
|
||||
table: sshCredentials,
|
||||
order: 10,
|
||||
});
|
||||
|
||||
// vaultProfiles (order 20) moved to the vault plugin. Hosts no longer
|
||||
// reference it: a host's profile is the plugin's profileId host setting.
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "sshFolders",
|
||||
table: sshFolders,
|
||||
order: 30,
|
||||
references: [CREDENTIAL_REFERENCE],
|
||||
});
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "hosts",
|
||||
table: hosts,
|
||||
order: 50,
|
||||
// connectionOrigin says how this device reached the host, which is never
|
||||
// true of the other side.
|
||||
readOnlyFields: ["connectionOrigin"],
|
||||
references: HOST_REFERENCES,
|
||||
encryptedFields: ["ssh_data"],
|
||||
});
|
||||
|
||||
// dashboardServiceLinks (order 70) and homepageItems (order 80) moved to
|
||||
// the homepage plugin, which registers them itself at activation.
|
||||
|
||||
registerEntity(CORE_OWNER, {
|
||||
type: "userPreferences",
|
||||
table: userPreferences,
|
||||
order: 90,
|
||||
singleton: true,
|
||||
readOnlyFields: ["storageMode"],
|
||||
});
|
||||
}
|
||||
|
||||
/** The tables a reference may point at, derived from the registrations. */
|
||||
export const ENCRYPTED_ENTITY_TABLES: Readonly<Record<string, string>> = {
|
||||
hosts: "ssh_data",
|
||||
sshCredentials: "ssh_credentials",
|
||||
};
|
||||
@@ -1,84 +0,0 @@
|
||||
/**
|
||||
* Host plugin settings over remote sync. A host row travels with a
|
||||
* pluginSettings map of its non-secret values; a plugin whose value refers to
|
||||
* one of its own rows by local id translates it with a registered
|
||||
* ctx.registry.provide("<id>.hostSettingsSync", { exportValue, importValue }).
|
||||
*/
|
||||
|
||||
import { consume } from "../../plugins/registry.js";
|
||||
import {
|
||||
declaredFields,
|
||||
getAllSettings,
|
||||
setSetting,
|
||||
} from "../../plugins/settings.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
hostSettingsPlugins,
|
||||
type HostPluginSettings,
|
||||
} from "./host-plugin-settings.js";
|
||||
|
||||
export interface PluginHostSettingsSync {
|
||||
exportValue?: (key: string, value: unknown) => unknown | Promise<unknown>;
|
||||
importValue?: (key: string, value: unknown) => unknown | Promise<unknown>;
|
||||
}
|
||||
|
||||
/** What a host carries to the other side of a sync pair. */
|
||||
export async function exportHostPluginSettings(
|
||||
hostId: number,
|
||||
): Promise<HostPluginSettings> {
|
||||
const result: HostPluginSettings = {};
|
||||
for (const manifest of hostSettingsPlugins()) {
|
||||
const fields = declaredFields(manifest, "host").filter(
|
||||
(field) => field.type !== "secret",
|
||||
);
|
||||
if (fields.length === 0) continue;
|
||||
const values = await getAllSettings(manifest, "host", hostId, {
|
||||
redactSecrets: true,
|
||||
});
|
||||
const hook = consume<PluginHostSettingsSync>(
|
||||
`${manifest.id}.hostSettingsSync`,
|
||||
);
|
||||
const own: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
const value = values[field.key];
|
||||
own[field.key] = hook?.exportValue
|
||||
? await hook.exportValue(field.key, value)
|
||||
: value;
|
||||
}
|
||||
result[manifest.id] = own;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Writes what a synced host carried, translated back to local ids. */
|
||||
export async function importHostPluginSettings(
|
||||
hostId: number,
|
||||
carried: unknown,
|
||||
): Promise<void> {
|
||||
if (!carried || typeof carried !== "object") return;
|
||||
const byPlugin = carried as HostPluginSettings;
|
||||
for (const manifest of hostSettingsPlugins()) {
|
||||
const own = byPlugin[manifest.id];
|
||||
if (!own || typeof own !== "object") continue;
|
||||
const hook = consume<PluginHostSettingsSync>(
|
||||
`${manifest.id}.hostSettingsSync`,
|
||||
);
|
||||
for (const field of declaredFields(manifest, "host")) {
|
||||
if (field.type === "secret" || !(field.key in own)) continue;
|
||||
try {
|
||||
const value = hook?.importValue
|
||||
? await hook.importValue(field.key, own[field.key])
|
||||
: own[field.key];
|
||||
await setSetting(manifest, "host", hostId, field.key, value);
|
||||
} catch (error) {
|
||||
databaseLogger.warn("Could not apply a synced host plugin setting", {
|
||||
operation: "sync_host_plugin_settings",
|
||||
pluginId: manifest.id,
|
||||
hostId,
|
||||
key: field.key,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
|
||||
import { getEntity } from "../../plugins/sync-registry.js";
|
||||
|
||||
/**
|
||||
* An entity another entity's rows may point at.
|
||||
*
|
||||
* A plain string rather than a union: the set is whatever is registered, and a
|
||||
* plugin can both register an entity and reference one. sync.ts resolves the
|
||||
* name through the registry, so an unknown one is an error there rather than a
|
||||
* silent fallthrough.
|
||||
*/
|
||||
export type SyncReferenceEntity = string;
|
||||
|
||||
interface SyncReference {
|
||||
field: string;
|
||||
syncField: string;
|
||||
entityType: SyncReferenceEntity;
|
||||
}
|
||||
|
||||
export function orderSyncRows(
|
||||
entityType: SyncEntityType,
|
||||
rows: Record<string, unknown>[],
|
||||
): Record<string, unknown>[] {
|
||||
// Only an entity that references itself needs its rows ordered among
|
||||
// themselves. Today that is hosts, through parentHostId.
|
||||
const selfReference = referencesFor(entityType).find(
|
||||
(reference) => reference.entityType === entityType,
|
||||
);
|
||||
if (!selfReference) return rows;
|
||||
|
||||
const bySyncId = new Map(
|
||||
rows
|
||||
.filter((row) => typeof row.syncId === "string")
|
||||
.map((row) => [row.syncId as string, row]),
|
||||
);
|
||||
const ordered: Record<string, unknown>[] = [];
|
||||
const visited = new Set<Record<string, unknown>>();
|
||||
const visiting = new Set<Record<string, unknown>>();
|
||||
|
||||
const visit = (row: Record<string, unknown>) => {
|
||||
if (visited.has(row)) return;
|
||||
if (visiting.has(row)) return;
|
||||
visiting.add(row);
|
||||
|
||||
const parentSyncId = row[selfReference.syncField];
|
||||
if (typeof parentSyncId === "string") {
|
||||
const parent = bySyncId.get(parentSyncId);
|
||||
if (parent) visit(parent);
|
||||
}
|
||||
|
||||
visiting.delete(row);
|
||||
visited.add(row);
|
||||
ordered.push(row);
|
||||
};
|
||||
|
||||
rows.forEach(visit);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function referencesFor(entityType: SyncEntityType): readonly SyncReference[] {
|
||||
return (getEntity(entityType)?.references ?? []) as readonly SyncReference[];
|
||||
}
|
||||
|
||||
export async function serializeSyncReferences(
|
||||
entityType: SyncEntityType,
|
||||
row: Record<string, unknown>,
|
||||
resolveSyncId: (
|
||||
entityType: SyncReferenceEntity,
|
||||
id: number,
|
||||
) => Promise<string | null>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const serialized = { ...row };
|
||||
|
||||
const custom = getEntity(entityType)?.serialize;
|
||||
if (custom) return custom(serialized, resolveSyncId);
|
||||
|
||||
for (const reference of referencesFor(entityType)) {
|
||||
const id = serialized[reference.field];
|
||||
serialized[reference.syncField] =
|
||||
typeof id === "number"
|
||||
? await resolveSyncId(reference.entityType, id)
|
||||
: null;
|
||||
delete serialized[reference.field];
|
||||
}
|
||||
return serialized;
|
||||
}
|
||||
|
||||
export async function deserializeSyncReferences(
|
||||
entityType: SyncEntityType,
|
||||
row: Record<string, unknown>,
|
||||
resolveId: (
|
||||
entityType: SyncReferenceEntity,
|
||||
syncId: string,
|
||||
) => Promise<number | null>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const deserialized = { ...row };
|
||||
|
||||
const custom = getEntity(entityType)?.deserialize;
|
||||
if (custom) return custom(deserialized, resolveId);
|
||||
|
||||
for (const reference of referencesFor(entityType)) {
|
||||
const syncId = deserialized[reference.syncField];
|
||||
delete deserialized[reference.syncField];
|
||||
delete deserialized[reference.field];
|
||||
|
||||
if (syncId == null) {
|
||||
deserialized[reference.field] = null;
|
||||
continue;
|
||||
}
|
||||
if (typeof syncId !== "string") {
|
||||
throw new Error(`Invalid ${reference.syncField}`);
|
||||
}
|
||||
|
||||
const id = await resolveId(reference.entityType, syncId);
|
||||
if (id === null) {
|
||||
throw new Error(
|
||||
`Missing ${reference.entityType} dependency ${reference.syncField}=${syncId}`,
|
||||
);
|
||||
}
|
||||
deserialized[reference.field] = id;
|
||||
}
|
||||
return deserialized;
|
||||
}
|
||||
@@ -1,588 +0,0 @@
|
||||
import express, { type Request, type Response } from "express";
|
||||
import { and, eq, type SQL } from "drizzle-orm";
|
||||
import { hosts } from "../db/schema.js";
|
||||
import { AuthManager } from "../../utils/auth-manager.js";
|
||||
import { DataCrypto } from "../../utils/data-crypto.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import {
|
||||
createCurrentRepositoryContext,
|
||||
createCurrentSyncTombstoneRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
|
||||
import {
|
||||
getEntity,
|
||||
hasEntity,
|
||||
listEntities,
|
||||
} from "../../plugins/sync-registry.js";
|
||||
import {
|
||||
ENCRYPTED_ENTITY_TABLES,
|
||||
registerCoreSyncEntities,
|
||||
} from "./sync-entities.js";
|
||||
import {
|
||||
deserializeSyncReferences,
|
||||
orderSyncRows,
|
||||
serializeSyncReferences,
|
||||
type SyncReferenceEntity,
|
||||
} from "./sync-references.js";
|
||||
import { timestampAtOrAfter } from "../sync-timestamp.js";
|
||||
import {
|
||||
insertReturningWhere,
|
||||
updateReturning,
|
||||
} from "../repositories/returning.js";
|
||||
import { validateParentHostId } from "./host-parent-validation.js";
|
||||
import {
|
||||
exportHostPluginSettings,
|
||||
importHostPluginSettings,
|
||||
} from "./sync-host-plugin-settings.js";
|
||||
|
||||
// Primed at import so every consumer of this module - the routes below, the
|
||||
// reference resolvers, the Electron entity-types endpoint - sees the same
|
||||
// registry regardless of which one runs first.
|
||||
registerCoreSyncEntities();
|
||||
|
||||
const router = express.Router();
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
|
||||
/**
|
||||
* What a sync entity looks like once it is registered.
|
||||
*
|
||||
* The table was a hand-maintained union of ten concrete drizzle types, which a
|
||||
* plugin-provided table could never join. It is the same deliberate
|
||||
* approximation repositories/database-context.ts documents: the query-builder
|
||||
* surface used here is identical across the engines and across tables, and
|
||||
* every use already casts to reach .syncId and .updatedAt.
|
||||
*/
|
||||
type SyncTable = typeof hosts;
|
||||
|
||||
function entityConfig(entityType: SyncEntityType) {
|
||||
const entity = getEntity(entityType);
|
||||
if (!entity) {
|
||||
throw new Error(`Unknown sync entity "${entityType}"`);
|
||||
}
|
||||
return {
|
||||
table: entity.table as SyncTable,
|
||||
readOnlyFields: entity.readOnlyFields,
|
||||
singleton: entity.singleton === true,
|
||||
shouldSync: (row: Record<string, unknown>) => isSyncedRow(entityType, row),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a row takes part in sync. An entity can leave some of its rows out
|
||||
* (a per-install "last session"): they are not pulled, and a push, update or
|
||||
* delete aimed at one is refused.
|
||||
*/
|
||||
export function isSyncedRow(
|
||||
entityType: string,
|
||||
row: Record<string, unknown>,
|
||||
): boolean {
|
||||
const entity = getEntity(entityType);
|
||||
if (!entity?.shouldSync) return true;
|
||||
return entity.shouldSync(row) !== false;
|
||||
}
|
||||
|
||||
type RepositoryContext = ReturnType<typeof createCurrentRepositoryContext>;
|
||||
|
||||
export function isValidEntityType(value: unknown): value is SyncEntityType {
|
||||
if (typeof value !== "string") return false;
|
||||
registerCoreSyncEntities();
|
||||
return hasEntity(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates the stored row a sync payload corresponds to.
|
||||
*
|
||||
* Read and write have to agree on this. A singleton entity is keyed on its
|
||||
* owner rather than a sync id, and `user_preferences` — the only singleton —
|
||||
* has no `id` column at all, so an update cannot fall back to one: `table.id`
|
||||
* is undefined there and drizzle emits `WHERE = ?`.
|
||||
*/
|
||||
export function locateSyncRow(
|
||||
entityType: SyncEntityType,
|
||||
userId: string,
|
||||
syncId: string,
|
||||
): SQL {
|
||||
const { table, singleton } = entityConfig(entityType);
|
||||
|
||||
if (singleton) {
|
||||
return eq(table.userId, userId);
|
||||
}
|
||||
|
||||
return and(
|
||||
eq((table as typeof hosts).syncId, syncId),
|
||||
eq(table.userId, userId),
|
||||
)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* The syncId of a referenced row.
|
||||
*
|
||||
* Generic over the registry rather than an if/else over three known entities.
|
||||
* The old chain ended in an unguarded else that returned vaultProfiles, so a
|
||||
* fourth reference target would have silently resolved against the wrong
|
||||
* table; an unregistered entity is an error here instead.
|
||||
*/
|
||||
async function findReferenceSyncId(
|
||||
context: RepositoryContext,
|
||||
entityType: SyncReferenceEntity,
|
||||
id: number,
|
||||
userId: string,
|
||||
): Promise<string | null> {
|
||||
const { table } = entityConfig(entityType as SyncEntityType);
|
||||
const [row] = await context.drizzle
|
||||
.select({ syncId: table.syncId })
|
||||
.from(table)
|
||||
.where(and(eq(table.id, id), eq(table.userId, userId)))
|
||||
.limit(1);
|
||||
return row?.syncId ?? null;
|
||||
}
|
||||
|
||||
async function findReferenceId(
|
||||
context: RepositoryContext,
|
||||
entityType: SyncReferenceEntity,
|
||||
syncId: string,
|
||||
userId: string,
|
||||
): Promise<number | null> {
|
||||
const { table } = entityConfig(entityType as SyncEntityType);
|
||||
const [row] = await context.drizzle
|
||||
.select({ id: table.id })
|
||||
.from(table)
|
||||
.where(and(eq(table.syncId, syncId), eq(table.userId, userId)))
|
||||
.limit(1);
|
||||
return row?.id ?? null;
|
||||
}
|
||||
|
||||
function requireUserDataKey(userId: string): Buffer {
|
||||
return DataCrypto.validateUserAccess(userId);
|
||||
}
|
||||
|
||||
function decryptIfNeeded(
|
||||
entityType: SyncEntityType,
|
||||
row: Record<string, unknown>,
|
||||
userId: string,
|
||||
): Record<string, unknown> {
|
||||
const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
|
||||
if (!tableName) return row;
|
||||
const userDataKey = DataCrypto.getUserDataKey(userId);
|
||||
if (!userDataKey) return row;
|
||||
return DataCrypto.decryptRecord(
|
||||
tableName,
|
||||
row,
|
||||
userId,
|
||||
userDataKey,
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function encryptIfNeeded(
|
||||
entityType: SyncEntityType,
|
||||
row: Record<string, unknown>,
|
||||
userId: string,
|
||||
): Record<string, unknown> {
|
||||
const tableName = ENCRYPTED_ENTITY_TABLES[entityType];
|
||||
if (!tableName) return row;
|
||||
const userDataKey = requireUserDataKey(userId);
|
||||
return DataCrypto.encryptRecord(
|
||||
tableName,
|
||||
row,
|
||||
userId,
|
||||
userDataKey,
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function stripWritePayload(
|
||||
entityType: SyncEntityType,
|
||||
payload: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const { readOnlyFields } = entityConfig(entityType);
|
||||
const clean = { ...payload };
|
||||
delete clean.id;
|
||||
delete clean.userId;
|
||||
delete clean.syncId;
|
||||
for (const field of readOnlyFields) delete clean[field];
|
||||
return clean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/entity-types:
|
||||
* get:
|
||||
* summary: List the entity types this server syncs, in dependency order
|
||||
* description: The desktop app syncs the entities in the order returned here, because an entity referenced by another has to exist on the far side first. A server that predates this endpoint returns 404 and the client falls back to its built-in list.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The ordered entity types.
|
||||
* 500:
|
||||
* description: Failed to list entity types.
|
||||
*/
|
||||
router.get(
|
||||
"/entity-types",
|
||||
authenticateJWT,
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
registerCoreSyncEntities();
|
||||
return res.json({
|
||||
entityTypes: listEntities().map((entity) => ({
|
||||
type: entity.type,
|
||||
order: entity.order,
|
||||
singleton: entity.singleton === true,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to list sync entity types", error, {
|
||||
operation: "sync_entity_types",
|
||||
});
|
||||
return res.status(500).json({ error: "Failed to list entity types" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/{entityType}:
|
||||
* get:
|
||||
* summary: Pull synced rows for an entity type
|
||||
* description: Returns rows owned by the authenticated user whose updatedAt is newer than `since` (or all rows if omitted). Used by the desktop app's remote sync engine to reconcile the embedded backend against a connected remote server.
|
||||
* tags:
|
||||
* - Sync
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: entityType
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: since
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Rows updated since the given timestamp.
|
||||
* 400:
|
||||
* description: Unknown entity type.
|
||||
* 500:
|
||||
* description: Failed to fetch rows.
|
||||
*/
|
||||
router.get(
|
||||
"/:entityType",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const entityType = req.params.entityType;
|
||||
if (!isValidEntityType(entityType)) {
|
||||
return res.status(400).json({ error: "Unknown entity type" });
|
||||
}
|
||||
const since =
|
||||
typeof req.query.since === "string" && req.query.since
|
||||
? req.query.since
|
||||
: null;
|
||||
|
||||
try {
|
||||
const { table, singleton, shouldSync } = entityConfig(entityType);
|
||||
const context = createCurrentRepositoryContext();
|
||||
const conditions = [eq(table.userId, userId)];
|
||||
if (since && "updatedAt" in table) {
|
||||
conditions.push(
|
||||
timestampAtOrAfter((table as typeof hosts).updatedAt, since),
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await context.drizzle
|
||||
.select()
|
||||
.from(table as typeof hosts)
|
||||
.where(and(...conditions));
|
||||
|
||||
const synced = (rows as Record<string, unknown>[]).filter(shouldSync);
|
||||
|
||||
const decrypted = await Promise.all(
|
||||
synced.map(async (row) => {
|
||||
const result = await serializeSyncReferences(
|
||||
entityType,
|
||||
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
|
||||
(referenceType, id) =>
|
||||
findReferenceSyncId(context, referenceType, id, userId),
|
||||
);
|
||||
if (entityType === "hosts") {
|
||||
result.pluginSettings = await exportHostPluginSettings(
|
||||
row.id as number,
|
||||
);
|
||||
}
|
||||
return singleton
|
||||
? { ...result, syncId: `${entityType}:singleton` }
|
||||
: result;
|
||||
}),
|
||||
);
|
||||
|
||||
res.json({ rows: orderSyncRows(entityType, decrypted) });
|
||||
} catch (err) {
|
||||
databaseLogger.error(`Failed to pull sync rows for ${entityType}`, err, {
|
||||
operation: "sync_pull",
|
||||
entityType,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to fetch rows" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/tombstones:
|
||||
* post:
|
||||
* summary: Report a deletion from the other side of a sync pair
|
||||
* description: Applies a remote deletion locally (if the row still exists) and records the tombstone so future pulls stay consistent.
|
||||
* tags:
|
||||
* - Sync
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Deletion applied (or row already absent).
|
||||
* 400:
|
||||
* description: Unknown entity type or missing syncId.
|
||||
* 500:
|
||||
* description: Failed to apply deletion.
|
||||
*/
|
||||
router.post(
|
||||
"/tombstones",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const entityType = req.body?.entityType;
|
||||
const syncId = req.body?.syncId;
|
||||
if (
|
||||
!isValidEntityType(entityType) ||
|
||||
typeof syncId !== "string" ||
|
||||
!syncId
|
||||
) {
|
||||
return res.status(400).json({ error: "Missing entityType or syncId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { table, shouldSync } = entityConfig(entityType);
|
||||
const context = createCurrentRepositoryContext();
|
||||
const locateRow = locateSyncRow(entityType, userId, syncId);
|
||||
|
||||
const [target] = (await context.drizzle
|
||||
.select()
|
||||
.from(table as typeof hosts)
|
||||
.where(locateRow)
|
||||
.limit(1)) as Record<string, unknown>[];
|
||||
if (target && !shouldSync(target)) {
|
||||
return res.status(400).json({ error: "This row is not synced" });
|
||||
}
|
||||
|
||||
await context.drizzle.delete(table as typeof hosts).where(locateRow);
|
||||
|
||||
await createCurrentSyncTombstoneRepository().record(
|
||||
userId,
|
||||
entityType,
|
||||
syncId,
|
||||
);
|
||||
await DatabaseSaveTrigger.forceSave("sync_tombstone_applied");
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
databaseLogger.error("Failed to apply sync tombstone", err, {
|
||||
operation: "sync_tombstone_apply",
|
||||
entityType,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to apply deletion" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/{entityType}:
|
||||
* post:
|
||||
* summary: Upsert a synced row by syncId
|
||||
* description: Creates or updates a row by its syncId. Used by the desktop app's remote sync engine to push local-only or newer rows to the other side of a sync pair.
|
||||
* tags:
|
||||
* - Sync
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: entityType
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Row upserted.
|
||||
* 400:
|
||||
* description: Unknown entity type or missing syncId.
|
||||
* 500:
|
||||
* description: Failed to upsert row.
|
||||
*/
|
||||
router.post(
|
||||
"/:entityType",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const entityType = req.params.entityType;
|
||||
if (!isValidEntityType(entityType)) {
|
||||
return res.status(400).json({ error: "Unknown entity type" });
|
||||
}
|
||||
const payload = req.body?.row;
|
||||
const syncId = payload?.syncId;
|
||||
if (!payload || typeof syncId !== "string" || !syncId) {
|
||||
return res.status(400).json({ error: "Missing row.syncId" });
|
||||
}
|
||||
|
||||
try {
|
||||
// singleton is still needed below: those tables have no sync_id column
|
||||
// for the insert to populate.
|
||||
const { table, singleton, shouldSync } = entityConfig(entityType);
|
||||
const context = createCurrentRepositoryContext();
|
||||
|
||||
const locateRow = locateSyncRow(entityType, userId, syncId);
|
||||
if (!shouldSync(payload)) {
|
||||
return res.status(400).json({ error: "This row is not synced" });
|
||||
}
|
||||
|
||||
const existingRows = await context.drizzle
|
||||
.select()
|
||||
.from(table as typeof hosts)
|
||||
.where(locateRow)
|
||||
.limit(1);
|
||||
const existing = existingRows[0] as Record<string, unknown> | undefined;
|
||||
if (existing && !shouldSync(existing)) {
|
||||
return res.status(400).json({ error: "This row is not synced" });
|
||||
}
|
||||
|
||||
const resolvedPayload = await deserializeSyncReferences(
|
||||
entityType,
|
||||
payload,
|
||||
(referenceType, referenceSyncId) =>
|
||||
findReferenceId(context, referenceType, referenceSyncId, userId),
|
||||
);
|
||||
if (
|
||||
entityType === "hosts" &&
|
||||
typeof resolvedPayload.parentHostId === "number"
|
||||
) {
|
||||
const parentError = await validateParentHostId(
|
||||
userId,
|
||||
typeof existing?.id === "number" ? existing.id : null,
|
||||
resolvedPayload.parentHostId,
|
||||
);
|
||||
if (parentError) return res.status(400).json({ error: parentError });
|
||||
}
|
||||
const carriedPluginSettings = resolvedPayload.pluginSettings;
|
||||
delete resolvedPayload.pluginSettings;
|
||||
const writePayload = stripWritePayload(entityType, resolvedPayload);
|
||||
const encryptedPayload = encryptIfNeeded(
|
||||
entityType,
|
||||
writePayload,
|
||||
userId,
|
||||
);
|
||||
|
||||
// Through the returning helpers, because MySQL has no RETURNING and a
|
||||
// bare .returning() throws there.
|
||||
let resultRow: Record<string, unknown>;
|
||||
if (existing) {
|
||||
const updatedRows = await updateReturning(
|
||||
context,
|
||||
table as typeof hosts,
|
||||
encryptedPayload,
|
||||
locateRow,
|
||||
);
|
||||
resultRow = updatedRows[0] as Record<string, unknown>;
|
||||
} else {
|
||||
const insertedRows = await insertReturningWhere(
|
||||
context,
|
||||
table as typeof hosts,
|
||||
(singleton
|
||||
? { ...encryptedPayload, userId }
|
||||
: {
|
||||
...encryptedPayload,
|
||||
userId,
|
||||
syncId,
|
||||
}) as typeof hosts.$inferInsert,
|
||||
locateRow,
|
||||
);
|
||||
resultRow = insertedRows[0] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
if (entityType === "hosts" && typeof resultRow?.id === "number") {
|
||||
await importHostPluginSettings(resultRow.id, carriedPluginSettings);
|
||||
}
|
||||
|
||||
await DatabaseSaveTrigger.forceSave("sync_upsert");
|
||||
|
||||
res.json({
|
||||
row: decryptIfNeeded(entityType, resultRow, userId),
|
||||
created: !existing,
|
||||
});
|
||||
} catch (err) {
|
||||
databaseLogger.error(`Failed to upsert sync row for ${entityType}`, err, {
|
||||
operation: "sync_upsert",
|
||||
entityType,
|
||||
userId,
|
||||
});
|
||||
res.status(500).json({ error: "Failed to upsert row" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /sync/{entityType}/tombstones:
|
||||
* get:
|
||||
* summary: Pull deletion tombstones for an entity type
|
||||
* description: Returns tombstones recorded since `since` so the other side of a sync pair can apply the same deletions.
|
||||
* tags:
|
||||
* - Sync
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: entityType
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: since
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Tombstones recorded since the given timestamp.
|
||||
* 400:
|
||||
* description: Unknown entity type.
|
||||
* 500:
|
||||
* description: Failed to fetch tombstones.
|
||||
*/
|
||||
router.get(
|
||||
"/:entityType/tombstones",
|
||||
authenticateJWT,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const entityType = req.params.entityType;
|
||||
if (!isValidEntityType(entityType)) {
|
||||
return res.status(400).json({ error: "Unknown entity type" });
|
||||
}
|
||||
const since =
|
||||
typeof req.query.since === "string" && req.query.since
|
||||
? req.query.since
|
||||
: null;
|
||||
|
||||
try {
|
||||
const tombstones = await createCurrentSyncTombstoneRepository().listSince(
|
||||
userId,
|
||||
entityType,
|
||||
since,
|
||||
);
|
||||
res.json({ tombstones });
|
||||
} catch (err) {
|
||||
databaseLogger.error(
|
||||
`Failed to fetch sync tombstones for ${entityType}`,
|
||||
err,
|
||||
{ operation: "sync_tombstones_pull", entityType, userId },
|
||||
);
|
||||
res.status(500).json({ error: "Failed to fetch tombstones" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -8,6 +8,7 @@ import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
|
||||
import { parseUserAgent } from "../../utils/user-agent-parser.js";
|
||||
import { deleteUserAndRelatedData } from "./delete-user-data.js";
|
||||
import {
|
||||
allowsDesktopAutoSession,
|
||||
isLoopbackRequest,
|
||||
extractBearerOrCookieToken,
|
||||
isNativeTokenExportRequest,
|
||||
@@ -545,6 +546,30 @@ router.post("/logout", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
async function describeDesktopLink(userId: string) {
|
||||
if (process.env.ELECTRON_EMBEDDED !== "true") return null;
|
||||
try {
|
||||
const { getLink } = await import("../../sync/client/link-store.js");
|
||||
const link = await getLink();
|
||||
if (!link || link.userId !== userId) 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/me:
|
||||
@@ -595,6 +620,7 @@ router.get("/me", authenticateJWT, async (req: Request, res: Response) => {
|
||||
user.id,
|
||||
),
|
||||
show_donation_modal: showDonationModal,
|
||||
linked: await describeDesktopLink(user.id),
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get username", err);
|
||||
@@ -728,7 +754,7 @@ router.get("/setup-required", async (req, res) => {
|
||||
*/
|
||||
router.post("/internal/auto-session", async (req, res) => {
|
||||
try {
|
||||
if (!isLoopbackRequest(req)) {
|
||||
if (!allowsDesktopAutoSession() || !isLoopbackRequest(req)) {
|
||||
authLogger.warn(
|
||||
"Rejected non-loopback attempt to access auto-session endpoint",
|
||||
{ source: req.ip },
|
||||
|
||||
Reference in new issue
Block a user