mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
v2.3.0
This commit is contained in:
@@ -147,6 +147,7 @@ router.post(
|
||||
key,
|
||||
keyPassword,
|
||||
keyType,
|
||||
certPublicKey,
|
||||
} = req.body;
|
||||
|
||||
if (!isNonEmptyString(userId) || !isNonEmptyString(name)) {
|
||||
@@ -230,6 +231,8 @@ router.post(
|
||||
keyPassword: plainKeyPassword,
|
||||
keyType: keyType || null,
|
||||
detectedKeyType: keyInfo?.keyType || null,
|
||||
certPublicKey:
|
||||
authType === "key" && certPublicKey ? certPublicKey.trim() : null,
|
||||
usageCount: 0,
|
||||
lastUsed: null,
|
||||
};
|
||||
@@ -431,15 +434,14 @@ router.get(
|
||||
if (credential.password) {
|
||||
output.password = credential.password;
|
||||
}
|
||||
if (credential.key) {
|
||||
output.key = credential.key;
|
||||
}
|
||||
if (credential.privateKey) {
|
||||
output.privateKey = credential.privateKey;
|
||||
}
|
||||
output.hasKey = !!credential.key;
|
||||
output.hasKeyPassword = !!credential.keyPassword;
|
||||
if (credential.publicKey) {
|
||||
output.publicKey = credential.publicKey;
|
||||
}
|
||||
if (credential.certPublicKey) {
|
||||
output.certPublicKey = credential.certPublicKey;
|
||||
}
|
||||
if (credential.keyPassword) {
|
||||
output.keyPassword = credential.keyPassword;
|
||||
}
|
||||
@@ -572,6 +574,9 @@ router.put(
|
||||
if (updateData.keyPassword !== undefined) {
|
||||
updateFields.keyPassword = updateData.keyPassword || null;
|
||||
}
|
||||
if (updateData.certPublicKey !== undefined) {
|
||||
updateFields.certPublicKey = updateData.certPublicKey?.trim() || null;
|
||||
}
|
||||
|
||||
if (Object.keys(updateFields).length === 0) {
|
||||
const existing = await SimpleDBOps.select(
|
||||
@@ -947,6 +952,7 @@ function formatCredentialOutput(
|
||||
authType: credential.authType,
|
||||
username: credential.username || null,
|
||||
publicKey: credential.publicKey,
|
||||
hasCertPublicKey: !!credential.certPublicKey,
|
||||
keyType: credential.keyType,
|
||||
detectedKeyType: credential.detectedKeyType,
|
||||
usageCount: credential.usageCount || 0,
|
||||
@@ -1445,7 +1451,7 @@ router.post(
|
||||
const publicKeyString =
|
||||
typeof publicKeyPem === "string"
|
||||
? publicKeyPem
|
||||
: publicKeyPem.toString("utf8");
|
||||
: (publicKeyPem as Buffer).toString("utf8");
|
||||
|
||||
let keyType = "unknown";
|
||||
const asymmetricKeyType = privateKeyObj.asymmetricKeyType;
|
||||
@@ -1617,9 +1623,12 @@ async function deploySSHKeyToHost(
|
||||
const escapedKey = actualPublicKey
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/'/g, "'\\''");
|
||||
const escapedName = credData.name
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/'/g, "'\\''");
|
||||
|
||||
conn.exec(
|
||||
`printf '%s\n' '${escapedKey} ${credData.name}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
|
||||
`printf '%s\n' '${escapedKey} ${escapedName}@Termix' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys`,
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
clearTimeout(addTimeout);
|
||||
|
||||
@@ -43,6 +43,8 @@ const router = express.Router();
|
||||
|
||||
const upload = multer({ storage: multer.memoryStorage() });
|
||||
|
||||
const STATS_SERVER_URL = "http://localhost:30005";
|
||||
|
||||
function notifyStatsHostUpdated(
|
||||
hostId: number,
|
||||
headers: Pick<Request["headers"], "authorization" | "cookie">,
|
||||
@@ -50,7 +52,7 @@ function notifyStatsHostUpdated(
|
||||
): void {
|
||||
axios
|
||||
.post(
|
||||
"http://localhost:30005/host-updated",
|
||||
`${STATS_SERVER_URL}/host-updated`,
|
||||
{ hostId },
|
||||
{
|
||||
headers: {
|
||||
@@ -244,26 +246,18 @@ function normalizeImportedHost(
|
||||
}
|
||||
|
||||
const SENSITIVE_FIELDS = [
|
||||
"password",
|
||||
"key",
|
||||
"keyPassword",
|
||||
"sudoPassword",
|
||||
"autostartPassword",
|
||||
"autostartKey",
|
||||
"autostartKeyPassword",
|
||||
"socks5Password",
|
||||
"rdpPassword",
|
||||
"vncPassword",
|
||||
"telnetPassword",
|
||||
];
|
||||
|
||||
function stripSensitiveFields(
|
||||
host: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const result = { ...host };
|
||||
result.hasPassword = !!host.password;
|
||||
result.hasKey = !!host.key;
|
||||
result.hasSudoPassword = !!host.sudoPassword;
|
||||
result.hasKeyPassword = !!host.keyPassword;
|
||||
for (const field of SENSITIVE_FIELDS) {
|
||||
delete result[field];
|
||||
}
|
||||
@@ -291,10 +285,23 @@ function transformHostResponse(
|
||||
showTunnelInSidebar: !!host.showTunnelInSidebar,
|
||||
showDockerInSidebar: !!host.showDockerInSidebar,
|
||||
showServerStatsInSidebar: !!host.showServerStatsInSidebar,
|
||||
enableSsh: host.enableSsh !== undefined ? !!host.enableSsh : true,
|
||||
enableRdp: !!host.enableRdp,
|
||||
enableVnc: !!host.enableVnc,
|
||||
enableTelnet: !!host.enableTelnet,
|
||||
// Old hosts only had connection_type set; the per-protocol enable flags didn't exist yet.
|
||||
// The schema defaults (enableSsh=true, others=false) wrongly mark every old host as SSH.
|
||||
// Detect this migration case: if no non-SSH protocol is explicitly enabled AND
|
||||
// connectionType is set to a non-SSH value, fall back to inferring from connectionType.
|
||||
...(() => {
|
||||
const ct = host.connectionType;
|
||||
const rdp = !!host.enableRdp;
|
||||
const vnc = !!host.enableVnc;
|
||||
const tel = !!host.enableTelnet;
|
||||
const isMigratedNonSsh = !rdp && !vnc && !tel && ct && ct !== "ssh";
|
||||
return {
|
||||
enableSsh: isMigratedNonSsh ? false : !!host.enableSsh,
|
||||
enableRdp: isMigratedNonSsh ? ct === "rdp" : rdp,
|
||||
enableVnc: isMigratedNonSsh ? ct === "vnc" : vnc,
|
||||
enableTelnet: isMigratedNonSsh ? ct === "telnet" : tel,
|
||||
};
|
||||
})(),
|
||||
sshPort: host.sshPort ?? host.port ?? 22,
|
||||
rdpPort: host.rdpPort ?? 3389,
|
||||
vncPort: host.vncPort ?? 5900,
|
||||
@@ -305,9 +312,6 @@ function transformHostResponse(
|
||||
rdpIgnoreCert: !!host.rdpIgnoreCert,
|
||||
vncUser: host.vncUser || undefined,
|
||||
telnetUser: host.telnetUser || undefined,
|
||||
hasRdpPassword: !!host.rdpPassword,
|
||||
hasVncPassword: !!host.vncPassword,
|
||||
hasTelnetPassword: !!host.telnetPassword,
|
||||
tunnelConnections: host.tunnelConnections
|
||||
? JSON.parse(host.tunnelConnections as string)
|
||||
: [],
|
||||
@@ -658,15 +662,19 @@ router.post(
|
||||
authType ||
|
||||
authMethod ||
|
||||
(effectiveConnectionType !== "ssh" ? "password" : undefined);
|
||||
const effectiveUsername =
|
||||
username || rdpUser || vncUser || telnetUser || "";
|
||||
const effectiveName =
|
||||
name || (effectiveUsername ? `${effectiveUsername}@${ip}` : String(ip));
|
||||
const sshDataObj: Record<string, unknown> = {
|
||||
userId: userId,
|
||||
connectionType: effectiveConnectionType,
|
||||
name,
|
||||
name: effectiveName,
|
||||
folder: folder || null,
|
||||
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
username: effectiveUsername,
|
||||
authType: effectiveAuthType,
|
||||
credentialId: credentialId || null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
@@ -722,7 +730,7 @@ router.post(
|
||||
portKnockSequence: portKnockSequence
|
||||
? JSON.stringify(portKnockSequence)
|
||||
: null,
|
||||
enableSsh: enableSsh !== false ? 1 : 0,
|
||||
enableSsh: enableSsh ? 1 : 0,
|
||||
enableRdp: enableRdp ? 1 : 0,
|
||||
enableVnc: enableVnc ? 1 : 0,
|
||||
enableTelnet: enableTelnet ? 1 : 0,
|
||||
@@ -1190,14 +1198,18 @@ router.put(
|
||||
}
|
||||
|
||||
const effectiveAuthType = authType || authMethod;
|
||||
const effectiveUsername =
|
||||
username || rdpUser || vncUser || telnetUser || "";
|
||||
const effectiveName =
|
||||
name || (effectiveUsername ? `${effectiveUsername}@${ip}` : String(ip));
|
||||
const sshDataObj: Record<string, unknown> = {
|
||||
connectionType: connectionType || "ssh",
|
||||
name,
|
||||
name: effectiveName,
|
||||
folder,
|
||||
tags: Array.isArray(tags) ? tags.join(",") : tags || "",
|
||||
ip,
|
||||
port,
|
||||
username,
|
||||
username: effectiveUsername,
|
||||
authType: effectiveAuthType,
|
||||
credentialId: credentialId || null,
|
||||
overrideCredentialUsername: overrideCredentialUsername ? 1 : 0,
|
||||
@@ -1253,7 +1265,7 @@ router.put(
|
||||
portKnockSequence: portKnockSequence
|
||||
? JSON.stringify(portKnockSequence)
|
||||
: null,
|
||||
enableSsh: enableSsh !== false ? 1 : 0,
|
||||
enableSsh: enableSsh ? 1 : 0,
|
||||
enableRdp: enableRdp ? 1 : 0,
|
||||
enableVnc: enableVnc ? 1 : 0,
|
||||
enableTelnet: enableTelnet ? 1 : 0,
|
||||
@@ -1566,6 +1578,23 @@ router.get(
|
||||
guacamoleConfig: hosts.guacamoleConfig,
|
||||
macAddress: hosts.macAddress,
|
||||
dockerConfig: hosts.dockerConfig,
|
||||
enableSsh: hosts.enableSsh,
|
||||
enableRdp: hosts.enableRdp,
|
||||
enableVnc: hosts.enableVnc,
|
||||
enableTelnet: hosts.enableTelnet,
|
||||
sshPort: hosts.sshPort,
|
||||
rdpPort: hosts.rdpPort,
|
||||
vncPort: hosts.vncPort,
|
||||
telnetPort: hosts.telnetPort,
|
||||
rdpUser: hosts.rdpUser,
|
||||
rdpPassword: hosts.rdpPassword,
|
||||
rdpDomain: hosts.rdpDomain,
|
||||
rdpSecurity: hosts.rdpSecurity,
|
||||
rdpIgnoreCert: hosts.rdpIgnoreCert,
|
||||
vncUser: hosts.vncUser,
|
||||
vncPassword: hosts.vncPassword,
|
||||
telnetUser: hosts.telnetUser,
|
||||
telnetPassword: hosts.telnetPassword,
|
||||
|
||||
ownerId: hosts.userId,
|
||||
isShared: sql<boolean>`${hostAccess.id} IS NOT NULL AND ${hosts.userId} != ${userId}`,
|
||||
@@ -1891,9 +1920,21 @@ router.get(
|
||||
const exportData = isRemoteDesktop
|
||||
? {
|
||||
...baseExportData,
|
||||
domain: resolvedHost.domain || null,
|
||||
security: resolvedHost.security || null,
|
||||
ignoreCert: !!resolvedHost.ignoreCert,
|
||||
enableRdp: !!resolvedHost.enableRdp,
|
||||
enableVnc: !!resolvedHost.enableVnc,
|
||||
enableTelnet: !!resolvedHost.enableTelnet,
|
||||
rdpPort: resolvedHost.rdpPort || 3389,
|
||||
vncPort: resolvedHost.vncPort || 5900,
|
||||
telnetPort: resolvedHost.telnetPort || 23,
|
||||
rdpUser: resolvedHost.rdpUser || null,
|
||||
rdpPassword: resolvedHost.rdpPassword || null,
|
||||
rdpDomain: resolvedHost.rdpDomain || null,
|
||||
rdpSecurity: resolvedHost.rdpSecurity || null,
|
||||
rdpIgnoreCert: !!resolvedHost.rdpIgnoreCert,
|
||||
vncUser: resolvedHost.vncUser || null,
|
||||
vncPassword: resolvedHost.vncPassword || null,
|
||||
telnetUser: resolvedHost.telnetUser || null,
|
||||
telnetPassword: resolvedHost.telnetPassword || null,
|
||||
guacamoleConfig: resolvedHost.guacamoleConfig
|
||||
? JSON.parse(resolvedHost.guacamoleConfig as string)
|
||||
: null,
|
||||
@@ -2217,9 +2258,8 @@ router.delete(
|
||||
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const statsPort = 30005;
|
||||
await axios.post(
|
||||
`http://localhost:${statsPort}/host-deleted`,
|
||||
`${STATS_SERVER_URL}/host-deleted`,
|
||||
{ hostId: numericHostId },
|
||||
{
|
||||
headers: {
|
||||
@@ -3394,11 +3434,10 @@ router.delete(
|
||||
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const statsPort = 30005;
|
||||
for (const host of hostsToDelete) {
|
||||
try {
|
||||
await axios.post(
|
||||
`http://localhost:${statsPort}/host-deleted`,
|
||||
`${STATS_SERVER_URL}/host-deleted`,
|
||||
{ hostId: host.id },
|
||||
{
|
||||
headers: {
|
||||
@@ -3804,6 +3843,10 @@ router.post(
|
||||
overrideCredentialUsername: hostData.overrideCredentialUsername
|
||||
? 1
|
||||
: 0,
|
||||
enableSsh: hostData.enableSsh ?? false,
|
||||
enableRdp: hostData.enableRdp ?? false,
|
||||
enableVnc: hostData.enableVnc ?? false,
|
||||
enableTelnet: hostData.enableTelnet ?? false,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -3814,9 +3857,21 @@ router.post(
|
||||
sshDataObj.key = null;
|
||||
sshDataObj.keyPassword = null;
|
||||
sshDataObj.keyType = null;
|
||||
sshDataObj.domain = hostData.domain || null;
|
||||
sshDataObj.security = hostData.security || null;
|
||||
sshDataObj.ignoreCert = hostData.ignoreCert ? 1 : 0;
|
||||
sshDataObj.rdpUser = hostData.rdpUser || null;
|
||||
sshDataObj.rdpPassword = hostData.rdpPassword || null;
|
||||
sshDataObj.rdpDomain = hostData.rdpDomain || null;
|
||||
sshDataObj.rdpSecurity = hostData.rdpSecurity || null;
|
||||
sshDataObj.rdpIgnoreCert = hostData.rdpIgnoreCert ? 1 : 0;
|
||||
sshDataObj.rdpPort = hostData.rdpPort || 3389;
|
||||
sshDataObj.vncUser = hostData.vncUser || null;
|
||||
sshDataObj.vncPassword = hostData.vncPassword || null;
|
||||
sshDataObj.vncPort = hostData.vncPort || 5900;
|
||||
sshDataObj.telnetUser = hostData.telnetUser || null;
|
||||
sshDataObj.telnetPassword = hostData.telnetPassword || null;
|
||||
sshDataObj.telnetPort = hostData.telnetPort || 23;
|
||||
sshDataObj.enableRdp = hostData.enableRdp ? 1 : 0;
|
||||
sshDataObj.enableVnc = hostData.enableVnc ? 1 : 0;
|
||||
sshDataObj.enableTelnet = hostData.enableTelnet ? 1 : 0;
|
||||
sshDataObj.guacamoleConfig = hostData.guacamoleConfig
|
||||
? JSON.stringify(hostData.guacamoleConfig)
|
||||
: null;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
sharedCredentials,
|
||||
snippets,
|
||||
snippetAccess,
|
||||
sshCredentials,
|
||||
} from "../db/schema.js";
|
||||
import { eq, and, desc, sql, or, isNull, gte } from "drizzle-orm";
|
||||
import type { Response } from "express";
|
||||
@@ -128,10 +129,10 @@ router.post(
|
||||
return res.status(403).json({ error: "Not host owner" });
|
||||
}
|
||||
|
||||
if (!host[0].credentialId) {
|
||||
if (!host[0].credentialId && host[0].authType !== "opkssh") {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
"Only hosts using credentials can be shared. Please create a credential and assign it to this host before sharing.",
|
||||
"Only hosts using credentials or OPKSSH can be shared. Please create a credential and assign it to this host before sharing.",
|
||||
code: "CREDENTIAL_REQUIRED_FOR_SHARING",
|
||||
});
|
||||
}
|
||||
@@ -203,23 +204,25 @@ router.post(
|
||||
.delete(sharedCredentials)
|
||||
.where(eq(sharedCredentials.hostAccessId, existing[0].id));
|
||||
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
if (targetType === "user") {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
existing[0].id,
|
||||
host[0].credentialId,
|
||||
targetUserId!,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await sharedCredManager.createSharedCredentialsForRole(
|
||||
existing[0].id,
|
||||
host[0].credentialId,
|
||||
targetRoleId!,
|
||||
userId,
|
||||
);
|
||||
if (host[0].credentialId) {
|
||||
const { SharedCredentialManager } =
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
if (targetType === "user") {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
existing[0].id,
|
||||
host[0].credentialId,
|
||||
targetUserId!,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await sharedCredManager.createSharedCredentialsForRole(
|
||||
existing[0].id,
|
||||
host[0].credentialId,
|
||||
targetRoleId!,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
databaseLogger.info("Permission granted", {
|
||||
operation: "rbac_permission_grant",
|
||||
@@ -249,20 +252,22 @@ router.post(
|
||||
await import("../../utils/shared-credential-manager.js");
|
||||
const sharedCredManager = SharedCredentialManager.getInstance();
|
||||
|
||||
if (targetType === "user") {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
result.lastInsertRowid as number,
|
||||
host[0].credentialId,
|
||||
targetUserId!,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await sharedCredManager.createSharedCredentialsForRole(
|
||||
result.lastInsertRowid as number,
|
||||
host[0].credentialId,
|
||||
targetRoleId!,
|
||||
userId,
|
||||
);
|
||||
if (host[0].credentialId) {
|
||||
if (targetType === "user") {
|
||||
await sharedCredManager.createSharedCredentialForUser(
|
||||
result.lastInsertRowid as number,
|
||||
host[0].credentialId,
|
||||
targetUserId!,
|
||||
userId,
|
||||
);
|
||||
} else {
|
||||
await sharedCredManager.createSharedCredentialsForRole(
|
||||
result.lastInsertRowid as number,
|
||||
host[0].credentialId,
|
||||
targetRoleId!,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
databaseLogger.success("Host shared successfully", {
|
||||
operation: "rbac_host_share_success",
|
||||
@@ -1520,4 +1525,58 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
router.put(
|
||||
"/host-access/:hostId/credential",
|
||||
async (req: express.Request, res: express.Response) => {
|
||||
try {
|
||||
const userId = (req as AuthenticatedRequest).userId!;
|
||||
const hostId = Number.parseInt(String(req.params.hostId), 10);
|
||||
const { credentialId } = req.body;
|
||||
|
||||
if (!hostId || isNaN(hostId)) {
|
||||
return res.status(400).json({ error: "Invalid host ID" });
|
||||
}
|
||||
|
||||
const access = await db
|
||||
.select()
|
||||
.from(hostAccess)
|
||||
.where(
|
||||
and(eq(hostAccess.hostId, hostId), eq(hostAccess.userId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (access.length === 0) {
|
||||
return res.status(403).json({ error: "No access to this host" });
|
||||
}
|
||||
|
||||
if (credentialId) {
|
||||
const cred = await db
|
||||
.select({ id: sshCredentials.id })
|
||||
.from(sshCredentials)
|
||||
.where(
|
||||
and(
|
||||
eq(sshCredentials.id, credentialId),
|
||||
eq(sshCredentials.userId, userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (cred.length === 0) {
|
||||
return res.status(404).json({ error: "Credential not found" });
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(hostAccess)
|
||||
.set({ overrideCredentialId: credentialId || null })
|
||||
.where(eq(hostAccess.id, access[0].id));
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
databaseLogger.error("Failed to set override credential", error);
|
||||
res.status(500).json({ error: "Failed to update credential" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -93,6 +93,7 @@ async function getAccessibleSnippet(snippetId: number, userId: string) {
|
||||
order: snippets.order,
|
||||
createdAt: snippets.createdAt,
|
||||
updatedAt: snippets.updatedAt,
|
||||
hostFilter: snippets.hostFilter,
|
||||
})
|
||||
.from(snippetAccess)
|
||||
.innerJoin(snippets, eq(snippetAccess.snippetId, snippets.id))
|
||||
@@ -1104,7 +1105,7 @@ router.post(
|
||||
requireDataAccess,
|
||||
async (req: Request, res: Response) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
const { name, content, description, folder, order } = req.body;
|
||||
const { name, content, description, folder, order, hostFilter } = req.body;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(userId) ||
|
||||
@@ -1146,6 +1147,7 @@ router.post(
|
||||
description: description?.trim() || null,
|
||||
folder: folder?.trim() || null,
|
||||
order: snippetOrder,
|
||||
hostFilter: hostFilter ? JSON.stringify(hostFilter) : null,
|
||||
};
|
||||
|
||||
const result = await db.insert(snippets).values(insertData).returning();
|
||||
@@ -1238,6 +1240,7 @@ router.put(
|
||||
description: string | null;
|
||||
folder: string | null;
|
||||
order: number;
|
||||
hostFilter: string | null;
|
||||
}> = {
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
};
|
||||
@@ -1251,6 +1254,10 @@ router.put(
|
||||
if (updateData.folder !== undefined)
|
||||
updateFields.folder = updateData.folder?.trim() || null;
|
||||
if (updateData.order !== undefined) updateFields.order = updateData.order;
|
||||
if (updateData.hostFilter !== undefined)
|
||||
updateFields.hostFilter = updateData.hostFilter
|
||||
? JSON.stringify(updateData.hostFilter)
|
||||
: null;
|
||||
|
||||
await db
|
||||
.update(snippets)
|
||||
|
||||
@@ -521,6 +521,7 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => {
|
||||
name_path,
|
||||
scopes,
|
||||
allowed_users,
|
||||
admin_group,
|
||||
} = req.body;
|
||||
|
||||
const isDisableRequest =
|
||||
@@ -579,6 +580,7 @@ router.post("/oidc-config", authenticateJWT, async (req, res) => {
|
||||
name_path,
|
||||
scopes: scopes || "openid email profile",
|
||||
allowed_users: allowed_users || "",
|
||||
admin_group: admin_group || "",
|
||||
};
|
||||
|
||||
let encryptedConfig;
|
||||
@@ -817,7 +819,7 @@ router.get("/oidc-config/admin", requireAdmin, async (req, res) => {
|
||||
*/
|
||||
router.get("/oidc/authorize", async (req, res) => {
|
||||
try {
|
||||
const { rememberMe } = req.query;
|
||||
const { rememberMe, desktopCallbackPort } = req.query;
|
||||
const origin = getRequestOriginWithForceHTTPS(req);
|
||||
const backendCallbackUri = `${origin}/users/oidc/callback`;
|
||||
|
||||
@@ -840,7 +842,9 @@ router.get("/oidc/authorize", async (req, res) => {
|
||||
|
||||
const referer = req.get("Referer");
|
||||
let frontendOrigin;
|
||||
if (referer) {
|
||||
if (desktopCallbackPort) {
|
||||
frontendOrigin = `http://127.0.0.1:${desktopCallbackPort}/oidc-callback`;
|
||||
} else if (referer) {
|
||||
const refererUrl = new URL(referer);
|
||||
frontendOrigin = `${refererUrl.protocol}//${refererUrl.host}`;
|
||||
} else {
|
||||
@@ -949,6 +953,18 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
config = JSON.parse(
|
||||
(configRow as Record<string, unknown>).value as string,
|
||||
);
|
||||
|
||||
if (config.client_secret?.startsWith("encrypted:")) {
|
||||
config.client_secret = Buffer.from(
|
||||
config.client_secret.substring(10),
|
||||
"base64",
|
||||
).toString("utf8");
|
||||
} else if (config.client_secret?.startsWith("encoded:")) {
|
||||
config.client_secret = Buffer.from(
|
||||
config.client_secret.substring(8),
|
||||
"base64",
|
||||
).toString("utf8");
|
||||
}
|
||||
}
|
||||
|
||||
const tokenResponse = await fetch(config.token_url, {
|
||||
@@ -1150,11 +1166,28 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
const oidcAllowRegistration =
|
||||
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() ===
|
||||
"true";
|
||||
let oidcAutoProvision = false;
|
||||
try {
|
||||
const oidcProvRow = db.$client
|
||||
.prepare(
|
||||
"SELECT value FROM settings WHERE key = 'oidc_auto_provision'",
|
||||
)
|
||||
.get();
|
||||
if (oidcProvRow) {
|
||||
oidcAutoProvision =
|
||||
(oidcProvRow as Record<string, unknown>).value === "true";
|
||||
}
|
||||
} catch {
|
||||
// fall through to env var check
|
||||
}
|
||||
|
||||
if (!isFirstUser && !oidcAllowRegistration) {
|
||||
if (!oidcAutoProvision) {
|
||||
oidcAutoProvision =
|
||||
(process.env.OIDC_ALLOW_REGISTRATION || "").trim().toLowerCase() ===
|
||||
"true";
|
||||
}
|
||||
|
||||
if (!isFirstUser && !oidcAutoProvision) {
|
||||
try {
|
||||
const regRow = db.$client
|
||||
.prepare(
|
||||
@@ -1300,6 +1333,25 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
|
||||
const userRecord = user[0];
|
||||
|
||||
// Sync admin status based on OIDC group membership
|
||||
if (config.admin_group) {
|
||||
const groups = (userInfo.groups || userInfo.roles || []) as string[];
|
||||
const shouldBeAdmin = groups.includes(config.admin_group);
|
||||
if (!!userRecord.isAdmin !== shouldBeAdmin) {
|
||||
await db
|
||||
.update(users)
|
||||
.set({ isAdmin: shouldBeAdmin })
|
||||
.where(eq(users.id, userRecord.id));
|
||||
userRecord.isAdmin = shouldBeAdmin;
|
||||
authLogger.info("OIDC admin status synced", {
|
||||
operation: "oidc_admin_group_sync",
|
||||
userId: userRecord.id,
|
||||
group: config.admin_group,
|
||||
isAdmin: shouldBeAdmin,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await authManager.authenticateOIDCUser(userRecord.id, deviceInfo.type);
|
||||
} catch (setupError) {
|
||||
@@ -1333,6 +1385,8 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
const redirectUrl = new URL(frontendOrigin);
|
||||
redirectUrl.searchParams.set("success", "true");
|
||||
|
||||
const isDesktopCallback = frontendOrigin.startsWith("http://127.0.0.1:");
|
||||
|
||||
const maxAge =
|
||||
deviceInfo.type === "desktop" || deviceInfo.type === "mobile"
|
||||
? 30 * 24 * 60 * 60 * 1000
|
||||
@@ -1342,6 +1396,11 @@ router.get("/oidc/callback", async (req, res) => {
|
||||
|
||||
res.clearCookie("jwt", authManager.getClearCookieOptions(req));
|
||||
|
||||
if (isDesktopCallback) {
|
||||
redirectUrl.searchParams.set("token", token);
|
||||
return res.redirect(redirectUrl.toString());
|
||||
}
|
||||
|
||||
return res
|
||||
.cookie("jwt", token, authManager.getSecureCookieOptions(req, maxAge))
|
||||
.redirect(redirectUrl.toString());
|
||||
@@ -1883,6 +1942,56 @@ router.patch("/registration-allowed", authenticateJWT, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/oidc-auto-provision", async (_req, res) => {
|
||||
try {
|
||||
const row = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'oidc_auto_provision'")
|
||||
.get();
|
||||
res.json({
|
||||
enabled: row ? (row as Record<string, unknown>).value === "true" : false,
|
||||
});
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to get OIDC auto-provision setting", err);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to get OIDC auto-provision setting" });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/oidc-auto-provision", authenticateJWT, async (req, res) => {
|
||||
const userId = (req as AuthenticatedRequest).userId;
|
||||
try {
|
||||
const user = await db.select().from(users).where(eq(users.id, userId));
|
||||
if (!user || user.length === 0 || !user[0].isAdmin) {
|
||||
return res.status(403).json({ error: "Not authorized" });
|
||||
}
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== "boolean") {
|
||||
return res.status(400).json({ error: "Invalid value for enabled" });
|
||||
}
|
||||
const existing = db.$client
|
||||
.prepare("SELECT value FROM settings WHERE key = 'oidc_auto_provision'")
|
||||
.get();
|
||||
if (existing) {
|
||||
db.$client
|
||||
.prepare(
|
||||
"UPDATE settings SET value = ? WHERE key = 'oidc_auto_provision'",
|
||||
)
|
||||
.run(enabled ? "true" : "false");
|
||||
} else {
|
||||
db.$client
|
||||
.prepare(
|
||||
"INSERT INTO settings (key, value) VALUES ('oidc_auto_provision', ?)",
|
||||
)
|
||||
.run(enabled ? "true" : "false");
|
||||
}
|
||||
res.json({ enabled });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to set OIDC auto-provision", err);
|
||||
res.status(500).json({ error: "Failed to set OIDC auto-provision" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /users/password-login-allowed:
|
||||
@@ -1954,6 +2063,8 @@ router.patch("/password-login-allowed", authenticateJWT, async (req, res) => {
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('allow_password_login', ?)",
|
||||
)
|
||||
.run(allowed ? "true" : "false");
|
||||
const { saveMemoryDatabaseToFile } = await import("../db/index.js");
|
||||
await saveMemoryDatabaseToFile();
|
||||
res.json({ allowed });
|
||||
} catch (err) {
|
||||
authLogger.error("Failed to set password login allowed", err);
|
||||
|
||||
Reference in New Issue
Block a user