mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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
@@ -16,7 +16,9 @@ import hostSidebarPreferencesRoutes from "./routes/host-sidebar-preferences.js";
|
||||
import credentialSidebarPreferencesRoutes from "./routes/credential-sidebar-preferences.js";
|
||||
import uiPreferencesRoutes from "./routes/ui-preferences.js";
|
||||
import { registerAuditLogRoutes } from "./routes/audit-log-routes.js";
|
||||
import syncRoutes from "./routes/sync.js";
|
||||
import syncRoutes from "../sync/server/routes.js";
|
||||
import syncLinkRoutes from "../sync/client/routes.js";
|
||||
import { syncChangeWatcher } from "../sync/server/change-watcher.js";
|
||||
import dashboardRoutes from "./routes/dashboard-routes.js";
|
||||
import {
|
||||
mountPluginApi,
|
||||
@@ -33,6 +35,13 @@ import path from "path";
|
||||
import os from "os";
|
||||
import "dotenv/config";
|
||||
import { databaseLogger, apiLogger } from "../utils/logger.js";
|
||||
import { getLocalVersion } from "../utils/app-version.js";
|
||||
import {
|
||||
compareSemver,
|
||||
fetchGitHubAPI,
|
||||
REPO_NAME,
|
||||
REPO_OWNER,
|
||||
} from "../utils/latest-release.js";
|
||||
import { AuthManager } from "../utils/auth-manager.js";
|
||||
import { DataCrypto } from "../utils/data-crypto.js";
|
||||
import { DatabaseFileEncryption } from "../utils/database-file-encryption.js";
|
||||
@@ -55,13 +64,7 @@ import {
|
||||
import { withCurrentSqliteForeignKeysDisabled } from "./repositories/sqlite-foreign-keys.js";
|
||||
import { applyPluginHostImportSettings } from "./routes/host-plugin-settings.js";
|
||||
import { parseUserAgent } from "../utils/user-agent-parser.js";
|
||||
import { getProxyAgent } from "../utils/proxy-agent.js";
|
||||
import type {
|
||||
CacheEntry,
|
||||
GitHubRelease,
|
||||
GitHubAPIResponse,
|
||||
AuthenticatedRequest,
|
||||
} from "../../types/index.js";
|
||||
import type { GitHubRelease, AuthenticatedRequest } from "../../types/index.js";
|
||||
import { DatabaseSaveTrigger } from "./db/index.js";
|
||||
import Database from "better-sqlite3";
|
||||
import { fileURLToPath } from "url";
|
||||
@@ -147,116 +150,6 @@ const upload = multer({
|
||||
},
|
||||
});
|
||||
|
||||
class GitHubCache {
|
||||
private cache: Map<string, CacheEntry> = new Map();
|
||||
private readonly CACHE_DURATION = 30 * 60 * 1000;
|
||||
|
||||
set<T>(key: string, data: T): void {
|
||||
const now = Date.now();
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: now,
|
||||
expiresAt: now + this.CACHE_DURATION,
|
||||
});
|
||||
}
|
||||
|
||||
get<T>(key: string): T | null {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data as T;
|
||||
}
|
||||
}
|
||||
|
||||
const githubCache = new GitHubCache();
|
||||
|
||||
function parseSemver(
|
||||
version: string | undefined,
|
||||
): [number, number, number] | null {
|
||||
const match = String(version || "").match(/(\d+)\.(\d+)(?:\.(\d+))?/);
|
||||
if (!match) return null;
|
||||
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3] || 0)];
|
||||
}
|
||||
|
||||
function compareSemver(
|
||||
a: string | undefined,
|
||||
b: string | undefined,
|
||||
): number | null {
|
||||
const parsedA = parseSemver(a);
|
||||
const parsedB = parseSemver(b);
|
||||
if (!parsedA || !parsedB) return null;
|
||||
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
if (parsedA[i] > parsedB[i]) return 1;
|
||||
if (parsedA[i] < parsedB[i]) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
const GITHUB_API_BASE = "https://api.github.com";
|
||||
const REPO_OWNER = "Termix-SSH";
|
||||
const REPO_NAME = "Termix";
|
||||
|
||||
async function fetchGitHubAPI<T>(
|
||||
endpoint: string,
|
||||
cacheKey: string,
|
||||
): Promise<GitHubAPIResponse<T>> {
|
||||
const cachedEntry = githubCache.get<CacheEntry<T>>(cacheKey);
|
||||
if (cachedEntry) {
|
||||
return {
|
||||
data: cachedEntry.data,
|
||||
cached: true,
|
||||
cache_age: Date.now() - cachedEntry.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `${GITHUB_API_BASE}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"User-Agent": "TermixUpdateChecker/1.0",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
dispatcher: getProxyAgent(url),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub API error: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as T;
|
||||
const cacheData: CacheEntry<T> = {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
expiresAt: Date.now() + 30 * 60 * 1000,
|
||||
};
|
||||
githubCache.set(cacheKey, cacheData);
|
||||
|
||||
return {
|
||||
data: data,
|
||||
cached: false,
|
||||
};
|
||||
} catch (error) {
|
||||
databaseLogger.error(`Failed to fetch from GitHub API`, error, {
|
||||
operation: "github_api",
|
||||
endpoint,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Skipped for /plugin-api: a plugin router brings its own parsers with its own
|
||||
// limit (see plugins/http.ts), and parsing here first would consume the body
|
||||
// and silently cap every plugin at this limit instead.
|
||||
@@ -279,6 +172,7 @@ app.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
app.use(syncChangeWatcher);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
@@ -343,51 +237,7 @@ app.get("/.well-known/acme-challenge/:token", acmeChallengeHandler);
|
||||
* description: Fetch error.
|
||||
*/
|
||||
app.get("/version", authenticateJWT, async (req, res) => {
|
||||
let localVersion = process.env.VERSION;
|
||||
|
||||
if (!localVersion) {
|
||||
const versionSources = [
|
||||
() => {
|
||||
try {
|
||||
const packagePath = path.resolve(process.cwd(), "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||
return packageJson.version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
try {
|
||||
const packagePath = path.resolve("/app", "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||
return packageJson.version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
try {
|
||||
const packagePath = path.resolve(__dirname, "../../../package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packagePath, "utf8"));
|
||||
return packageJson.version;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
for (const getVersion of versionSources) {
|
||||
try {
|
||||
const foundVersion = getVersion();
|
||||
if (foundVersion && foundVersion !== "unknown") {
|
||||
localVersion = foundVersion;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
const localVersion = getLocalVersion();
|
||||
|
||||
if (!localVersion) {
|
||||
databaseLogger.error("No version information available", undefined, {
|
||||
@@ -1567,6 +1417,7 @@ app.use("/credential-sidebar/preferences", credentialSidebarPreferencesRoutes);
|
||||
app.use("/ui-preferences", uiPreferencesRoutes);
|
||||
registerAuditLogRoutes(app, authenticateJWT);
|
||||
app.use("/sync", syncRoutes);
|
||||
app.use("/sync", syncLinkRoutes);
|
||||
app.use("/dashboard", dashboardRoutes);
|
||||
app.use("/plugins", pluginRoutes);
|
||||
app.use(
|
||||
|
||||
@@ -1598,28 +1598,80 @@ const migrateSchema = () => {
|
||||
}
|
||||
}
|
||||
|
||||
addColumnIfNotExists(
|
||||
"ssh_data",
|
||||
"local_only",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
addColumnIfNotExists("ssh_data", "shared_source", "TEXT");
|
||||
addColumnIfNotExists("ssh_credentials", "shared_source", "TEXT");
|
||||
addColumnIfNotExists(
|
||||
"ssh_folders",
|
||||
"local_only",
|
||||
"INTEGER NOT NULL DEFAULT 0",
|
||||
);
|
||||
|
||||
try {
|
||||
sqlite.prepare("SELECT id FROM sync_tombstones LIMIT 1").get();
|
||||
} catch {
|
||||
try {
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sync_tombstones (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
entity_type TEXT NOT NULL,
|
||||
sync_id TEXT NOT NULL,
|
||||
deleted_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
sqlite.exec(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sync_tombstones_user_entity ON sync_tombstones(user_id, entity_type)",
|
||||
sqlite.exec(`
|
||||
DROP TABLE IF EXISTS sync_tombstones;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
entity_type TEXT NOT NULL,
|
||||
sync_id TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
seq INTEGER NOT NULL DEFAULT 0,
|
||||
hash TEXT,
|
||||
deleted INTEGER NOT NULL DEFAULT 0,
|
||||
error TEXT,
|
||||
error_hash TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create sync_tombstones table", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_sync_records_user_entity_sync
|
||||
ON sync_records(user_id, entity_type, sync_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_records_user_seq
|
||||
ON sync_records(user_id, seq);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_conflicts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
entity_type TEXT NOT NULL,
|
||||
sync_id TEXT NOT NULL,
|
||||
local_row TEXT NOT NULL,
|
||||
server_revision INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sync_conflicts_user
|
||||
ON sync_conflicts(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_link (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
server_url TEXT NOT NULL,
|
||||
server_name TEXT,
|
||||
server_version TEXT,
|
||||
session_token TEXT,
|
||||
custom_headers TEXT,
|
||||
basic_auth TEXT,
|
||||
allow_invalid_certificate INTEGER NOT NULL DEFAULT 0,
|
||||
remote_user_id TEXT,
|
||||
remote_username TEXT,
|
||||
account TEXT,
|
||||
scope TEXT,
|
||||
known_types TEXT,
|
||||
cursor INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'idle',
|
||||
last_error TEXT,
|
||||
linked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
last_sync_at TEXT
|
||||
);
|
||||
`);
|
||||
} catch (createError) {
|
||||
databaseLogger.warn("Failed to create sync tables", {
|
||||
operation: "schema_migration",
|
||||
error: createError,
|
||||
});
|
||||
}
|
||||
// --- sync end ---
|
||||
|
||||
|
||||
@@ -251,6 +251,11 @@ export const hosts = mysqlTable(
|
||||
// databases (the embedded backend and a connected remote server) during
|
||||
// sync -- local autoincrement ids collide across instances.
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
// Desktop only: a host kept on this device that never goes to the server.
|
||||
localOnly: boolean("local_only").notNull().default(false),
|
||||
// Desktop only: set on the read-only copy of a host someone shared with
|
||||
// the linked account. JSON with the share's owner and permission level.
|
||||
sharedSource: text("shared_source"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
@@ -303,6 +308,7 @@ export const sshCredentials = mysqlTable(
|
||||
usageCount: int("usage_count").notNull().default(0),
|
||||
lastUsed: text("last_used"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
sharedSource: text("shared_source"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
@@ -353,6 +359,7 @@ export const sshFolders = mysqlTable(
|
||||
// to name sort, same convention as hosts.sortOrder.
|
||||
sortOrder: int("sort_order"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
localOnly: boolean("local_only").notNull().default(false),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
@@ -707,19 +714,100 @@ export const uiPreferences = mysqlTable("ui_preferences", {
|
||||
});
|
||||
|
||||
// --- sync begin ---
|
||||
// Records a delete for a synced entity type so the other side of a sync
|
||||
// pair (embedded desktop backend <-> connected remote server) learns about
|
||||
// the deletion instead of re-creating the row on its next pull.
|
||||
export const syncTombstones = mysqlTable("sync_tombstones", {
|
||||
/**
|
||||
* One row per synced record a user owns, on both ends of a sync link.
|
||||
*
|
||||
* On a server, revision counts the record's changes and seq is the position
|
||||
* in the user's change feed, which is what a desktop's cursor points at. On a
|
||||
* linked desktop, revision is the last server revision this device saw and
|
||||
* hash is what the record looked like then, so a different hash now means a
|
||||
* local edit waiting to be pushed.
|
||||
*/
|
||||
export const syncRecords = mysqlTable(
|
||||
"sync_records",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
entityType: varchar("entity_type", { length: 255 }).notNull(),
|
||||
syncId: varchar("sync_id", { length: 255 }).notNull(),
|
||||
revision: int("revision").notNull().default(0),
|
||||
seq: int("seq").notNull().default(0),
|
||||
hash: text("hash"),
|
||||
deleted: boolean("deleted").notNull().default(false),
|
||||
// Desktop only: why the server refused the last push of this record, and
|
||||
// the hash that was refused, so the same content is not re-sent forever.
|
||||
error: text("error"),
|
||||
errorHash: text("error_hash"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_sync_records_user_entity_sync").on(
|
||||
table.userId,
|
||||
table.entityType,
|
||||
table.syncId,
|
||||
),
|
||||
index("idx_sync_records_user_seq").on(table.userId, table.seq),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Desktop only: a local edit that lost to a newer server edit. The server
|
||||
* version was applied; this keeps the local one so the user can pick it.
|
||||
*/
|
||||
export const syncConflicts = mysqlTable(
|
||||
"sync_conflicts",
|
||||
{
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
entityType: text("entity_type").notNull(),
|
||||
syncId: text("sync_id").notNull(),
|
||||
localRow: text("local_row").notNull(),
|
||||
serverRevision: int("server_revision").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
},
|
||||
(table) => [index("idx_sync_conflicts_user").on(table.userId)],
|
||||
);
|
||||
|
||||
/**
|
||||
* Desktop only: the server this install is linked to. One row at most.
|
||||
* Secrets (session token, proxy headers, basic auth) are encrypted with the
|
||||
* system key.
|
||||
*/
|
||||
export const syncLink = mysqlTable("sync_link", {
|
||||
id: int("id").autoincrement().primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
entityType: text("entity_type").notNull(),
|
||||
syncId: text("sync_id").notNull(),
|
||||
deletedAt: text("deleted_at")
|
||||
serverUrl: text("server_url").notNull(),
|
||||
serverName: text("server_name"),
|
||||
serverVersion: text("server_version"),
|
||||
sessionToken: text("session_token"),
|
||||
customHeaders: text("custom_headers"),
|
||||
basicAuth: text("basic_auth"),
|
||||
allowInvalidCertificate: boolean("allow_invalid_certificate")
|
||||
.notNull()
|
||||
.default(false),
|
||||
remoteUserId: text("remote_user_id"),
|
||||
remoteUsername: text("remote_username"),
|
||||
// The linked account as the server describes it: roles, admin, permissions.
|
||||
account: text("account"),
|
||||
scope: text("scope"),
|
||||
knownTypes: text("known_types"),
|
||||
cursor: int("cursor").notNull().default(0),
|
||||
status: text("status").notNull().default("idle"),
|
||||
lastError: text("last_error"),
|
||||
linkedAt: text("linked_at")
|
||||
.notNull()
|
||||
.default(sql`(CURRENT_TIMESTAMP)`),
|
||||
lastSyncAt: text("last_sync_at"),
|
||||
});
|
||||
// --- sync end ---
|
||||
|
||||
|
||||
@@ -252,6 +252,11 @@ export const hosts = pgTable(
|
||||
// databases (the embedded backend and a connected remote server) during
|
||||
// sync -- local autoincrement ids collide across instances.
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
// Desktop only: a host kept on this device that never goes to the server.
|
||||
localOnly: boolean("local_only").notNull().default(false),
|
||||
// Desktop only: set on the read-only copy of a host someone shared with
|
||||
// the linked account. JSON with the share's owner and permission level.
|
||||
sharedSource: text("shared_source"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
@@ -304,6 +309,7 @@ export const sshCredentials = pgTable(
|
||||
usageCount: integer("usage_count").notNull().default(0),
|
||||
lastUsed: text("last_used"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
sharedSource: text("shared_source"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -354,6 +360,7 @@ export const sshFolders = pgTable(
|
||||
// to name sort, same convention as hosts.sortOrder.
|
||||
sortOrder: integer("sort_order"),
|
||||
syncId: varchar("sync_id", { length: 255 }).unique(),
|
||||
localOnly: boolean("local_only").notNull().default(false),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -708,19 +715,100 @@ export const uiPreferences = pgTable("ui_preferences", {
|
||||
});
|
||||
|
||||
// --- sync begin ---
|
||||
// Records a delete for a synced entity type so the other side of a sync
|
||||
// pair (embedded desktop backend <-> connected remote server) learns about
|
||||
// the deletion instead of re-creating the row on its next pull.
|
||||
export const syncTombstones = pgTable("sync_tombstones", {
|
||||
/**
|
||||
* One row per synced record a user owns, on both ends of a sync link.
|
||||
*
|
||||
* On a server, revision counts the record's changes and seq is the position
|
||||
* in the user's change feed, which is what a desktop's cursor points at. On a
|
||||
* linked desktop, revision is the last server revision this device saw and
|
||||
* hash is what the record looked like then, so a different hash now means a
|
||||
* local edit waiting to be pushed.
|
||||
*/
|
||||
export const syncRecords = pgTable(
|
||||
"sync_records",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
entityType: varchar("entity_type", { length: 255 }).notNull(),
|
||||
syncId: varchar("sync_id", { length: 255 }).notNull(),
|
||||
revision: integer("revision").notNull().default(0),
|
||||
seq: integer("seq").notNull().default(0),
|
||||
hash: text("hash"),
|
||||
deleted: boolean("deleted").notNull().default(false),
|
||||
// Desktop only: why the server refused the last push of this record, and
|
||||
// the hash that was refused, so the same content is not re-sent forever.
|
||||
error: text("error"),
|
||||
errorHash: text("error_hash"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_sync_records_user_entity_sync").on(
|
||||
table.userId,
|
||||
table.entityType,
|
||||
table.syncId,
|
||||
),
|
||||
index("idx_sync_records_user_seq").on(table.userId, table.seq),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Desktop only: a local edit that lost to a newer server edit. The server
|
||||
* version was applied; this keeps the local one so the user can pick it.
|
||||
*/
|
||||
export const syncConflicts = pgTable(
|
||||
"sync_conflicts",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
entityType: text("entity_type").notNull(),
|
||||
syncId: text("sync_id").notNull(),
|
||||
localRow: text("local_row").notNull(),
|
||||
serverRevision: integer("server_revision").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [index("idx_sync_conflicts_user").on(table.userId)],
|
||||
);
|
||||
|
||||
/**
|
||||
* Desktop only: the server this install is linked to. One row at most.
|
||||
* Secrets (session token, proxy headers, basic auth) are encrypted with the
|
||||
* system key.
|
||||
*/
|
||||
export const syncLink = pgTable("sync_link", {
|
||||
id: serial("id").primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
entityType: text("entity_type").notNull(),
|
||||
syncId: text("sync_id").notNull(),
|
||||
deletedAt: text("deleted_at")
|
||||
serverUrl: text("server_url").notNull(),
|
||||
serverName: text("server_name"),
|
||||
serverVersion: text("server_version"),
|
||||
sessionToken: text("session_token"),
|
||||
customHeaders: text("custom_headers"),
|
||||
basicAuth: text("basic_auth"),
|
||||
allowInvalidCertificate: boolean("allow_invalid_certificate")
|
||||
.notNull()
|
||||
.default(false),
|
||||
remoteUserId: text("remote_user_id"),
|
||||
remoteUsername: text("remote_username"),
|
||||
// The linked account as the server describes it: roles, admin, permissions.
|
||||
account: text("account"),
|
||||
scope: text("scope"),
|
||||
knownTypes: text("known_types"),
|
||||
cursor: integer("cursor").notNull().default(0),
|
||||
status: text("status").notNull().default("idle"),
|
||||
lastError: text("last_error"),
|
||||
linkedAt: text("linked_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
lastSyncAt: text("last_sync_at"),
|
||||
});
|
||||
// --- sync end ---
|
||||
|
||||
|
||||
@@ -244,6 +244,11 @@ export const hosts = sqliteTable(
|
||||
// databases (the embedded backend and a connected remote server) during
|
||||
// sync -- local autoincrement ids collide across instances.
|
||||
syncId: text("sync_id").unique(),
|
||||
// Desktop only: a host kept on this device that never goes to the server.
|
||||
localOnly: integer("local_only", { mode: "boolean" }).notNull().default(false),
|
||||
// Desktop only: set on the read-only copy of a host someone shared with
|
||||
// the linked account. JSON with the share's owner and permission level.
|
||||
sharedSource: text("shared_source"),
|
||||
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
@@ -296,6 +301,7 @@ export const sshCredentials = sqliteTable(
|
||||
usageCount: integer("usage_count").notNull().default(0),
|
||||
lastUsed: text("last_used"),
|
||||
syncId: text("sync_id").unique(),
|
||||
sharedSource: text("shared_source"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -346,6 +352,7 @@ export const sshFolders = sqliteTable(
|
||||
// to name sort, same convention as hosts.sortOrder.
|
||||
sortOrder: integer("sort_order"),
|
||||
syncId: text("sync_id").unique(),
|
||||
localOnly: integer("local_only", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
@@ -704,19 +711,102 @@ export const uiPreferences = sqliteTable("ui_preferences", {
|
||||
});
|
||||
|
||||
// --- sync begin ---
|
||||
// Records a delete for a synced entity type so the other side of a sync
|
||||
// pair (embedded desktop backend <-> connected remote server) learns about
|
||||
// the deletion instead of re-creating the row on its next pull.
|
||||
export const syncTombstones = sqliteTable("sync_tombstones", {
|
||||
/**
|
||||
* One row per synced record a user owns, on both ends of a sync link.
|
||||
*
|
||||
* On a server, revision counts the record's changes and seq is the position
|
||||
* in the user's change feed, which is what a desktop's cursor points at. On a
|
||||
* linked desktop, revision is the last server revision this device saw and
|
||||
* hash is what the record looked like then, so a different hash now means a
|
||||
* local edit waiting to be pushed.
|
||||
*/
|
||||
export const syncRecords = sqliteTable(
|
||||
"sync_records",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
entityType: text("entity_type").notNull(),
|
||||
syncId: text("sync_id").notNull(),
|
||||
revision: integer("revision").notNull().default(0),
|
||||
seq: integer("seq").notNull().default(0),
|
||||
hash: text("hash"),
|
||||
deleted: integer("deleted", { mode: "boolean" }).notNull().default(false),
|
||||
// Desktop only: why the server refused the last push of this record, and
|
||||
// the hash that was refused, so the same content is not re-sent forever.
|
||||
error: text("error"),
|
||||
errorHash: text("error_hash"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex("idx_sync_records_user_entity_sync").on(
|
||||
table.userId,
|
||||
table.entityType,
|
||||
table.syncId,
|
||||
),
|
||||
index("idx_sync_records_user_seq").on(table.userId, table.seq),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Desktop only: a local edit that lost to a newer server edit. The server
|
||||
* version was applied; this keeps the local one so the user can pick it.
|
||||
*/
|
||||
export const syncConflicts = sqliteTable(
|
||||
"sync_conflicts",
|
||||
{
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
entityType: text("entity_type").notNull(),
|
||||
syncId: text("sync_id").notNull(),
|
||||
localRow: text("local_row").notNull(),
|
||||
serverRevision: integer("server_revision").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
},
|
||||
(table) => [index("idx_sync_conflicts_user").on(table.userId)],
|
||||
);
|
||||
|
||||
/**
|
||||
* Desktop only: the server this install is linked to. One row at most.
|
||||
* Secrets (session token, proxy headers, basic auth) are encrypted with the
|
||||
* system key.
|
||||
*/
|
||||
export const syncLink = sqliteTable("sync_link", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
userId: text("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
entityType: text("entity_type").notNull(),
|
||||
syncId: text("sync_id").notNull(),
|
||||
deletedAt: text("deleted_at")
|
||||
serverUrl: text("server_url").notNull(),
|
||||
serverName: text("server_name"),
|
||||
serverVersion: text("server_version"),
|
||||
sessionToken: text("session_token"),
|
||||
customHeaders: text("custom_headers"),
|
||||
basicAuth: text("basic_auth"),
|
||||
allowInvalidCertificate: integer("allow_invalid_certificate", {
|
||||
mode: "boolean",
|
||||
})
|
||||
.notNull()
|
||||
.default(false),
|
||||
remoteUserId: text("remote_user_id"),
|
||||
remoteUsername: text("remote_username"),
|
||||
// The linked account as the server describes it: roles, admin, permissions.
|
||||
account: text("account"),
|
||||
scope: text("scope"),
|
||||
knownTypes: text("known_types"),
|
||||
cursor: integer("cursor").notNull().default(0),
|
||||
status: text("status").notNull().default("idle"),
|
||||
lastError: text("last_error"),
|
||||
linkedAt: text("linked_at")
|
||||
.notNull()
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
lastSyncAt: text("last_sync_at"),
|
||||
});
|
||||
// --- sync end ---
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ import { SettingsRepository } from "./settings-repository.js";
|
||||
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
|
||||
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
|
||||
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
|
||||
import { SyncTombstoneRepository } from "./sync-tombstone-repository.js";
|
||||
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
|
||||
import { UserDataExportRepository } from "./user-data-export-repository.js";
|
||||
import { UserPreferenceRepository } from "./user-preference-repository.js";
|
||||
@@ -138,13 +137,6 @@ export function createCurrentCredentialRepository(): CredentialRepository {
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentSyncTombstoneRepository(): SyncTombstoneRepository {
|
||||
return new SyncTombstoneRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
createCurrentRepositoryWriteHook("sync_tombstone_repository_write"),
|
||||
);
|
||||
}
|
||||
|
||||
export function createCurrentHostFolderRepository(): HostFolderRepository {
|
||||
return new HostFolderRepository(
|
||||
createCurrentRepositoryContext(),
|
||||
|
||||
@@ -85,6 +85,28 @@ export class HostFolderRepository {
|
||||
.where(eq(sshFolders.userId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a folder, its subfolders and every host in them on this device
|
||||
* only, or lets them sync again.
|
||||
*/
|
||||
async setLocalOnly(
|
||||
userId: string,
|
||||
folderName: string,
|
||||
localOnly: boolean,
|
||||
): Promise<void> {
|
||||
const inFolder = (col: SQLiteColumn) =>
|
||||
or(eq(col, folderName), like(col, `${folderName} / %`));
|
||||
await this.context.drizzle
|
||||
.update(sshFolders)
|
||||
.set({ localOnly })
|
||||
.where(and(eq(sshFolders.userId, userId), inFolder(sshFolders.name)));
|
||||
await this.context.drizzle
|
||||
.update(hosts)
|
||||
.set({ localOnly, updatedAt: new Date().toISOString() })
|
||||
.where(and(eq(hosts.userId, userId), inFolder(hosts.folder)));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async upsertMetadata(
|
||||
userId: string,
|
||||
name: string,
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { syncTombstones } from "../db/schema.js";
|
||||
import { timestampAtOrAfter } from "../sync-timestamp.js";
|
||||
import type { DatabaseContext } from "./database-context.js";
|
||||
|
||||
export type SyncTombstoneRecord = typeof syncTombstones.$inferSelect;
|
||||
|
||||
/**
|
||||
* The wire name of a synced entity.
|
||||
*
|
||||
* A string rather than a union: the set is whatever core and the installed
|
||||
* plugins have registered, so it cannot be known at compile time. The column
|
||||
* was already free-form text with no enum constraint, so nothing in the
|
||||
* database changes. sync.ts validates a name through the registry before it
|
||||
* reaches a query.
|
||||
*/
|
||||
export type SyncEntityType = string;
|
||||
|
||||
export class SyncTombstoneRepository {
|
||||
constructor(
|
||||
private readonly context: DatabaseContext,
|
||||
private readonly onWrite?: () => void | Promise<void>,
|
||||
) {}
|
||||
|
||||
async record(
|
||||
userId: string,
|
||||
entityType: SyncEntityType,
|
||||
syncId: string,
|
||||
): Promise<void> {
|
||||
if (!syncId) return;
|
||||
await this.context.drizzle.insert(syncTombstones).values({
|
||||
userId,
|
||||
entityType,
|
||||
syncId,
|
||||
});
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async recordMany(
|
||||
userId: string,
|
||||
entityType: SyncEntityType,
|
||||
syncIds: string[],
|
||||
): Promise<void> {
|
||||
const rows = syncIds.filter(Boolean);
|
||||
if (rows.length === 0) return;
|
||||
await this.context.drizzle
|
||||
.insert(syncTombstones)
|
||||
.values(rows.map((syncId) => ({ userId, entityType, syncId })));
|
||||
await this.afterWrite();
|
||||
}
|
||||
|
||||
async listSince(
|
||||
userId: string,
|
||||
entityType: SyncEntityType,
|
||||
since: string | null,
|
||||
): Promise<SyncTombstoneRecord[]> {
|
||||
const conditions = [
|
||||
eq(syncTombstones.userId, userId),
|
||||
eq(syncTombstones.entityType, entityType),
|
||||
];
|
||||
if (since)
|
||||
conditions.push(timestampAtOrAfter(syncTombstones.deletedAt, since));
|
||||
|
||||
return this.context.drizzle
|
||||
.select()
|
||||
.from(syncTombstones)
|
||||
.where(and(...conditions));
|
||||
}
|
||||
|
||||
private async afterWrite(): Promise<void> {
|
||||
await this.onWrite?.();
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { sql, type SQLWrapper } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Sync cursors and stored timestamps do not share a layout.
|
||||
*
|
||||
* `updated_at` and `deleted_at` are TEXT columns written both by
|
||||
* `default(sql`CURRENT_TIMESTAMP`)` ("2026-07-29 10:11:21") and by
|
||||
* `new Date().toISOString()` ("2026-07-29T10:11:21.123Z"), while the desktop
|
||||
* sync engine always sends the ISO form as `since`. Comparing those as text is
|
||||
* decided at position 10, where ' ' (0x20) sorts below 'T' (0x54), so the
|
||||
* answer depends on which writer produced the row rather than on when it was
|
||||
* written -- and `column > :isoCursor` is false for every row stored in the
|
||||
* CURRENT_TIMESTAMP form, however new it is.
|
||||
*
|
||||
* Both layouts share a prefix once the separator is levelled, so comparing
|
||||
* "YYYY-MM-DD HH:MM:SS" on both sides is layout-independent. `replace` and
|
||||
* `substr` are used rather than `datetime()` to keep the expression portable
|
||||
* across engines.
|
||||
*/
|
||||
const CANONICAL_TIMESTAMP_LENGTH = 19;
|
||||
|
||||
export function normalizeSyncTimestamp(value: string): string {
|
||||
return value.replace("T", " ").slice(0, CANONICAL_TIMESTAMP_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* `>=` rather than `>`: normalising truncates sub-second precision, so a strict
|
||||
* comparison would permanently skip rows written in the same second as the
|
||||
* cursor. Re-sending that boundary second costs nothing -- the sync engine only
|
||||
* pushes a row when one side is strictly newer, so rows equal on both sides are
|
||||
* a no-op.
|
||||
*/
|
||||
export function timestampAtOrAfter(column: SQLWrapper, since: string) {
|
||||
return sql`substr(replace(${column}, 'T', ' '), 1, ${CANONICAL_TIMESTAMP_LENGTH}) >= ${normalizeSyncTimestamp(since)}`;
|
||||
}
|
||||
Reference in new issue
Block a user