mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
fix: preserve remote sync references (#1092)
This commit is contained in:
@@ -16,12 +16,12 @@ const fs = require("fs");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const SYNCED_ENTITY_TYPES = [
|
const SYNCED_ENTITY_TYPES = [
|
||||||
"hosts",
|
|
||||||
"sshCredentials",
|
"sshCredentials",
|
||||||
"sshFolders",
|
|
||||||
"snippets",
|
|
||||||
"snippetFolders",
|
|
||||||
"vaultProfiles",
|
"vaultProfiles",
|
||||||
|
"sshFolders",
|
||||||
|
"snippetFolders",
|
||||||
|
"hosts",
|
||||||
|
"snippets",
|
||||||
"dashboardServiceLinks",
|
"dashboardServiceLinks",
|
||||||
"homepageItems",
|
"homepageItems",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
|
||||||
|
|
||||||
|
export type SyncReferenceEntity = "sshCredentials" | "vaultProfiles";
|
||||||
|
|
||||||
|
interface SyncReference {
|
||||||
|
field: string;
|
||||||
|
syncField: string;
|
||||||
|
entityType: SyncReferenceEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HOST_REFERENCES: SyncReference[] = [
|
||||||
|
{
|
||||||
|
field: "credentialId",
|
||||||
|
syncField: "credentialSyncId",
|
||||||
|
entityType: "sshCredentials",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "rdpCredentialId",
|
||||||
|
syncField: "rdpCredentialSyncId",
|
||||||
|
entityType: "sshCredentials",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "vncCredentialId",
|
||||||
|
syncField: "vncCredentialSyncId",
|
||||||
|
entityType: "sshCredentials",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "telnetCredentialId",
|
||||||
|
syncField: "telnetCredentialSyncId",
|
||||||
|
entityType: "sshCredentials",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: "vaultProfileId",
|
||||||
|
syncField: "vaultProfileSyncId",
|
||||||
|
entityType: "vaultProfiles",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const REFERENCES: Partial<Record<SyncEntityType, SyncReference[]>> = {
|
||||||
|
hosts: HOST_REFERENCES,
|
||||||
|
sshFolders: [HOST_REFERENCES[0]],
|
||||||
|
};
|
||||||
|
|
||||||
|
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 };
|
||||||
|
for (const reference of REFERENCES[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 };
|
||||||
|
for (const reference of REFERENCES[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;
|
||||||
|
}
|
||||||
@@ -21,6 +21,11 @@ import {
|
|||||||
createCurrentSyncTombstoneRepository,
|
createCurrentSyncTombstoneRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
|
import type { SyncEntityType } from "../repositories/sync-tombstone-repository.js";
|
||||||
|
import {
|
||||||
|
deserializeSyncReferences,
|
||||||
|
serializeSyncReferences,
|
||||||
|
type SyncReferenceEntity,
|
||||||
|
} from "./sync-references.js";
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const authManager = AuthManager.getInstance();
|
const authManager = AuthManager.getInstance();
|
||||||
@@ -65,11 +70,65 @@ const ENTITY_CONFIG: Record<SyncEntityType, EntityConfig> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG));
|
const VALID_ENTITY_TYPES = new Set(Object.keys(ENTITY_CONFIG));
|
||||||
|
type RepositoryContext = ReturnType<typeof createCurrentRepositoryContext>;
|
||||||
|
|
||||||
export function isValidEntityType(value: unknown): value is SyncEntityType {
|
export function isValidEntityType(value: unknown): value is SyncEntityType {
|
||||||
return typeof value === "string" && VALID_ENTITY_TYPES.has(value);
|
return typeof value === "string" && VALID_ENTITY_TYPES.has(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function findReferenceSyncId(
|
||||||
|
context: RepositoryContext,
|
||||||
|
entityType: SyncReferenceEntity,
|
||||||
|
id: number,
|
||||||
|
userId: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (entityType === "sshCredentials") {
|
||||||
|
const [row] = await context.drizzle
|
||||||
|
.select({ syncId: sshCredentials.syncId })
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(and(eq(sshCredentials.id, id), eq(sshCredentials.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
return row?.syncId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await context.drizzle
|
||||||
|
.select({ syncId: vaultProfiles.syncId })
|
||||||
|
.from(vaultProfiles)
|
||||||
|
.where(and(eq(vaultProfiles.id, id), eq(vaultProfiles.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
return row?.syncId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findReferenceId(
|
||||||
|
context: RepositoryContext,
|
||||||
|
entityType: SyncReferenceEntity,
|
||||||
|
syncId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<number | null> {
|
||||||
|
if (entityType === "sshCredentials") {
|
||||||
|
const [row] = await context.drizzle
|
||||||
|
.select({ id: sshCredentials.id })
|
||||||
|
.from(sshCredentials)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sshCredentials.syncId, syncId),
|
||||||
|
eq(sshCredentials.userId, userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return row?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [row] = await context.drizzle
|
||||||
|
.select({ id: vaultProfiles.id })
|
||||||
|
.from(vaultProfiles)
|
||||||
|
.where(
|
||||||
|
and(eq(vaultProfiles.syncId, syncId), eq(vaultProfiles.userId, userId)),
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
return row?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
function requireUserDataKey(userId: string): Buffer {
|
function requireUserDataKey(userId: string): Buffer {
|
||||||
return DataCrypto.validateUserAccess(userId);
|
return DataCrypto.validateUserAccess(userId);
|
||||||
}
|
}
|
||||||
@@ -173,8 +232,15 @@ router.get(
|
|||||||
.from(table as typeof hosts)
|
.from(table as typeof hosts)
|
||||||
.where(and(...conditions));
|
.where(and(...conditions));
|
||||||
|
|
||||||
const decrypted = rows.map((row) =>
|
const decrypted = await Promise.all(
|
||||||
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
|
rows.map((row) =>
|
||||||
|
serializeSyncReferences(
|
||||||
|
entityType,
|
||||||
|
decryptIfNeeded(entityType, row as Record<string, unknown>, userId),
|
||||||
|
(referenceType, id) =>
|
||||||
|
findReferenceSyncId(context, referenceType, id, userId),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
res.json({ rows: decrypted });
|
res.json({ rows: decrypted });
|
||||||
@@ -242,7 +308,13 @@ router.post(
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
const existing = existingRows[0] as Record<string, unknown> | undefined;
|
const existing = existingRows[0] as Record<string, unknown> | undefined;
|
||||||
|
|
||||||
const writePayload = stripWritePayload(entityType, payload);
|
const resolvedPayload = await deserializeSyncReferences(
|
||||||
|
entityType,
|
||||||
|
payload,
|
||||||
|
(referenceType, referenceSyncId) =>
|
||||||
|
findReferenceId(context, referenceType, referenceSyncId, userId),
|
||||||
|
);
|
||||||
|
const writePayload = stripWritePayload(entityType, resolvedPayload);
|
||||||
const encryptedPayload = encryptIfNeeded(
|
const encryptedPayload = encryptIfNeeded(
|
||||||
entityType,
|
entityType,
|
||||||
writePayload,
|
writePayload,
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
deserializeSyncReferences,
|
||||||
|
serializeSyncReferences,
|
||||||
|
} from "../../database/routes/sync-references.js";
|
||||||
|
|
||||||
|
describe("sync references", () => {
|
||||||
|
it("serializes database-local host IDs as stable sync IDs", async () => {
|
||||||
|
const row = await serializeSyncReferences(
|
||||||
|
"hosts",
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
credentialId: 12,
|
||||||
|
rdpCredentialId: 13,
|
||||||
|
vncCredentialId: null,
|
||||||
|
telnetCredentialId: null,
|
||||||
|
vaultProfileId: 4,
|
||||||
|
},
|
||||||
|
async (entityType, id) => `${entityType}-${id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(row).toMatchObject({
|
||||||
|
credentialSyncId: "sshCredentials-12",
|
||||||
|
rdpCredentialSyncId: "sshCredentials-13",
|
||||||
|
vncCredentialSyncId: null,
|
||||||
|
telnetCredentialSyncId: null,
|
||||||
|
vaultProfileSyncId: "vaultProfiles-4",
|
||||||
|
});
|
||||||
|
expect(row).not.toHaveProperty("credentialId");
|
||||||
|
expect(row).not.toHaveProperty("vaultProfileId");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves stable sync IDs to IDs from the receiving database", async () => {
|
||||||
|
const ids = new Map([
|
||||||
|
["sshCredentials:credential-sync", 91],
|
||||||
|
["vaultProfiles:vault-sync", 37],
|
||||||
|
]);
|
||||||
|
const row = await deserializeSyncReferences(
|
||||||
|
"hosts",
|
||||||
|
{
|
||||||
|
credentialId: 12,
|
||||||
|
credentialSyncId: "credential-sync",
|
||||||
|
rdpCredentialSyncId: null,
|
||||||
|
vncCredentialSyncId: null,
|
||||||
|
telnetCredentialSyncId: null,
|
||||||
|
vaultProfileSyncId: "vault-sync",
|
||||||
|
},
|
||||||
|
async (entityType, syncId) => ids.get(`${entityType}:${syncId}`) ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(row).toMatchObject({
|
||||||
|
credentialId: 91,
|
||||||
|
rdpCredentialId: null,
|
||||||
|
vncCredentialId: null,
|
||||||
|
telnetCredentialId: null,
|
||||||
|
vaultProfileId: 37,
|
||||||
|
});
|
||||||
|
expect(row).not.toHaveProperty("credentialSyncId");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a row whose referenced dependency has not synced", async () => {
|
||||||
|
await expect(
|
||||||
|
deserializeSyncReferences(
|
||||||
|
"sshFolders",
|
||||||
|
{ credentialSyncId: "missing" },
|
||||||
|
async () => null,
|
||||||
|
),
|
||||||
|
).rejects.toThrow("Missing sshCredentials dependency");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user