mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: redesign user host defaults system
This commit is contained in:
1 parent
3028ab1176
commit
a4a4d4f27b
144 files changed
+24609
-2555
No files matched your search
@@ -3,6 +3,16 @@ import {
|
||||
remapImportedJumpHosts,
|
||||
} from "./host-import-order.js";
|
||||
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
|
||||
import {
|
||||
applyDefaultsAfterHostWrite,
|
||||
applyHostDefaultsToWrite,
|
||||
} from "../../hosts/defaults/index.js";
|
||||
import {
|
||||
changeHostOverrides,
|
||||
type OverrideChange,
|
||||
} from "../../hosts/defaults/overrides.js";
|
||||
import { recompute } from "../../hosts/defaults/recompute.js";
|
||||
import { splitDefaultKey } from "../../../types/host-defaults.js";
|
||||
import {
|
||||
keepUsableProtocolCredentials,
|
||||
readProtocolAuthPayload,
|
||||
@@ -16,6 +26,7 @@ import {
|
||||
createCurrentCredentialRepository,
|
||||
createCurrentHostRepository,
|
||||
createCurrentHostResolutionRepository,
|
||||
createCurrentHostDefaultsRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { validateParentHostId } from "./host-parent-validation.js";
|
||||
import {
|
||||
@@ -203,6 +214,20 @@ export function registerHostBulkRoutes(
|
||||
* description: Plugin id to on or off, for each plugin that declares a host enable switch.
|
||||
* additionalProperties:
|
||||
* type: boolean
|
||||
* resetDefaults:
|
||||
* type: object
|
||||
* description: Hands keys back to the host defaults. `all`, or `namespaces` ("core" or a plugin id), or `keys` as "namespace.key".
|
||||
* properties:
|
||||
* all:
|
||||
* type: boolean
|
||||
* namespaces:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* keys:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Bulk update completed.
|
||||
@@ -309,6 +334,14 @@ export function registerHostBulkRoutes(
|
||||
simpleUpdates,
|
||||
);
|
||||
}
|
||||
if (typeof updates.statusCheckEnabled === "boolean") {
|
||||
await changeHostOverrides(ownedIds, {
|
||||
own: [["core", "statusCheckEnabled"]],
|
||||
});
|
||||
}
|
||||
if ("folder" in simpleUpdates || "parentHostId" in simpleUpdates) {
|
||||
void recompute({ userIds: [userId] }).catch(() => {});
|
||||
}
|
||||
|
||||
// Each plugin's own host switch, by plugin id: the field its manifest
|
||||
// names in contributes.settings.host.enableKey.
|
||||
@@ -323,6 +356,9 @@ export function registerHostBulkRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
const reset = readDefaultsReset(updates.resetDefaults);
|
||||
if (reset) await changeHostOverrides(ownedIds, reset);
|
||||
|
||||
return res.json({
|
||||
updated: ownedIds.length,
|
||||
failed: unauthorizedIds.length,
|
||||
@@ -727,6 +763,12 @@ export function registerHostBulkRoutes(
|
||||
|
||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||
const existing = existingHostMap?.get(lookupKey);
|
||||
await withHostDefaults(
|
||||
userId,
|
||||
existing?.id ?? null,
|
||||
sshDataObj,
|
||||
hostData as Record<string, unknown>,
|
||||
);
|
||||
|
||||
let savedHostId: number;
|
||||
if (existing) {
|
||||
@@ -770,6 +812,7 @@ export function registerHostBulkRoutes(
|
||||
savedHostId,
|
||||
hostData as Record<string, unknown>,
|
||||
);
|
||||
await applyDefaultsAfterHostWrite(savedHostId);
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
|
||||
@@ -947,6 +990,12 @@ export function registerHostBulkRoutes(
|
||||
|
||||
const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`;
|
||||
const existing = existingHostMap?.get(lookupKey);
|
||||
await withHostDefaults(
|
||||
userId,
|
||||
existing?.id ?? null,
|
||||
sshDataObj,
|
||||
hostData as unknown as Record<string, unknown>,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
await hostRepository.updateEncryptedForUser(
|
||||
@@ -954,10 +1003,15 @@ export function registerHostBulkRoutes(
|
||||
existing.id,
|
||||
sshDataObj,
|
||||
);
|
||||
await applyDefaultsAfterHostWrite(existing.id);
|
||||
results.updated++;
|
||||
} else {
|
||||
sshDataObj.createdAt = new Date().toISOString();
|
||||
await hostRepository.createEncryptedForUser(userId, sshDataObj);
|
||||
const saved = await hostRepository.createEncryptedForUser(
|
||||
userId,
|
||||
sshDataObj,
|
||||
);
|
||||
await applyDefaultsAfterHostWrite(saved.id);
|
||||
results.success++;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -1022,3 +1076,49 @@ function listKnownAuthTypes(): Set<string> {
|
||||
...listSshAuthTypeOwners().map((owner) => owner.type),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Fills an imported host's inherited keys and records what it sets itself. */
|
||||
async function withHostDefaults(
|
||||
userId: string,
|
||||
hostId: number | null,
|
||||
columns: Record<string, unknown>,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const stored =
|
||||
hostId === null
|
||||
? null
|
||||
: ((
|
||||
await createCurrentHostDefaultsRepository().listHosts({
|
||||
hostIds: [hostId],
|
||||
})
|
||||
)[0] ?? null);
|
||||
columns.defaultOverrides = JSON.stringify(
|
||||
await applyHostDefaultsToWrite({
|
||||
ownerId: userId,
|
||||
hostId,
|
||||
columns,
|
||||
body,
|
||||
stored,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** A resetDefaults body as an override change, or null when there is none. */
|
||||
export function readDefaultsReset(raw: unknown): OverrideChange | null {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
||||
const source = raw as Record<string, unknown>;
|
||||
const strings = (value: unknown) =>
|
||||
Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
const change: OverrideChange = {
|
||||
inheritAll: source.all === true,
|
||||
inheritNamespaces: strings(source.namespaces),
|
||||
inherit: strings(source.keys).map(splitDefaultKey),
|
||||
};
|
||||
return change.inheritAll ||
|
||||
change.inheritNamespaces!.length > 0 ||
|
||||
change.inherit!.length > 0
|
||||
? change
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
import type { Request, RequestHandler, Response, Router } from "express";
|
||||
import type { AuthenticatedRequest } from "../../../types/index.js";
|
||||
import { databaseLogger } from "../../utils/logger.js";
|
||||
import {
|
||||
getRequestMeta,
|
||||
logAudit,
|
||||
getAuditUsername,
|
||||
} from "../../utils/audit-logger.js";
|
||||
import { PermissionManager } from "../../utils/permission-manager.js";
|
||||
import { createCurrentHostDefaultsRepository } from "../repositories/factory.js";
|
||||
import type { HostDefaultsScope } from "../repositories/host-defaults-repository.js";
|
||||
import { findUsableCredential } from "../../hosts/usable-credential.js";
|
||||
import {
|
||||
currentCatalog,
|
||||
materializeHosts,
|
||||
} from "../../hosts/defaults/materialize.js";
|
||||
import {
|
||||
getRecomputeJob,
|
||||
recompute,
|
||||
startRecomputeJob,
|
||||
targetForScope,
|
||||
} from "../../hosts/defaults/recompute.js";
|
||||
import {
|
||||
levelsAbove,
|
||||
readLevel,
|
||||
resolveAll,
|
||||
resolveForEditor,
|
||||
saveLevel,
|
||||
validateLevelChange,
|
||||
type CheckContext,
|
||||
type LevelChangeInput,
|
||||
} from "../../hosts/defaults/service.js";
|
||||
import { changeHostOverrides } from "../../hosts/defaults/overrides.js";
|
||||
import { readDefaultsReset } from "./host-bulk-routes.js";
|
||||
|
||||
type HostDefaultsRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
requireEditPermission: RequestHandler;
|
||||
requireDataAccess: RequestHandler;
|
||||
requireAdminSettings: RequestHandler;
|
||||
};
|
||||
|
||||
function checkContext(actorId: string, ownerId: string | null): CheckContext {
|
||||
const permissions = PermissionManager.getInstance();
|
||||
return {
|
||||
actorId,
|
||||
ownerId,
|
||||
canUseCredential: async (credentialId, userId) =>
|
||||
(await findUsableCredential(credentialId, userId)) !== null,
|
||||
canUseHost: async (hostId, userId) =>
|
||||
(await permissions.canAccessHost(userId, hostId, "connect")).hasAccess,
|
||||
};
|
||||
}
|
||||
|
||||
function readChange(body: unknown): LevelChangeInput {
|
||||
const source =
|
||||
body && typeof body === "object" ? (body as Record<string, unknown>) : {};
|
||||
return {
|
||||
set:
|
||||
source.set && typeof source.set === "object" && !Array.isArray(source.set)
|
||||
? (source.set as Record<string, unknown>)
|
||||
: {},
|
||||
unset: Array.isArray(source.unset)
|
||||
? source.unset.filter((key): key is string => typeof key === "string")
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function describeLevel(
|
||||
scope: HostDefaultsScope,
|
||||
folderOwner?: { userId: string; name: string },
|
||||
) {
|
||||
const { catalog } = currentCatalog();
|
||||
return {
|
||||
values: await readLevel(scope),
|
||||
inherited: resolveAll(catalog, await levelsAbove(scope, folderOwner)),
|
||||
};
|
||||
}
|
||||
|
||||
/** The folder a user may edit defaults for, by id. */
|
||||
async function ownedFolder(userId: string, folderId: number) {
|
||||
const folder =
|
||||
await createCurrentHostDefaultsRepository().findFolder(folderId);
|
||||
return folder && folder.userId === userId ? folder : null;
|
||||
}
|
||||
|
||||
export function registerHostDefaultsRoutes(
|
||||
router: Router,
|
||||
{
|
||||
authenticateJWT,
|
||||
requireEditPermission,
|
||||
requireDataAccess,
|
||||
requireAdminSettings,
|
||||
}: HostDefaultsRoutesDeps,
|
||||
): void {
|
||||
const save = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
scope: HostDefaultsScope,
|
||||
ownerId: string | null,
|
||||
folderOwnerId?: string,
|
||||
) => {
|
||||
const actorId = (req as AuthenticatedRequest).userId;
|
||||
const change = await validateLevelChange(
|
||||
scope,
|
||||
readChange(req.body),
|
||||
checkContext(actorId, ownerId),
|
||||
);
|
||||
if (Object.keys(change.errors).length > 0) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Some defaults were rejected", errors: change.errors });
|
||||
}
|
||||
await saveLevel(scope, change, actorId);
|
||||
const keys = new Set([...change.set.keys(), ...change.unset]);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId: actorId,
|
||||
username: await getAuditUsername(actorId),
|
||||
action: "update_host_defaults",
|
||||
resourceType: "host_defaults",
|
||||
resourceId:
|
||||
scope.level === "admin"
|
||||
? "admin"
|
||||
: scope.level === "user"
|
||||
? String(scope.userId)
|
||||
: String(scope.folderId),
|
||||
resourceName: scope.level,
|
||||
details: JSON.stringify({ keys: [...keys] }),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
const target = targetForScope(scope, folderOwnerId);
|
||||
if (scope.level === "admin") {
|
||||
const job = startRecomputeJob(target, keys);
|
||||
return res.json({ jobId: job.id });
|
||||
}
|
||||
const result = await recompute(target, keys);
|
||||
return res.json({ changedHosts: result.changedHostIds.length });
|
||||
};
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/admin:
|
||||
* get:
|
||||
* summary: Get the server's host defaults
|
||||
* description: The host defaults every user's hosts follow unless the user, a folder or the host sets its own. Returns the values set at this level and what each key reads without them.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Values and inherited values, keyed by "namespace.key".
|
||||
* 403:
|
||||
* description: Missing admin.settings.manage.
|
||||
*/
|
||||
router.get(
|
||||
"/defaults/admin",
|
||||
authenticateJWT,
|
||||
requireAdminSettings,
|
||||
async (_req: Request, res: Response) => {
|
||||
try {
|
||||
res.json(await describeLevel({ level: "admin" }));
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to read admin host defaults", error);
|
||||
res.status(500).json({ error: "Failed to read host defaults" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/admin:
|
||||
* put:
|
||||
* summary: Update the server's host defaults
|
||||
* description: Sets and clears server-wide host defaults. Every inheriting host is updated in the background; poll the returned job.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* set:
|
||||
* type: object
|
||||
* description: Values by "namespace.key".
|
||||
* unset:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Saved. Returns the recompute job id.
|
||||
* 400:
|
||||
* description: One or more values were rejected.
|
||||
* 403:
|
||||
* description: Missing admin.settings.manage.
|
||||
*/
|
||||
router.put(
|
||||
"/defaults/admin",
|
||||
authenticateJWT,
|
||||
requireAdminSettings,
|
||||
async (req: Request, res: Response) => {
|
||||
try {
|
||||
await save(req, res, { level: "admin" }, null);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to update admin host defaults", error);
|
||||
res.status(500).json({ error: "Failed to update host defaults" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/user:
|
||||
* get:
|
||||
* summary: Get your host defaults
|
||||
* description: The defaults your hosts follow, over the server's. Returns the values you set and what each key reads without them.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Values and inherited values, keyed by "namespace.key".
|
||||
*/
|
||||
router.get(
|
||||
"/defaults/user",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
res.json(await describeLevel({ level: "user", userId }));
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to read user host defaults", error);
|
||||
res.status(500).json({ error: "Failed to read host defaults" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/user:
|
||||
* put:
|
||||
* summary: Update your host defaults
|
||||
* description: Sets and clears your own host defaults and updates every host of yours that follows them.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* set:
|
||||
* type: object
|
||||
* unset:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Saved. Returns how many hosts changed.
|
||||
* 400:
|
||||
* description: One or more values were rejected.
|
||||
*/
|
||||
router.put(
|
||||
"/defaults/user",
|
||||
authenticateJWT,
|
||||
requireEditPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
await save(req, res, { level: "user", userId }, userId);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to update user host defaults", error);
|
||||
res.status(500).json({ error: "Failed to update host defaults" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/folders/{folderId}:
|
||||
* get:
|
||||
* summary: Get a folder's host defaults
|
||||
* description: The defaults hosts in this folder and its subfolders follow, over yours and the server's.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: folderId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Values and inherited values, keyed by "namespace.key".
|
||||
* 404:
|
||||
* description: No such folder of yours.
|
||||
*/
|
||||
router.get(
|
||||
"/defaults/folders/:folderId",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const folder = await ownedFolder(userId, Number(req.params.folderId));
|
||||
if (!folder) return res.status(404).json({ error: "Folder not found" });
|
||||
try {
|
||||
res.json(
|
||||
await describeLevel(
|
||||
{ level: "folder", folderId: folder.id, userId },
|
||||
folder,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to read folder host defaults", error);
|
||||
res.status(500).json({ error: "Failed to read host defaults" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/folders/{folderId}:
|
||||
* put:
|
||||
* summary: Update a folder's host defaults
|
||||
* description: Sets and clears a folder's host defaults and updates every host in it (and its subfolders) that follows them.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: folderId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Saved. Returns how many hosts changed.
|
||||
* 400:
|
||||
* description: One or more values were rejected.
|
||||
* 404:
|
||||
* description: No such folder of yours.
|
||||
*/
|
||||
router.put(
|
||||
"/defaults/folders/:folderId",
|
||||
authenticateJWT,
|
||||
requireEditPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const folder = await ownedFolder(userId, Number(req.params.folderId));
|
||||
if (!folder) return res.status(404).json({ error: "Folder not found" });
|
||||
try {
|
||||
await save(
|
||||
req,
|
||||
res,
|
||||
{ level: "folder", folderId: folder.id, userId },
|
||||
userId,
|
||||
userId,
|
||||
);
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to update folder host defaults", error);
|
||||
res.status(500).json({ error: "Failed to update host defaults" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/folders:
|
||||
* post:
|
||||
* summary: Get or create a folder's defaults id
|
||||
* description: A folder can exist only as a path on its hosts. This returns its id, creating the folder row when needed, so its defaults can be edited.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The folder's id.
|
||||
* 400:
|
||||
* description: Missing folder name.
|
||||
*/
|
||||
router.post(
|
||||
"/defaults/folders",
|
||||
authenticateJWT,
|
||||
requireEditPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const name = typeof req.body?.name === "string" ? req.body.name : "";
|
||||
if (!name.trim()) {
|
||||
return res.status(400).json({ error: "Folder name is required" });
|
||||
}
|
||||
try {
|
||||
const id = await createCurrentHostDefaultsRepository().ensureFolder(
|
||||
userId,
|
||||
name,
|
||||
);
|
||||
res.json({ id });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to find folder for host defaults", error);
|
||||
res.status(500).json({ error: "Failed to find the folder" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/preview:
|
||||
* post:
|
||||
* summary: Preview a host defaults change
|
||||
* description: How many hosts would change if a level were saved with these values. Nothing is written.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* level:
|
||||
* type: string
|
||||
* enum: [admin, user, folder]
|
||||
* folderId:
|
||||
* type: integer
|
||||
* set:
|
||||
* type: object
|
||||
* unset:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The number of hosts that would change.
|
||||
* 403:
|
||||
* description: Not allowed to change that level.
|
||||
*/
|
||||
router.post(
|
||||
"/defaults/preview",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const level = req.body?.level;
|
||||
try {
|
||||
let target;
|
||||
let overlayUserId: string | undefined;
|
||||
let folderId: number | undefined;
|
||||
if (level === "admin") {
|
||||
const permissions = PermissionManager.getInstance();
|
||||
if (
|
||||
!(await permissions.hasPermission(userId, "admin.settings.manage"))
|
||||
) {
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
target = { all: true };
|
||||
} else if (level === "user") {
|
||||
target = { userIds: [userId] };
|
||||
overlayUserId = userId;
|
||||
} else if (level === "folder") {
|
||||
const folder = await ownedFolder(userId, Number(req.body?.folderId));
|
||||
if (!folder)
|
||||
return res.status(404).json({ error: "Folder not found" });
|
||||
target = { userIds: [userId] };
|
||||
folderId = folder.id;
|
||||
} else {
|
||||
return res.status(400).json({ error: "Unknown level" });
|
||||
}
|
||||
const change = readChange(req.body);
|
||||
const { catalog } = currentCatalog();
|
||||
const set = new Map<string, unknown>();
|
||||
for (const [key, value] of Object.entries(change.set ?? {})) {
|
||||
const info = catalog.get(key);
|
||||
if (info) set.set(key, info.normalize(value));
|
||||
}
|
||||
const result = await materializeHosts(target, {
|
||||
dryRun: true,
|
||||
overlay: {
|
||||
level,
|
||||
userId: overlayUserId,
|
||||
folderId,
|
||||
set,
|
||||
unset: new Set(change.unset ?? []),
|
||||
},
|
||||
});
|
||||
res.json({ changedHosts: result.changedHostIds.length });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to preview host defaults", error);
|
||||
res.status(500).json({ error: "Failed to preview host defaults" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/resolve:
|
||||
* get:
|
||||
* summary: Resolve host defaults for a host
|
||||
* description: What every key resolves to for a host, or for a new host in a folder or under a parent host, and where each value comes from.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: hostId
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: folder
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: parentHostId
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Resolved values with their source, keyed by "namespace.key".
|
||||
* 404:
|
||||
* description: No such host you can edit.
|
||||
*/
|
||||
router.get(
|
||||
"/defaults/resolve",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
let ownerId = userId;
|
||||
const hostId =
|
||||
req.query.hostId !== undefined ? Number(req.query.hostId) : null;
|
||||
if (hostId !== null) {
|
||||
if (!Number.isInteger(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"edit",
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
return res.status(404).json({ error: "Host not found" });
|
||||
}
|
||||
const [row] = await createCurrentHostDefaultsRepository().listHosts({
|
||||
hostIds: [hostId],
|
||||
});
|
||||
if (!row) return res.status(404).json({ error: "Host not found" });
|
||||
ownerId = row.userId;
|
||||
}
|
||||
const folder =
|
||||
typeof req.query.folder === "string" ? req.query.folder : undefined;
|
||||
const parentHostId =
|
||||
req.query.parentHostId !== undefined && req.query.parentHostId !== ""
|
||||
? Number(req.query.parentHostId)
|
||||
: undefined;
|
||||
res.json({
|
||||
values: await resolveForEditor({
|
||||
ownerId,
|
||||
hostId,
|
||||
folder: folder === undefined ? undefined : folder || null,
|
||||
parentHostId:
|
||||
parentHostId === undefined || !Number.isInteger(parentHostId)
|
||||
? folder !== undefined
|
||||
? null
|
||||
: undefined
|
||||
: parentHostId,
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to resolve host defaults", error);
|
||||
res.status(500).json({ error: "Failed to resolve host defaults" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/defaults/jobs/{id}:
|
||||
* get:
|
||||
* summary: Host defaults job status
|
||||
* description: Progress of a server-wide host defaults update.
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: The job's status and how many hosts changed.
|
||||
* 404:
|
||||
* description: No such job.
|
||||
*/
|
||||
router.get(
|
||||
"/defaults/jobs/:id",
|
||||
authenticateJWT,
|
||||
requireAdminSettings,
|
||||
(req: Request, res: Response) => {
|
||||
const job = getRecomputeJob(String(req.params.id));
|
||||
if (!job) return res.status(404).json({ error: "Job not found" });
|
||||
res.json(job);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /host/db/host/{id}/reset-defaults:
|
||||
* post:
|
||||
* summary: Reset a host to its defaults
|
||||
* description: Hands keys the host set itself back to its host defaults. `all`, or `namespaces` ("core" or a plugin id), or `keys` as "namespace.key".
|
||||
* tags:
|
||||
* - Host Defaults
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* all:
|
||||
* type: boolean
|
||||
* namespaces:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* keys:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Reset.
|
||||
* 400:
|
||||
* description: Nothing to reset.
|
||||
* 403:
|
||||
* description: No edit access to the host.
|
||||
*/
|
||||
router.post(
|
||||
"/db/host/:id/reset-defaults",
|
||||
authenticateJWT,
|
||||
requireEditPermission,
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const hostId = Number(req.params.id);
|
||||
if (!Number.isInteger(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
const reset = readDefaultsReset(req.body);
|
||||
if (!reset) return res.status(400).json({ error: "Nothing to reset" });
|
||||
try {
|
||||
const access = await PermissionManager.getInstance().canAccessHost(
|
||||
userId,
|
||||
hostId,
|
||||
"edit",
|
||||
);
|
||||
if (!access.hasAccess) {
|
||||
return res.status(403).json({ error: "Access denied" });
|
||||
}
|
||||
if (!access.isOwner) {
|
||||
// A shared editor never takes over the owner's SSH login.
|
||||
reset.inherit = (reset.inherit ?? []).filter(
|
||||
([namespace, key]) => !(namespace === "core" && key === "auth"),
|
||||
);
|
||||
if (reset.inheritAll || reset.inheritNamespaces?.includes("core")) {
|
||||
return res.status(403).json({
|
||||
error: "Only the host owner can reset the host's SSH login",
|
||||
});
|
||||
}
|
||||
}
|
||||
await changeHostOverrides([hostId], reset);
|
||||
res.json({ message: "Host reset to its defaults" });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to reset host defaults", error);
|
||||
res.status(500).json({ error: "Failed to reset host defaults" });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
createCurrentSshCredentialUsageRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import { isNonEmptyString } from "./host-normalizers.js";
|
||||
import { recompute } from "../../hosts/defaults/recompute.js";
|
||||
import { setFolderCredentialDefault } from "../../hosts/defaults/service.js";
|
||||
|
||||
type HostFolderRoutesDeps = {
|
||||
authenticateJWT: RequestHandler;
|
||||
@@ -92,6 +94,8 @@ export function registerHostFolderRoutes(
|
||||
oldName,
|
||||
newName,
|
||||
);
|
||||
// Subfolders change parents with a rename, so their chains do too.
|
||||
void recompute({ userIds: [userId] }).catch(() => {});
|
||||
|
||||
res.json({
|
||||
message: "Folder renamed successfully",
|
||||
@@ -243,6 +247,14 @@ export function registerHostFolderRoutes(
|
||||
localOnly,
|
||||
);
|
||||
}
|
||||
if (normalizedCredentialId !== undefined) {
|
||||
await setFolderCredentialDefault(
|
||||
userId,
|
||||
folder.id,
|
||||
normalizedCredentialId,
|
||||
);
|
||||
void recompute({ userIds: [userId] }).catch(() => {});
|
||||
}
|
||||
|
||||
if (!created) {
|
||||
databaseLogger.info("Updating SSH folder", {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseDefaultOverrides } from "../../../types/host-defaults.js";
|
||||
import {
|
||||
parseSshOptions,
|
||||
type HostSshOptions,
|
||||
@@ -481,5 +482,6 @@ export function transformHostResponse(
|
||||
portKnockSequence: host.portKnockSequence
|
||||
? JSON.parse(host.portKnockSequence as string)
|
||||
: [],
|
||||
defaultOverrides: parseDefaultOverrides(host.defaultOverrides),
|
||||
};
|
||||
}
|
||||
@@ -224,14 +224,23 @@ export function attachHostPluginSettings(
|
||||
}
|
||||
}
|
||||
|
||||
/** The single-host convenience wrapper. */
|
||||
/**
|
||||
* The single-host convenience wrapper. With a viewer, a shared host's
|
||||
* personal fields come from that viewer's own defaults.
|
||||
*/
|
||||
export async function withHostPluginSettings(
|
||||
host: Record<string, unknown>,
|
||||
viewerId?: string,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const hostId = Number(host.id);
|
||||
if (!Number.isInteger(hostId)) return host;
|
||||
|
||||
const settings = await loadHostPluginSettings([hostId]);
|
||||
if (viewerId) {
|
||||
const { applyPersonalHostValues } =
|
||||
await import("../../hosts/defaults/personal.js");
|
||||
await applyPersonalHostValues(settings, viewerId).catch(() => {});
|
||||
}
|
||||
const values = settings.get(hostId);
|
||||
if (!values) return host;
|
||||
const result: Record<string, unknown> = {
|
||||
@@ -391,5 +400,8 @@ export async function setHostPluginEnabled(
|
||||
await setSetting(manifest, "host", hostId, enableKey, enabled);
|
||||
await touchHost(hostId);
|
||||
}
|
||||
const { changeHostOverrides } =
|
||||
await import("../../hosts/defaults/overrides.js");
|
||||
await changeHostOverrides(hostIds, { own: [[pluginId, enableKey]] });
|
||||
return true;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
createCurrentHostRepository,
|
||||
createCurrentUserRepository,
|
||||
createCurrentSharedHostAuthOverrideRepository,
|
||||
createCurrentHostDefaultsRepository,
|
||||
} from "../repositories/factory.js";
|
||||
import {
|
||||
applyHostKeyTypeUpdate,
|
||||
@@ -51,6 +52,7 @@ import { validateParentHostId } from "./host-parent-validation.js";
|
||||
import { registerHostFolderRoutes } from "./host-folder-routes.js";
|
||||
import { registerHostNetworkRoutes } from "./host-network-routes.js";
|
||||
import { registerHostBulkRoutes } from "./host-bulk-routes.js";
|
||||
import { registerHostDefaultsRoutes } from "./host-defaults-routes.js";
|
||||
import { registerHostStatusRoutes } from "./host-status-routes.js";
|
||||
import {
|
||||
applyHostEnrollmentDefaults,
|
||||
@@ -71,6 +73,11 @@ import {
|
||||
} from "../../utils/shared-host-auth-resolver.js";
|
||||
import { rejectSharedCopyWrites } from "../../sync/shared-copy-guard.js";
|
||||
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
|
||||
import {
|
||||
applyDefaultsAfterHostWrite,
|
||||
applyHostDefaultsToWrite,
|
||||
} from "../../hosts/defaults/index.js";
|
||||
import { applyPersonalHostValues } from "../../hosts/defaults/personal.js";
|
||||
import {
|
||||
applyProtocolAuthPlan,
|
||||
attachProtocolAuth,
|
||||
@@ -385,6 +392,14 @@ router.post(
|
||||
}
|
||||
|
||||
try {
|
||||
sshDataObj.defaultOverrides = JSON.stringify(
|
||||
await applyHostDefaultsToWrite({
|
||||
ownerId: userId,
|
||||
hostId: null,
|
||||
columns: sshDataObj,
|
||||
body: hostData,
|
||||
}),
|
||||
);
|
||||
const result = await createCurrentHostRepository().createEncryptedForUser(
|
||||
userId,
|
||||
sshDataObj,
|
||||
@@ -402,6 +417,7 @@ router.post(
|
||||
}
|
||||
|
||||
const createdHost = result;
|
||||
await applyDefaultsAfterHostWrite(createdHost.id as number);
|
||||
if (protocolAuthPatch) {
|
||||
await writeProtocolAuth(
|
||||
userId,
|
||||
@@ -1072,11 +1088,37 @@ router.put(
|
||||
return res.status(400).json({ error: terminalFieldsError });
|
||||
}
|
||||
|
||||
const storedRow = (
|
||||
await createCurrentHostDefaultsRepository().listHosts({
|
||||
hostIds: [Number(hostId)],
|
||||
})
|
||||
)[0];
|
||||
sshDataObj.defaultOverrides = JSON.stringify(
|
||||
await applyHostDefaultsToWrite({
|
||||
ownerId,
|
||||
hostId: Number(hostId),
|
||||
columns: sshDataObj,
|
||||
body: hostData,
|
||||
stored: storedRow,
|
||||
lockedKeys: accessInfo.isOwner ? [] : ["auth"],
|
||||
}),
|
||||
);
|
||||
|
||||
await createCurrentHostRepository().updateEncryptedForUser(
|
||||
ownerId,
|
||||
Number(hostId),
|
||||
sshDataObj,
|
||||
);
|
||||
await applyDefaultsAfterHostWrite(Number(hostId), {
|
||||
moved:
|
||||
!!storedRow &&
|
||||
((sshDataObj.folder !== undefined &&
|
||||
(sshDataObj.folder ?? null) !== (storedRow.folder ?? null)) ||
|
||||
(sshDataObj.parentHostId !== undefined &&
|
||||
(sshDataObj.parentHostId ?? null) !==
|
||||
(storedRow.parentHostId ?? null))),
|
||||
ownerId,
|
||||
});
|
||||
if (protocolAuthPlan) {
|
||||
await applyProtocolAuthPlan(ownerId, Number(hostId), protocolAuthPlan);
|
||||
}
|
||||
@@ -1296,14 +1338,15 @@ router.get(
|
||||
|
||||
// After sanitizing: the connect-level projection reduces a shared host
|
||||
// to an allowlist, which would drop this again. One query for the list.
|
||||
attachHostPluginSettings(
|
||||
sanitized,
|
||||
await loadHostPluginSettings(
|
||||
sanitized
|
||||
.map((host) => Number(host.id))
|
||||
.filter((id) => Number.isInteger(id)),
|
||||
),
|
||||
const pluginSettingsByHost = await loadHostPluginSettings(
|
||||
sanitized
|
||||
.map((host) => Number(host.id))
|
||||
.filter((id) => Number.isInteger(id)),
|
||||
);
|
||||
await applyPersonalHostValues(pluginSettingsByHost, userId).catch(
|
||||
() => {},
|
||||
);
|
||||
attachHostPluginSettings(sanitized, pluginSettingsByHost);
|
||||
|
||||
res.json(sanitized);
|
||||
} catch (err) {
|
||||
@@ -1432,6 +1475,7 @@ router.get(
|
||||
resolvedSharedResult,
|
||||
accessInfo.permissionLevel,
|
||||
),
|
||||
userId,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
@@ -2301,6 +2345,15 @@ registerHostNetworkRoutes(router, {
|
||||
requireDataAccess,
|
||||
});
|
||||
|
||||
registerHostDefaultsRoutes(router, {
|
||||
authenticateJWT,
|
||||
requireEditPermission: permissionManager.requirePermission("hosts.edit"),
|
||||
requireDataAccess,
|
||||
requireAdminSettings: permissionManager.requirePermission(
|
||||
"admin.settings.manage",
|
||||
),
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
/** Seconds between status checks, or null to follow the global setting. */
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "../repositories/factory.js";
|
||||
import { getPluginRuntime } from "../../plugins/index.js";
|
||||
import { describePluginFrontend } from "../../plugins/assets.js";
|
||||
import { getSetting } from "../../plugins/settings.js";
|
||||
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
|
||||
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
|
||||
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
|
||||
@@ -254,7 +253,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
};
|
||||
contributes = await withHostFieldDefaults(manifest as PluginManifest);
|
||||
contributes = manifest?.contributes ?? null;
|
||||
capabilities = Array.isArray(manifest?.capabilities)
|
||||
? (manifest.capabilities as string[])
|
||||
: [];
|
||||
@@ -1189,7 +1188,7 @@ router.get(
|
||||
* /plugins/{id}/settings/host/{hostId}:
|
||||
* put:
|
||||
* summary: Update a plugin's settings for one host
|
||||
* description: Writes host-scope values. Requires edit access to the host, so a user who may only connect to a shared host cannot change how a plugin treats it.
|
||||
* description: Writes host-scope values. Requires edit access to the host, so a user who may only connect to a shared host cannot change how a plugin treats it. A value written here becomes the host's own rather than following the host defaults; list keys in `_inherit` to hand them back to the defaults.
|
||||
* tags:
|
||||
* - Plugins
|
||||
* parameters:
|
||||
@@ -1253,12 +1252,19 @@ router.put(
|
||||
return;
|
||||
}
|
||||
|
||||
const { _inherit: inheritRaw, ...written } = req.body as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const inherit = Array.isArray(inheritRaw)
|
||||
? inheritRaw.filter((key): key is string => typeof key === "string")
|
||||
: [];
|
||||
const errors = await applySettings(
|
||||
userId,
|
||||
manifest,
|
||||
"host",
|
||||
String(hostId),
|
||||
req.body,
|
||||
written,
|
||||
{ isHostOwner: access.isOwner === true },
|
||||
);
|
||||
if (Object.keys(errors).length > 0) {
|
||||
@@ -1267,6 +1273,14 @@ router.put(
|
||||
}
|
||||
const { touchHost } = await import("./host-plugin-settings.js");
|
||||
await touchHost(hostId);
|
||||
// A value set here is the host's own; a key in _inherit follows the
|
||||
// host defaults again.
|
||||
const { changeHostOverrides } =
|
||||
await import("../../hosts/defaults/overrides.js");
|
||||
await changeHostOverrides([hostId], {
|
||||
own: Object.keys(written).map((key) => [pluginId, key]),
|
||||
inherit: inherit.map((key) => [pluginId, key]),
|
||||
});
|
||||
|
||||
const values = await getAllSettings(manifest, "host", hostId, {
|
||||
redactSecrets: true,
|
||||
@@ -1284,37 +1298,3 @@ router.put(
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
/**
|
||||
* A host field with defaultFrom starts new hosts at an admin setting's value,
|
||||
* so the host editor gets that value as the field's default.
|
||||
*/
|
||||
async function withHostFieldDefaults(
|
||||
manifest: PluginManifest,
|
||||
): Promise<unknown> {
|
||||
const contributes = manifest?.contributes;
|
||||
const host = contributes?.settings?.host;
|
||||
if (!host?.fields.some((field) => field.defaultFrom)) {
|
||||
return contributes ?? null;
|
||||
}
|
||||
const fields: PluginSettingsField[] = await Promise.all(
|
||||
host.fields.map(async (field) => {
|
||||
if (!field.defaultFrom) return field;
|
||||
try {
|
||||
const value = await getSetting(
|
||||
manifest,
|
||||
"admin",
|
||||
null,
|
||||
field.defaultFrom,
|
||||
);
|
||||
return value === undefined ? field : { ...field, default: value };
|
||||
} catch {
|
||||
return field;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return {
|
||||
...contributes,
|
||||
settings: { ...contributes.settings, host: { ...host, fields } },
|
||||
};
|
||||
}
|
||||
@@ -22,43 +22,6 @@ import {
|
||||
} from "../repositories/factory.js";
|
||||
import type { UserRecord } from "../repositories/user-repository.js";
|
||||
|
||||
export type HostDefaults = {
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
credentialId?: number | null;
|
||||
statusCheckEnabled?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keys host_defaults held before the terminal's new-host defaults became the
|
||||
* ssh-terminal plugin's admin settings. Kept in the stored row for the boot
|
||||
* copy and a downgrade, never served or overwritten here.
|
||||
*/
|
||||
const LEGACY_TERMINAL_HOST_DEFAULTS = [
|
||||
"fontSize",
|
||||
"fontFamily",
|
||||
"theme",
|
||||
"cursorStyle",
|
||||
"cursorBlink",
|
||||
"autoTmux",
|
||||
"enableCommandHistory",
|
||||
];
|
||||
|
||||
function parseHostDefaults(value: string | null): Record<string, unknown> {
|
||||
if (!value) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? parsed
|
||||
: {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function getAdminActor(
|
||||
userId: string | undefined,
|
||||
): Promise<UserRecord | null> {
|
||||
@@ -559,95 +522,4 @@ export function registerUserSettingsRoutes(
|
||||
"update_notification_private_endpoints",
|
||||
"notification endpoint",
|
||||
);
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/host-defaults:
|
||||
* get:
|
||||
* summary: Get host creation defaults
|
||||
* description: Returns the global default settings applied when creating a new host.
|
||||
* tags:
|
||||
* - Users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Host defaults object.
|
||||
* 500:
|
||||
* description: Failed to get host defaults.
|
||||
*/
|
||||
router.get("/host-defaults", authenticateJWT, async (_req, res) => {
|
||||
try {
|
||||
const stored = parseHostDefaults(
|
||||
await createCurrentSettingsRepository().get("host_defaults"),
|
||||
);
|
||||
for (const key of LEGACY_TERMINAL_HOST_DEFAULTS) delete stored[key];
|
||||
res.json(stored as HostDefaults);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get host defaults", err);
|
||||
res.status(500).json({ error: "Failed to get host defaults" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/host-defaults:
|
||||
* patch:
|
||||
* summary: Update host creation defaults (admin only)
|
||||
* description: Sets global default settings applied when a new host is created.
|
||||
* tags:
|
||||
* - Users
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Host defaults updated.
|
||||
* 403:
|
||||
* description: Not authorized.
|
||||
* 500:
|
||||
* description: Failed to update host defaults.
|
||||
*/
|
||||
router.patch("/host-defaults", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const actor = await getAdminActor(userId);
|
||||
if (!actor) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const defaults: HostDefaults = { ...(req.body ?? {}) };
|
||||
for (const key of LEGACY_TERMINAL_HOST_DEFAULTS) {
|
||||
delete (defaults as Record<string, unknown>)[key];
|
||||
}
|
||||
const settings = createCurrentSettingsRepository();
|
||||
const stored = parseHostDefaults(await settings.get("host_defaults"));
|
||||
const legacy = Object.fromEntries(
|
||||
LEGACY_TERMINAL_HOST_DEFAULTS.filter((key) => key in stored).map(
|
||||
(key) => [key, stored[key]],
|
||||
),
|
||||
);
|
||||
await settings.set(
|
||||
"host_defaults",
|
||||
JSON.stringify({ ...legacy, ...defaults }),
|
||||
);
|
||||
|
||||
const { ipAddress, userAgent } = getRequestMeta(req);
|
||||
await logAudit({
|
||||
userId,
|
||||
username: actor.username ?? userId,
|
||||
action: "update_host_defaults",
|
||||
resourceType: "setting",
|
||||
details: JSON.stringify(defaults),
|
||||
ipAddress,
|
||||
userAgent,
|
||||
success: true,
|
||||
});
|
||||
|
||||
res.json(defaults);
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to update host defaults", err);
|
||||
res.status(500).json({ error: "Failed to update host defaults" });
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user