mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 06:35:24 +00:00
chore: lock the plugin boundary and remove 2.9.0 leftovers
This commit is contained in:
1 parent
6489b62ece
commit
2961297989
399 files changed
+3964
-5627
No files matched your search
@@ -47,11 +47,7 @@ export function getActor(): string | undefined {
|
||||
return storage.getStore()?.userId;
|
||||
}
|
||||
|
||||
export function getActorSource(): ActorStore["source"] | undefined {
|
||||
return storage.getStore()?.source;
|
||||
}
|
||||
|
||||
export class PluginActorError extends Error {
|
||||
class PluginActorError extends Error {
|
||||
readonly code = "EPLUGINACTOR";
|
||||
|
||||
constructor(pluginId: string, operation: string) {
|
||||
@@ -62,9 +58,3 @@ export class PluginActorError extends Error {
|
||||
this.name = "PluginActorError";
|
||||
}
|
||||
}
|
||||
|
||||
export function requireActor(pluginId: string, operation: string): string {
|
||||
const userId = getActor();
|
||||
if (!userId) throw new PluginActorError(pluginId, operation);
|
||||
return userId;
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Boot copies of 2.8 data into plugin settings and plugin tables.
|
||||
*
|
||||
* They run after the plugins are seeded and activated: plugin_settings has a
|
||||
* foreign key to plugins, and some copies write into tables a plugin adopts
|
||||
* in its own migrations. Each one is idempotent, so they also run again when
|
||||
* an admin enables a plugin without a restart.
|
||||
*/
|
||||
|
||||
import { databaseLogger } from "../utils/logger.js";
|
||||
import { getErrorMessage } from "../utils/error-message.js";
|
||||
import { runTailscaleSettingsMigration } from "../utils/crypto-migration/tailscale-settings-migration.js";
|
||||
import { runProxmoxSettingsMigration } from "../utils/crypto-migration/proxmox-settings-migration.js";
|
||||
import { runFileManagerSettingsMigration } from "../utils/crypto-migration/file-manager-settings-migration.js";
|
||||
import { runTunnelsSettingsMigration } from "../utils/crypto-migration/tunnels-settings-migration.js";
|
||||
import { runWebEndpointSettingsMigration } from "../utils/crypto-migration/web-endpoint-settings-migration.js";
|
||||
import { runSshTerminalSettingsMigration } from "../utils/crypto-migration/ssh-terminal-settings-migration.js";
|
||||
import { runTmuxMonitorSettingsMigration } from "../utils/crypto-migration/tmux-monitor-settings-migration.js";
|
||||
import { runSessionSharingSettingsMigration } from "../utils/crypto-migration/session-sharing-settings-migration.js";
|
||||
import { runSessionRecordingSettingsMigration } from "../utils/crypto-migration/session-recording-settings-migration.js";
|
||||
import { runRemoteDesktopSettingsMigration } from "../utils/crypto-migration/remote-desktop-settings-migration.js";
|
||||
import { runHostMetricsSettingsMigration } from "../utils/crypto-migration/host-metrics-settings-migration.js";
|
||||
import { runDockerSettingsMigration } from "../utils/crypto-migration/docker-settings-migration.js";
|
||||
import { runAiSettingsMigration } from "../utils/crypto-migration/ai-settings-migration.js";
|
||||
import { runWakeOnLanSettingsMigration } from "../utils/crypto-migration/wake-on-lan-settings-migration.js";
|
||||
import { runWarpgateSettingsMigration } from "../utils/crypto-migration/warpgate-settings-migration.js";
|
||||
import { runStepCaSettingsMigration } from "../utils/crypto-migration/step-ca-settings-migration.js";
|
||||
import { runAcmeSslSettingsMigration } from "../utils/crypto-migration/acme-ssl-settings-migration.js";
|
||||
import { runVaultSettingsMigration } from "../utils/crypto-migration/vault-settings-migration.js";
|
||||
import { runSecretSourcesTokenMigration } from "../utils/crypto-migration/secret-sources-token-migration.js";
|
||||
import { runTotpMigration } from "../utils/crypto-migration/totp-migration.js";
|
||||
import { runTermixIdentityCaMigration } from "../utils/crypto-migration/termix-identity-ca-migration.js";
|
||||
|
||||
const MIGRATIONS: Array<[string, () => Promise<unknown>]> = [
|
||||
["runTailscaleSettingsMigration", runTailscaleSettingsMigration],
|
||||
["runProxmoxSettingsMigration", runProxmoxSettingsMigration],
|
||||
["runFileManagerSettingsMigration", runFileManagerSettingsMigration],
|
||||
["runTunnelsSettingsMigration", runTunnelsSettingsMigration],
|
||||
["runWebEndpointSettingsMigration", runWebEndpointSettingsMigration],
|
||||
["runSshTerminalSettingsMigration", runSshTerminalSettingsMigration],
|
||||
["runTmuxMonitorSettingsMigration", runTmuxMonitorSettingsMigration],
|
||||
["runSessionSharingSettingsMigration", runSessionSharingSettingsMigration],
|
||||
[
|
||||
"runSessionRecordingSettingsMigration",
|
||||
runSessionRecordingSettingsMigration,
|
||||
],
|
||||
["runRemoteDesktopSettingsMigration", runRemoteDesktopSettingsMigration],
|
||||
["runHostMetricsSettingsMigration", runHostMetricsSettingsMigration],
|
||||
["runDockerSettingsMigration", runDockerSettingsMigration],
|
||||
["runAiSettingsMigration", runAiSettingsMigration],
|
||||
["runWakeOnLanSettingsMigration", runWakeOnLanSettingsMigration],
|
||||
["runWarpgateSettingsMigration", runWarpgateSettingsMigration],
|
||||
["runStepCaSettingsMigration", runStepCaSettingsMigration],
|
||||
["runAcmeSslSettingsMigration", runAcmeSslSettingsMigration],
|
||||
["runVaultSettingsMigration", runVaultSettingsMigration],
|
||||
["runSecretSourcesTokenMigration", runSecretSourcesTokenMigration],
|
||||
["runTotpMigration", runTotpMigration],
|
||||
["runTermixIdentityCaMigration", runTermixIdentityCaMigration],
|
||||
];
|
||||
|
||||
export async function runPluginDataMigrations(): Promise<void> {
|
||||
for (const [name, run] of MIGRATIONS) {
|
||||
try {
|
||||
await run();
|
||||
} catch (error) {
|
||||
databaseLogger.warn(`Plugin data migration ${name} failed`, {
|
||||
operation: "plugin_data_migration",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,3 @@ export function recordConflict(conflict: RegistrationConflict): void {
|
||||
export function listRegistrationConflicts(): RegistrationConflict[] {
|
||||
return [...conflicts];
|
||||
}
|
||||
|
||||
/** Test seam. */
|
||||
export function clearRegistrationConflicts(): void {
|
||||
conflicts.length = 0;
|
||||
}
|
||||
@@ -39,6 +39,7 @@ import type {
|
||||
MutableConnectConfig,
|
||||
SshAuthProvider,
|
||||
SshConnectHost,
|
||||
SshConnectProfile,
|
||||
SshConnectPurpose,
|
||||
} from "../hosts/connect/types.js";
|
||||
|
||||
@@ -54,24 +55,22 @@ interface Deps {
|
||||
audit: AuditFn;
|
||||
}
|
||||
|
||||
const PLUGIN_PURPOSES = new Set<SshConnectPurpose>([
|
||||
"plugin",
|
||||
"docker",
|
||||
"docker-console",
|
||||
"metrics",
|
||||
"proxmox",
|
||||
"fleet",
|
||||
"remote-desktop",
|
||||
"tunnel",
|
||||
"file-manager",
|
||||
"file-transfer",
|
||||
const PLUGIN_PROFILES = new Set<SshConnectProfile>([
|
||||
"terminal",
|
||||
"tmux",
|
||||
"session",
|
||||
"stream",
|
||||
"forward",
|
||||
"background",
|
||||
]);
|
||||
|
||||
function purposeOf(options?: PluginSshConnectOptions): SshConnectPurpose {
|
||||
const purpose = (options?.purpose ?? "plugin") as SshConnectPurpose;
|
||||
return PLUGIN_PURPOSES.has(purpose) ? purpose : "plugin";
|
||||
function purposeOf(options?: { purpose?: string }): SshConnectPurpose {
|
||||
return options?.purpose || "plugin";
|
||||
}
|
||||
|
||||
// "jump" belongs to core's own jump chain; a plugin gets background.
|
||||
function profileOf(options?: { profile?: string }): SshConnectProfile {
|
||||
const profile = options?.profile as SshConnectProfile | undefined;
|
||||
return profile && PLUGIN_PROFILES.has(profile) ? profile : "background";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -141,6 +140,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
const connection = await connectHost(host as number | SshConnectHost, {
|
||||
userId,
|
||||
purpose: purposeOf(options),
|
||||
profile: profileOf(options),
|
||||
timeoutMs: options?.timeoutMs,
|
||||
prompt: options?.prompt,
|
||||
overrides: options?.overrides as Partial<MutableConnectConfig>,
|
||||
@@ -273,6 +273,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
const built = await buildConnectConfig(host as SshConnectHost, {
|
||||
userId: actingUser(host),
|
||||
purpose: purposeOf(options),
|
||||
profile: profileOf(options),
|
||||
client: options.client as never,
|
||||
serverHostId: options.serverHostId,
|
||||
log: options.log,
|
||||
|
||||
+14
-15
@@ -17,7 +17,6 @@ import { buildTable, dropTableSql } from "./table-builder.js";
|
||||
import {
|
||||
applyPluginMigrations,
|
||||
readMigrations,
|
||||
tableExistsSql,
|
||||
type MigrationRunner,
|
||||
} from "./migrations.js";
|
||||
|
||||
@@ -49,16 +48,26 @@ export function registerTable(
|
||||
return table;
|
||||
}
|
||||
|
||||
export function getTable(pluginId: string, name: string): unknown {
|
||||
function getTable(pluginId: string, name: string): unknown {
|
||||
return built.get(pluginId)?.get(name);
|
||||
}
|
||||
|
||||
export function listTables(pluginId: string): PluginTableDefinition[] {
|
||||
function listTables(pluginId: string): PluginTableDefinition[] {
|
||||
return [...(registered.get(pluginId) ?? [])];
|
||||
}
|
||||
|
||||
/** Every registered definition, with the plugin that owns it. */
|
||||
export function listAllTables(): Array<{
|
||||
pluginId: string;
|
||||
definition: PluginTableDefinition;
|
||||
}> {
|
||||
return [...registered.entries()].flatMap(([pluginId, definitions]) =>
|
||||
definitions.map((definition) => ({ pluginId, definition })),
|
||||
);
|
||||
}
|
||||
|
||||
/** Forgets a plugin's definitions. Used by the tests and by uninstall. */
|
||||
export function forgetTables(pluginId: string): void {
|
||||
function forgetTables(pluginId: string): void {
|
||||
registered.delete(pluginId);
|
||||
built.delete(pluginId);
|
||||
}
|
||||
@@ -84,7 +93,7 @@ async function execute(statements: string[]): Promise<void> {
|
||||
* the engine does not keep. A half-applied migration is recorded statement by
|
||||
* statement either way, so a retry resumes rather than repeating.
|
||||
*/
|
||||
export async function createMigrationRunner(
|
||||
async function createMigrationRunner(
|
||||
dialect: DatabaseDialect = resolveDatabaseDialect(),
|
||||
): Promise<MigrationRunner> {
|
||||
const { createCurrentPluginMigrationRepository } =
|
||||
@@ -125,16 +134,6 @@ export async function migratePlugin(
|
||||
return applyPluginMigrations(pluginId, pluginDir, runner);
|
||||
}
|
||||
|
||||
/** Whether a table exists, asked in the active dialect's own catalog. */
|
||||
export async function tableExists(
|
||||
table: string,
|
||||
dialect: DatabaseDialect = resolveDatabaseDialect(),
|
||||
): Promise<boolean> {
|
||||
const { getDb } = await import("../database/db/index.js");
|
||||
const rows = (await getDb().all(tableExistsSql(dialect, table))) as unknown[];
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes everything a plugin owns.
|
||||
*
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
/**
|
||||
* The only shape of a host a plugin ever sees.
|
||||
*
|
||||
* This is an ALLOWLIST, deliberately, not a list of fields to strip. A
|
||||
* blocklist rots: the moment someone adds a new secret-bearing column to
|
||||
* SSHHost, a blocklist silently starts leaking it, and nothing fails. With an
|
||||
* allowlist a new field is invisible to plugins until someone consciously adds
|
||||
* it here.
|
||||
*
|
||||
* resolveHostById returns an object carrying password, key, keyPassword,
|
||||
* certPublicKey, sudoPassword and the autostart* secrets in plaintext. None of
|
||||
* them are in this list, and none of them may ever be.
|
||||
*/
|
||||
|
||||
export interface PluginHostView {
|
||||
id: number;
|
||||
name: string | null;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string | null;
|
||||
tags: string[];
|
||||
authType: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
const NUMBER_FIELDS = ["id", "port"] as const;
|
||||
const STRING_FIELDS = [
|
||||
"name",
|
||||
"ip",
|
||||
"username",
|
||||
"folder",
|
||||
"authType",
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
] as const;
|
||||
|
||||
export function toPluginHostView(
|
||||
host: Record<string, unknown>,
|
||||
): PluginHostView {
|
||||
const view: Record<string, unknown> = {};
|
||||
|
||||
for (const field of NUMBER_FIELDS) {
|
||||
view[field] = Number(host[field] ?? 0);
|
||||
}
|
||||
for (const field of STRING_FIELDS) {
|
||||
const value = host[field];
|
||||
view[field] = typeof value === "string" ? value : null;
|
||||
}
|
||||
view.tags = Array.isArray(host.tags)
|
||||
? host.tags.filter((tag): tag is string => typeof tag === "string")
|
||||
: [];
|
||||
|
||||
return view as unknown as PluginHostView;
|
||||
}
|
||||
@@ -42,10 +42,6 @@ export function getPluginRouter(pluginId: string): Router | undefined {
|
||||
return routers.get(pluginId);
|
||||
}
|
||||
|
||||
export function getRegisteredHttpPluginIds(): string[] {
|
||||
return [...routers.keys()];
|
||||
}
|
||||
|
||||
export function unregisterPluginHttp(pluginId: string): void {
|
||||
if (!routers.delete(pluginId)) return;
|
||||
pluginLogger.info(`Unmounted /plugin-api/${pluginId}`, {
|
||||
|
||||
@@ -431,7 +431,8 @@ export async function activatePlugin(pluginId: string): Promise<void> {
|
||||
await pluginLoader.activate(pluginId);
|
||||
|
||||
// A plugin enabled without a restart still needs its 2.8 data copied.
|
||||
const { runPluginDataMigrations } = await import("./boot-migrations.js");
|
||||
const { runPluginDataMigrations } =
|
||||
await import("../upgrade/boot-migrations.js");
|
||||
await runPluginDataMigrations();
|
||||
}
|
||||
|
||||
|
||||
@@ -48,8 +48,8 @@ export type PluginSource = "bundled" | "user";
|
||||
const ACTIVATION_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** Defaults for the runtime error budget. Both are configurable. */
|
||||
export const DEFAULT_ERROR_THRESHOLD = 5;
|
||||
export const DEFAULT_ERROR_WINDOW_MS = 60_000;
|
||||
const DEFAULT_ERROR_THRESHOLD = 5;
|
||||
const DEFAULT_ERROR_WINDOW_MS = 60_000;
|
||||
|
||||
export interface LoadedPlugin {
|
||||
id: string;
|
||||
@@ -141,7 +141,7 @@ export class PluginLoader {
|
||||
/**
|
||||
* Scans both roots. A user-installed plugin can never shadow a bundled one:
|
||||
* the collision is rejected with an error rather than silently skipped, so
|
||||
* dropping an "ssh-terminal" folder into the data directory is visibly
|
||||
* dropping a folder with a bundled plugin's id into the data directory is visibly
|
||||
* refused instead of quietly ignored.
|
||||
*
|
||||
* The check is on the parsed manifest id, not the directory name, because a
|
||||
@@ -347,7 +347,7 @@ export class PluginLoader {
|
||||
// A new table may be where core data is waiting to move.
|
||||
if (applied.length > 0) {
|
||||
const { runPluginDataMoves } =
|
||||
await import("../utils/crypto-migration/plugin-data-moves.js");
|
||||
await import("../upgrade/plugin-data-moves.js");
|
||||
await runPluginDataMoves();
|
||||
}
|
||||
|
||||
|
||||
@@ -11,12 +11,6 @@ export {
|
||||
validateManifest,
|
||||
parseManifest,
|
||||
SUPPORTED_PLUGIN_API_VERSION,
|
||||
PLUGIN_CATEGORIES,
|
||||
PLUGIN_PLATFORMS,
|
||||
ACTION_CONTRIBUTION_KINDS,
|
||||
DEFAULT_BACKEND_ENTRY,
|
||||
DEFAULT_FRONTEND_ENTRY,
|
||||
DEFAULT_LOCALES_DIR,
|
||||
SYSTEM_ROLE_NAMES,
|
||||
RESERVED_PERMISSION_PREFIXES,
|
||||
qualifyPermission,
|
||||
@@ -41,13 +35,6 @@ export type {
|
||||
ParsedManifest,
|
||||
} from "@termix/plugin-sdk/manifest";
|
||||
|
||||
export {
|
||||
CAPABILITY_CATALOG,
|
||||
CAPABILITY_IDS,
|
||||
isKnownCapability,
|
||||
getCapabilityInfo,
|
||||
} from "@termix/plugin-sdk/capabilities";
|
||||
|
||||
export type {
|
||||
Capability,
|
||||
CapabilityInfo,
|
||||
|
||||
@@ -57,10 +57,7 @@ export function checksum(contents: string): string {
|
||||
return crypto.createHash("sha256").update(normalised).digest("hex");
|
||||
}
|
||||
|
||||
export function migrationsDir(
|
||||
pluginDir: string,
|
||||
dialect: DatabaseDialect,
|
||||
): string {
|
||||
function migrationsDir(pluginDir: string, dialect: DatabaseDialect): string {
|
||||
return path.join(pluginDir, "migrations", dialect);
|
||||
}
|
||||
|
||||
@@ -211,11 +208,6 @@ export async function applyPluginMigrations(
|
||||
return pending.map((migration) => migration.id);
|
||||
}
|
||||
|
||||
/** The dialect directories a plugin is expected to ship. */
|
||||
export function expectedDialects(): readonly DatabaseDialect[] {
|
||||
return DIALECTS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames a legacy core table into a plugin's namespace, or creates it fresh.
|
||||
*
|
||||
|
||||
@@ -47,10 +47,6 @@ export function getBundledPluginsDir(): string {
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
export function getPluginDir(pluginId: string): string {
|
||||
return path.join(getPluginsDir(), pluginId);
|
||||
}
|
||||
|
||||
/** The backend entry, from the manifest's own `backend` field. */
|
||||
export function getPluginBackendEntry(
|
||||
pluginDir: string,
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
import { createCurrentPluginPermissionGrantRepository } from "../database/repositories/factory.js";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export { PluginCapabilityError };
|
||||
|
||||
const cache = new Map<string, Set<string>>();
|
||||
|
||||
export function invalidatePluginPermissionCache(pluginId?: string): void {
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
* ## Authorization is the provider's own permission
|
||||
*
|
||||
* No fourth grant mechanism. The provider declares which of its own role
|
||||
* permissions gates sharing ("ai.secrets.share", alongside "ai.services.use"),
|
||||
* permissions gates sharing (for example "<plugin>.secrets.share"),
|
||||
* and getShared runs the acting user through the same PermissionManager check
|
||||
* that service-registry.ts and requirePermission() use. So:
|
||||
*
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
* order rather than relying on registration sequence.
|
||||
*/
|
||||
|
||||
import { pluginLogger } from "../utils/logger.js";
|
||||
import type { SyncEntityRegistration } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export interface RegisteredSyncEntity extends SyncEntityRegistration {
|
||||
@@ -91,10 +90,3 @@ export function unregisterByOwner(owner: string): void {
|
||||
export function resetSyncRegistry(): void {
|
||||
entities.clear();
|
||||
}
|
||||
|
||||
/** Logged once at boot so a missing entity is visible without a debugger. */
|
||||
export function logRegisteredEntities(): void {
|
||||
pluginLogger.info(`Sync entities: ${listEntityTypes().join(", ")}`, {
|
||||
operation: "sync_registry",
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,4 @@ export {
|
||||
dropTableSql,
|
||||
addColumnSql,
|
||||
keyedColumns,
|
||||
columnName,
|
||||
REFERENCEABLE,
|
||||
} from "@termix/plugin-sdk/ddl";
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* A user's rows in plugin tables, for the per-user exports and imports.
|
||||
*
|
||||
* Core never names a plugin table. Any table a running plugin declared with a
|
||||
* refUser column belongs to a user, so it travels with that user's export.
|
||||
* Encrypted columns stay behind: they are sealed with this server's keys.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { columnName, createTableSql } from "@termix/plugin-sdk/ddl";
|
||||
import { prefixedTableName } from "@termix/plugin-sdk/db";
|
||||
import type { PluginTableDefinition } from "@termix/plugin-sdk/db";
|
||||
import { getDb } from "../database/db/index.js";
|
||||
import { listAllTables } from "./data.js";
|
||||
|
||||
export interface UserOwnedTable {
|
||||
pluginId: string;
|
||||
/** The physical table name, p_<id>_<name>. */
|
||||
table: string;
|
||||
/** The legacy core name the table adopted, for older export files. */
|
||||
legacyName?: string;
|
||||
userColumn: string;
|
||||
/** Columns that travel, as SQL names. The primary key and secrets do not. */
|
||||
columns: string[];
|
||||
definition: PluginTableDefinition;
|
||||
}
|
||||
|
||||
export function listUserOwnedTables(): UserOwnedTable[] {
|
||||
const owned: UserOwnedTable[] = [];
|
||||
for (const { pluginId, definition } of listAllTables()) {
|
||||
const entries = Object.entries(definition.columns);
|
||||
const user = entries.find(([, column]) => column.type === "refUser");
|
||||
if (!user) continue;
|
||||
owned.push({
|
||||
pluginId,
|
||||
table: prefixedTableName(pluginId, definition.name),
|
||||
legacyName: definition.adopts,
|
||||
userColumn: columnName(user[0], user[1]),
|
||||
columns: entries
|
||||
.filter(
|
||||
([, column]) =>
|
||||
column.type !== "id" &&
|
||||
!column.primaryKey &&
|
||||
column.type !== "encryptedText",
|
||||
)
|
||||
.map(([property, column]) => columnName(property, column)),
|
||||
definition,
|
||||
});
|
||||
}
|
||||
return owned;
|
||||
}
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
/** The user's rows in every user-owned plugin table, keyed by table. */
|
||||
export async function readUserPluginRows(
|
||||
userId: string,
|
||||
): Promise<Record<string, Row[]>> {
|
||||
const db = getDb();
|
||||
const result: Record<string, Row[]> = {};
|
||||
for (const owned of listUserOwnedTables()) {
|
||||
try {
|
||||
const rows = await db.all<Row>(
|
||||
sql`SELECT ${sql.join(
|
||||
owned.columns.map((column) => sql.identifier(column)),
|
||||
sql`, `,
|
||||
)} FROM ${sql.identifier(owned.table)} WHERE ${sql.identifier(
|
||||
owned.userColumn,
|
||||
)} = ${userId}`,
|
||||
);
|
||||
if (rows.length > 0) result[owned.table] = rows;
|
||||
} catch {
|
||||
// The plugin's migration has not created the table yet.
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
interface SqliteLike {
|
||||
exec(source: string): unknown;
|
||||
prepare(source: string): {
|
||||
run(...params: unknown[]): unknown;
|
||||
all(...params: unknown[]): unknown[];
|
||||
};
|
||||
}
|
||||
|
||||
/** Writes the user's plugin rows into a SQLite export file. */
|
||||
export async function writeUserPluginTables(
|
||||
exportDb: SqliteLike,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
const rowsByTable = await readUserPluginRows(userId);
|
||||
let written = 0;
|
||||
for (const owned of listUserOwnedTables()) {
|
||||
const rows = rowsByTable[owned.table];
|
||||
if (!rows?.length) continue;
|
||||
for (const statement of createTableSql(
|
||||
"sqlite",
|
||||
owned.pluginId,
|
||||
owned.definition,
|
||||
)) {
|
||||
exportDb.exec(statement);
|
||||
}
|
||||
const insert = exportDb.prepare(
|
||||
`INSERT INTO "${owned.table}" (${owned.columns
|
||||
.map((column) => `"${column}"`)
|
||||
.join(", ")}) VALUES (${owned.columns.map(() => "?").join(", ")})`,
|
||||
);
|
||||
for (const row of rows) {
|
||||
insert.run(...owned.columns.map((column) => toSqlite(row[column])));
|
||||
written++;
|
||||
}
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
function toSqlite(value: unknown): unknown {
|
||||
if (value === undefined) return null;
|
||||
if (typeof value === "boolean") return value ? 1 : 0;
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (value !== null && typeof value === "object") return JSON.stringify(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
export interface PluginRowsImport {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies plugin rows from an import file into this server as `userId`. A row
|
||||
* matching one the user already has is skipped. Files from before 2.9 carry
|
||||
* the legacy table names, which are read too.
|
||||
*/
|
||||
export async function importUserPluginRows(
|
||||
importDb: SqliteLike,
|
||||
userId: string,
|
||||
): Promise<PluginRowsImport> {
|
||||
const db = getDb();
|
||||
const summary: PluginRowsImport = { imported: 0, skipped: 0, errors: [] };
|
||||
|
||||
for (const owned of listUserOwnedTables()) {
|
||||
let rows: Row[] | null = null;
|
||||
for (const name of [owned.table, owned.legacyName].filter(Boolean)) {
|
||||
try {
|
||||
rows = importDb.prepare(`SELECT * FROM "${name}"`).all() as Row[];
|
||||
break;
|
||||
} catch {
|
||||
// not in this file under that name
|
||||
}
|
||||
}
|
||||
if (!rows) continue;
|
||||
|
||||
for (const source of rows) {
|
||||
const columns = owned.columns.filter(
|
||||
(column) => column === owned.userColumn || source[column] !== undefined,
|
||||
);
|
||||
const values = columns.map((column) =>
|
||||
column === owned.userColumn ? userId : source[column],
|
||||
);
|
||||
try {
|
||||
const existing = await db.all(
|
||||
sql`SELECT 1 FROM ${sql.identifier(owned.table)} WHERE ${sql.join(
|
||||
columns.map((column, index) =>
|
||||
values[index] === null
|
||||
? sql`${sql.identifier(column)} IS NULL`
|
||||
: sql`${sql.identifier(column)} = ${values[index]}`,
|
||||
),
|
||||
sql` AND `,
|
||||
)} LIMIT 1`,
|
||||
);
|
||||
if (existing.length > 0) {
|
||||
summary.skipped++;
|
||||
continue;
|
||||
}
|
||||
await db.run(
|
||||
sql`INSERT INTO ${sql.identifier(owned.table)} (${sql.join(
|
||||
columns.map((column) => sql.identifier(column)),
|
||||
sql`, `,
|
||||
)}) VALUES (${sql.join(
|
||||
values.map((value) => sql`${value}`),
|
||||
sql`, `,
|
||||
)})`,
|
||||
);
|
||||
summary.imported++;
|
||||
} catch (error) {
|
||||
summary.errors.push(
|
||||
`${owned.table} import error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ import { recordConflict } from "./conflicts.js";
|
||||
import { extractWebSocketToken } from "../utils/ws-auth.js";
|
||||
import { runAsActor } from "./actor.js";
|
||||
|
||||
export const PLUGIN_WS_PREFIX = "/plugin-ws/";
|
||||
const PLUGIN_WS_PREFIX = "/plugin-ws/";
|
||||
|
||||
type RawUpgradeHandler = (
|
||||
request: IncomingMessage,
|
||||
@@ -127,7 +127,7 @@ function reject(socket: Duplex, status: number, message: string): void {
|
||||
* Exported for the test suite and for database.ts, which wires it into both
|
||||
* the HTTP and the HTTPS server.
|
||||
*/
|
||||
export async function handlePluginUpgrade(
|
||||
async function handlePluginUpgrade(
|
||||
request: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
|
||||
Reference in new issue
Block a user