Files
miniSCP/src/bun/store/connections.ts
T
JokinandClaude Fable 5 c7271fb7cf Windows compatibility pass (beta target)
External editor via cmd/start + exe picker; Permissions hidden for
Windows-local (chmod only flips read-only there); Explorer/Recycle Bin
wording; Ctrl vs Cmd shortcut labels; separator-aware path joins +
drive-root detection; drives list in the panel source picker; icon.ico
+ colored tray icon; icacls ACL on connections.json; README Windows
(beta) section with upstream known issues and a smoke-test checklist.
Runtime paths pending first boot on a real Windows machine.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 00:02:42 +02:00

92 lines
3.0 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { ConnectionsFile, SavedConnection, Stack } from "../../shared/types";
import { appDataDir } from "./paths";
const EMPTY: ConnectionsFile = { version: 2, stacks: [], connections: [] };
/**
* Plain-JSON store for saved connections (passwords included — the user's
* explicit v1 choice). Kept at 0600 and written atomically via tmp+rename.
* Only the current version 2 format is read; anything else starts empty.
*/
export class ConnectionStore {
private readonly file = path.join(appDataDir(), "connections.json");
private data: ConnectionsFile | null = null;
async load(): Promise<ConnectionsFile> {
if (!this.data) {
try {
const parsed = JSON.parse(await fs.readFile(this.file, "utf8")) as ConnectionsFile;
this.data = parsed.version === 2 ? parsed : structuredClone(EMPTY);
} catch {
this.data = structuredClone(EMPTY);
}
}
return this.data;
}
async upsertConnection(connection: SavedConnection): Promise<string> {
const data = await this.load();
const saved = { ...connection, id: connection.id || crypto.randomUUID() };
const index = data.connections.findIndex((c) => c.id === saved.id);
if (index >= 0) data.connections[index] = saved;
else data.connections.push(saved);
await this.save();
return saved.id;
}
async deleteConnection(id: string): Promise<void> {
const data = await this.load();
data.connections = data.connections.filter((c) => c.id !== id);
await this.save();
}
async upsertStack(stack: Stack): Promise<string> {
const data = await this.load();
const saved = { ...stack, id: stack.id || crypto.randomUUID() };
const index = data.stacks.findIndex((s) => s.id === saved.id);
if (index >= 0) data.stacks[index] = saved;
else data.stacks.push(saved);
await this.save();
return saved.id;
}
/** Deleting a stack moves its connections to the loose list. */
async deleteStack(id: string): Promise<void> {
const data = await this.load();
data.stacks = data.stacks.filter((s) => s.id !== id);
for (const connection of data.connections) {
if (connection.stackId === id) connection.stackId = null;
}
await this.save();
}
private async save(): Promise<void> {
await fs.mkdir(path.dirname(this.file), { recursive: true });
const tmp = `${this.file}.tmp`;
await fs.writeFile(tmp, JSON.stringify(this.data, null, "\t"), { mode: 0o600 });
await fs.rename(tmp, this.file);
await fs.chmod(this.file, 0o600);
this.restrictOnWindows();
}
/**
* PORTABILITY: 0o600 only toggles the read-only bit on Windows, so the
* plaintext passwords in this file would be world-readable there. Replace
* inheritance with a current-user-only ACL, best effort.
*/
private restrictOnWindows(): void {
if (process.platform !== "win32") return;
try {
Bun.spawn(
["icacls", this.file, "/inheritance:r", "/grant:r", `${os.userInfo().username}:F`],
{ stdout: "ignore", stderr: "ignore" },
);
} catch {
// ACL tooling unavailable — file still works, just unprotected
}
}
}