mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
* feat: let the login page send the token back to the mobile app * feat: add host-scripts entry to the plugin sdk * feat: let plugins say how they show in the mobile app * feat: frame host for running one plugin view outside the shell * fix: build the SDK host modules in production mode * chore: note the SDK host build fix in the changelog * feat: list plugin engine ranges for the mobile app * feat: host color and icon columns that sync * chore: update translations and plugin pins * fix: test * fix: show source address when rejecting proxy auth * fix: deleting your own account removes all of its data * fix(deps): update body-parser to 2.4.0 (#1736) * fix: body-parser 보안 패치 반영 * chore: 자동 검사를 막던 기존 JSON 서식 정리 --------- Co-authored-by: renovys <180682734+renovys@users.noreply.github.com> * fix(sdk): decode command output as GBK when it is not valid UTF-8 (#1735) * fix: honor shared credentials on shared hosts (#1730) * fix: honor shared credentials on shared hosts and in personal SSH auth * fix: snapshot shared SSH auth from a credential shared with the host owner * fix: size DataView list columns by the table's own width (#1713) * fix: size DataView list columns by the table's own width The list grid was built from every column, so columns hidden below md/lg left empty tracks and the visible cells landed under the wrong headers. Each width now gets a template of only its visible columns (CSS variables + container queries on the table, so a narrow panel counts, not the window), the header uses the same gap as the rows, and rows use a minimum height so wrapped cells no longer overlap. * test: wait for the title bar offset effect before asserting The bar shows once getTitleBarVisible resolves, but --termix-titlebar-height is set in a later passive effect, so reading it right after findByText raced (3-5 of 40 runs under load, on main too). * chore: rerun CI against dev-26.10.6 * fix: unlock passphrase keys once per connect burst (#1737) * feat: give ssh transports the host origin and sync id (#1717) * fix: wake other clients after a sync push (Plugin-Sync#1) * feat: look up local row ids by sync id for the sync plugin (Plugin-Sync#4) * feat: rail items can show a status dot (Plugin-Sync#5) * fix: add the desktop shared copy banner text (#1709) * feat: sidebar preference for alternating rows (#1712) * fix: striped rows text * feat: shell can open a saved host's editor for plugins (Plugin-SSH-Terminal#36) * fix: show the logo in the macos menu bar (#1728) * fix: number shortcuts skip the home tab (#1724) * fix: show plugins that could not be downloaded as failed (#1714) * fix: pass the host id as text when opening its editor * fix: retry a plugin bundle that was not ready yet (#1708) * fix: let commonjs plugins fall back to core packages on the desktop * fix: let non docker installs switch nginx to https (#1711) * feat: remove a folder share from its hosts too (#1719) * chore: changelog for merged pull requests * fix: accept the release .sig when installing a plugin from a file * feat: folder logins for shared hosts, read only plugin sync, full desktop builds * fix: test * fix: stop dev runner restart loops and narrow plugin watchers * chore: update translations and plugin pins * chore: lint, format, and bump version to 26.10.6 --------- Co-authored-by: renovys <renovy@gmail.com> Co-authored-by: renovys <180682734+renovys@users.noreply.github.com> Co-authored-by: Sebastian Cao <cycsmail@gmail.com> Co-authored-by: Jonathan Rehuel Lewerissa <jonathan.rehuel16@gmail.com> Co-authored-by: Rhiz3K <33246262+Rhiz3K@users.noreply.github.com>
297 lines
9.0 KiB
TypeScript
297 lines
9.0 KiB
TypeScript
/**
|
|
* Applying a desktop's pushed changes on the server.
|
|
*
|
|
* Each op names the revision its sender last saw. If the server has moved on
|
|
* since, the op is a conflict and the server's version is sent back instead
|
|
* of being overwritten. Every op gets its own result, so one bad row never
|
|
* holds up the rest of the batch.
|
|
*/
|
|
|
|
import type { SyncRow } from "@termix-ssh/plugin-sdk/backend";
|
|
import { getEntity } from "../../plugins/sync-registry.js";
|
|
import { PermissionManager } from "../../utils/permission-manager.js";
|
|
import { syncLogger } from "../../utils/logger.js";
|
|
import {
|
|
hasHostLocks,
|
|
hostLock,
|
|
manualCreateBlockedBy,
|
|
} from "../../hosts/locks.js";
|
|
import { createCurrentHostResolutionRepository } from "../../database/repositories/factory.js";
|
|
import {
|
|
createResolvers,
|
|
deleteStoredRow,
|
|
loadWireRow,
|
|
writeWireRow,
|
|
} from "../store.js";
|
|
import { allocateSeq, getRecord, putRecord, recordKey } from "../records.js";
|
|
import { orderSelfReferences } from "../wire.js";
|
|
import { markChanged, notifyListeners, reconcileUser } from "./feed.js";
|
|
|
|
export interface PushOp {
|
|
entityType: string;
|
|
syncId: string;
|
|
baseRevision: number;
|
|
op: "upsert" | "delete";
|
|
row?: SyncRow;
|
|
}
|
|
|
|
export type PushResult =
|
|
| { syncId: string; entityType: string; status: "ok"; revision: number }
|
|
| {
|
|
syncId: string;
|
|
entityType: string;
|
|
status: "conflict";
|
|
revision: number;
|
|
deleted: boolean;
|
|
row: SyncRow | null;
|
|
}
|
|
| {
|
|
syncId: string;
|
|
entityType: string;
|
|
status: "rejected";
|
|
reason: string;
|
|
};
|
|
|
|
export const MAX_PUSH_OPS = 500;
|
|
|
|
/** A host a plugin manages can't be changed from a desktop either. */
|
|
async function hostPushLocked(
|
|
userId: string,
|
|
syncId: string,
|
|
exists: boolean,
|
|
): Promise<boolean> {
|
|
if (!hasHostLocks()) return false;
|
|
if (!exists) return manualCreateBlockedBy() !== null;
|
|
const hostId =
|
|
await createCurrentHostResolutionRepository().findHostIdBySyncIdForUser(
|
|
syncId,
|
|
userId,
|
|
);
|
|
return hostId !== null && hostId !== undefined && hostLock(hostId) !== null;
|
|
}
|
|
|
|
function isPushOp(value: unknown): value is PushOp {
|
|
if (!value || typeof value !== "object") return false;
|
|
const op = value as Record<string, unknown>;
|
|
return (
|
|
typeof op.entityType === "string" &&
|
|
typeof op.syncId === "string" &&
|
|
op.syncId.length > 0 &&
|
|
op.syncId.length <= 191 &&
|
|
typeof op.baseRevision === "number" &&
|
|
(op.op === "upsert" || op.op === "delete") &&
|
|
(op.op === "delete" || (!!op.row && typeof op.row === "object"))
|
|
);
|
|
}
|
|
|
|
export function parsePushOps(body: unknown): PushOp[] | null {
|
|
const ops = (body as { ops?: unknown })?.ops;
|
|
if (!Array.isArray(ops) || ops.length > MAX_PUSH_OPS) return null;
|
|
return ops.every(isPushOp) ? ops : null;
|
|
}
|
|
|
|
/** Upserts in dependency order, then deletes in reverse, children first. */
|
|
export function orderOps(ops: PushOp[]): PushOp[] {
|
|
const order = (op: PushOp) => getEntity(op.entityType)?.order ?? 1_000_000;
|
|
const upserts = ops
|
|
.filter((op) => op.op === "upsert")
|
|
.sort((a, b) => order(a) - order(b));
|
|
const deletes = ops
|
|
.filter((op) => op.op === "delete")
|
|
.sort((a, b) => order(b) - order(a));
|
|
|
|
const orderedUpserts: PushOp[] = [];
|
|
const byType = new Map<string, PushOp[]>();
|
|
for (const op of upserts) {
|
|
const list = byType.get(op.entityType) ?? [];
|
|
list.push(op);
|
|
byType.set(op.entityType, list);
|
|
}
|
|
for (const [type, list] of byType) {
|
|
const entity = getEntity(type);
|
|
if (!entity) {
|
|
orderedUpserts.push(...list);
|
|
continue;
|
|
}
|
|
const bySyncId = new Map(list.map((op) => [op.syncId, op]));
|
|
const rows = orderSelfReferences(
|
|
entity,
|
|
list.map((op) => ({ ...(op.row as SyncRow), syncId: op.syncId })),
|
|
);
|
|
for (const row of rows) {
|
|
orderedUpserts.push(bySyncId.get(row.syncId as string)!);
|
|
}
|
|
}
|
|
return [...orderedUpserts, ...deletes];
|
|
}
|
|
|
|
/** Call inside withUserLock. */
|
|
export async function applyPush(
|
|
userId: string,
|
|
ops: PushOp[],
|
|
): Promise<PushResult[]> {
|
|
const { wire } = await reconcileUser(userId, { force: true });
|
|
const permissions = PermissionManager.getInstance();
|
|
let missed = false;
|
|
const resolvers = createResolvers(userId, () => {
|
|
missed = true;
|
|
});
|
|
const results = new Map<string, PushResult>();
|
|
// Rows written before something they point at, for a second pass.
|
|
const retry: PushOp[] = [];
|
|
|
|
for (const op of orderOps(ops)) {
|
|
const key = recordKey(op.entityType, op.syncId);
|
|
const base = { syncId: op.syncId, entityType: op.entityType };
|
|
const entity = getEntity(op.entityType);
|
|
if (!entity) {
|
|
results.set(key, { ...base, status: "rejected", reason: "unknown_type" });
|
|
continue;
|
|
}
|
|
if (entity.readOnly) {
|
|
results.set(key, { ...base, status: "rejected", reason: "read_only" });
|
|
continue;
|
|
}
|
|
|
|
const record = await getRecord(userId, op.entityType, op.syncId);
|
|
const currentRevision = record?.revision ?? 0;
|
|
if (op.baseRevision !== currentRevision) {
|
|
const row = record?.deleted ? null : (wire.get(key) ?? null);
|
|
// A live record with no row failed to serialize; a conflict without a
|
|
// row would read as a delete on the desktop.
|
|
if (record && !record.deleted && !row) {
|
|
results.set(key, {
|
|
...base,
|
|
status: "rejected",
|
|
reason: "unavailable",
|
|
});
|
|
continue;
|
|
}
|
|
results.set(key, {
|
|
...base,
|
|
status: "conflict",
|
|
revision: currentRevision,
|
|
deleted: !!record?.deleted,
|
|
row,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const exists = !!record && !record.deleted;
|
|
const permission =
|
|
op.op === "delete"
|
|
? entity.permissions?.delete
|
|
: exists
|
|
? entity.permissions?.update
|
|
: entity.permissions?.create;
|
|
if (permission && !(await permissions.hasPermission(userId, permission))) {
|
|
results.set(key, { ...base, status: "rejected", reason: "permission" });
|
|
continue;
|
|
}
|
|
if (
|
|
op.entityType === "hosts" &&
|
|
(await hostPushLocked(userId, op.syncId, exists))
|
|
) {
|
|
results.set(key, { ...base, status: "rejected", reason: "locked" });
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
if (op.op === "delete") {
|
|
await deleteStoredRow(entity, userId, op.syncId);
|
|
await putRecord(userId, op.entityType, op.syncId, {
|
|
revision: currentRevision + 1,
|
|
seq: await allocateSeq(),
|
|
hash: null,
|
|
deleted: true,
|
|
});
|
|
wire.delete(key);
|
|
results.set(key, {
|
|
...base,
|
|
status: "ok",
|
|
revision: currentRevision + 1,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
const row = { ...(op.row as SyncRow), syncId: op.syncId };
|
|
if (entity.shouldSync && entity.shouldSync(row) === false) {
|
|
results.set(key, { ...base, status: "rejected", reason: "not_synced" });
|
|
continue;
|
|
}
|
|
missed = false;
|
|
await writeWireRow(entity, userId, row, resolvers);
|
|
if (missed) retry.push(op);
|
|
const written = await loadWireRow(entity, userId, op.syncId, resolvers);
|
|
await putRecord(userId, op.entityType, op.syncId, {
|
|
revision: currentRevision + 1,
|
|
seq: await allocateSeq(),
|
|
hash: written?.hash ?? null,
|
|
deleted: false,
|
|
});
|
|
if (written) wire.set(key, written.wire);
|
|
results.set(key, {
|
|
...base,
|
|
status: "ok",
|
|
revision: currentRevision + 1,
|
|
});
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : String(error);
|
|
syncLogger.warn("Rejected a pushed sync change", {
|
|
operation: "sync_push",
|
|
userId,
|
|
entityType: op.entityType,
|
|
syncId: op.syncId,
|
|
error: reason,
|
|
});
|
|
results.set(key, {
|
|
...base,
|
|
status: "rejected",
|
|
reason: `invalid: ${reason}`.slice(0, 500),
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const op of retry) {
|
|
const entity = getEntity(op.entityType)!;
|
|
try {
|
|
await writeWireRow(
|
|
entity,
|
|
userId,
|
|
{ ...(op.row as SyncRow), syncId: op.syncId },
|
|
resolvers,
|
|
);
|
|
const written = await loadWireRow(entity, userId, op.syncId, resolvers);
|
|
const record = await getRecord(userId, op.entityType, op.syncId);
|
|
if (record && written) {
|
|
await putRecord(userId, op.entityType, op.syncId, {
|
|
revision: record.revision,
|
|
seq: record.seq,
|
|
hash: written.hash,
|
|
deleted: false,
|
|
});
|
|
}
|
|
} catch {
|
|
// The first write stands.
|
|
}
|
|
}
|
|
|
|
// Refresh the snapshot so the next pull serves what was just written.
|
|
await reconcileUser(userId, { force: true });
|
|
// The reconcile finds nothing new, since the push wrote the records itself,
|
|
// so tell this user's other clients, and other users who may share a row.
|
|
if ([...results.values()].some((result) => result.status === "ok")) {
|
|
notifyListeners(userId);
|
|
markChanged();
|
|
}
|
|
return ops.map(
|
|
(op) =>
|
|
results.get(recordKey(op.entityType, op.syncId)) ?? {
|
|
syncId: op.syncId,
|
|
entityType: op.entityType,
|
|
status: "rejected",
|
|
reason: "duplicate",
|
|
},
|
|
);
|
|
}
|