Initial PHP rebuild of the app
This commit is contained in:
690 files changed
+33944
-12046
No files matched your search
@@ -1,54 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const cookieSet = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: vi.fn(async () => ({ set: cookieSet })),
|
||||
}));
|
||||
|
||||
import { createManagerSession } from "../management-auth";
|
||||
|
||||
describe("Manager session cookie", () => {
|
||||
const originalAppUrl = process.env.NEXT_PUBLIC_APP_URL;
|
||||
const originalAdminEmail = process.env.MANAGER_ADMIN_EMAIL;
|
||||
const originalAuthSecret = process.env.MANAGER_AUTH_SECRET;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalAppUrl === undefined) delete process.env.NEXT_PUBLIC_APP_URL;
|
||||
else process.env.NEXT_PUBLIC_APP_URL = originalAppUrl;
|
||||
if (originalAdminEmail === undefined) delete process.env.MANAGER_ADMIN_EMAIL;
|
||||
else process.env.MANAGER_ADMIN_EMAIL = originalAdminEmail;
|
||||
if (originalAuthSecret === undefined) delete process.env.MANAGER_AUTH_SECRET;
|
||||
else process.env.MANAGER_AUTH_SECRET = originalAuthSecret;
|
||||
cookieSet.mockReset();
|
||||
});
|
||||
|
||||
it("does not mark the cookie Secure for an HTTP deployment", async () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = "http://manager.example.com";
|
||||
process.env.MANAGER_ADMIN_EMAIL = "admin@example.com";
|
||||
process.env.MANAGER_AUTH_SECRET = "test-secret";
|
||||
|
||||
await createManagerSession();
|
||||
|
||||
expect(cookieSet).toHaveBeenCalledWith(
|
||||
"nextchair_manager_session",
|
||||
expect.any(String),
|
||||
expect.objectContaining({ secure: false, path: "/" }),
|
||||
);
|
||||
expect(cookieSet).toHaveBeenCalledWith("nextchair_manager_session", "", { path: "/manager", maxAge: 0 });
|
||||
});
|
||||
|
||||
it("marks the cookie Secure for an HTTPS deployment", async () => {
|
||||
process.env.NEXT_PUBLIC_APP_URL = "https://manager.example.com";
|
||||
process.env.MANAGER_ADMIN_EMAIL = "admin@example.com";
|
||||
process.env.MANAGER_AUTH_SECRET = "test-secret";
|
||||
|
||||
await createManagerSession();
|
||||
|
||||
expect(cookieSet).toHaveBeenCalledWith(
|
||||
"nextchair_manager_session",
|
||||
expect.any(String),
|
||||
expect.objectContaining({ secure: true, path: "/" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,79 +0,0 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { cookies } from "next/headers";
|
||||
import { isSecureDeployment } from "@/lib/security/cookie-security";
|
||||
import { getManagerConfig } from "./manager-config";
|
||||
|
||||
const COOKIE_NAME = "nextchair_manager_session";
|
||||
|
||||
function signature(value: string, secret: string) {
|
||||
return createHmac("sha256", secret).update(value).digest("hex");
|
||||
}
|
||||
|
||||
export function isManagerConfigured() {
|
||||
const config = getManagerConfig();
|
||||
return Boolean(config.adminEmail && config.adminPassword && config.authSecret);
|
||||
}
|
||||
|
||||
export function authenticateManager(email: string, password: string) {
|
||||
const config = getManagerConfig();
|
||||
if (!isManagerConfigured() || email.trim().toLowerCase() !== config.adminEmail.trim().toLowerCase()) return false;
|
||||
const expected = Buffer.from(config.adminPassword);
|
||||
const received = Buffer.from(password);
|
||||
return expected.length === received.length && timingSafeEqual(expected, received);
|
||||
}
|
||||
|
||||
export async function createManagerSession() {
|
||||
const config = getManagerConfig();
|
||||
const value = `${config.adminEmail.trim().toLowerCase()}.${Date.now()}`;
|
||||
const store = await cookies();
|
||||
// Remove sessions issued by older builds with Path=/manager. Browsers can
|
||||
// retain both cookies, and the stale one may be selected first on /manager.
|
||||
store.set(COOKIE_NAME, "", { path: "/manager", maxAge: 0 });
|
||||
store.set(COOKIE_NAME, `${value}.${signature(value, config.authSecret)}`, {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: isSecureDeployment(),
|
||||
// The Manager UI is under /manager, but its authenticated API is under
|
||||
// /api/manager, so the cookie must cover both paths.
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 8,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearManagerSession() {
|
||||
const store = await cookies();
|
||||
store.delete(COOKIE_NAME);
|
||||
store.set(COOKIE_NAME, "", { path: "/manager", maxAge: 0 });
|
||||
}
|
||||
|
||||
export async function isManagerAuthenticated() {
|
||||
const config = getManagerConfig();
|
||||
|
||||
console.log("[manager-auth] auth secret:", config.authSecret ? "set" : "missing");
|
||||
|
||||
if (!config.authSecret) return false;
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const rawValue = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
console.log("[manager-auth] cookie:", rawValue ? "received" : "missing");
|
||||
|
||||
const value = rawValue ? decodeURIComponent(rawValue) : undefined;
|
||||
|
||||
if (!value) return false;
|
||||
|
||||
const separator = value.lastIndexOf(".");
|
||||
if (separator < 1) return false;
|
||||
|
||||
const payload = value.slice(0, separator);
|
||||
const received = Buffer.from(value.slice(separator + 1));
|
||||
const expected = Buffer.from(signature(payload, config.authSecret));
|
||||
|
||||
const valid =
|
||||
received.length === expected.length &&
|
||||
timingSafeEqual(received, expected);
|
||||
|
||||
console.log("[manager-auth] signature valid:", valid);
|
||||
|
||||
return valid;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
export function getManagerConfig() {
|
||||
const stackRoot = process.env.NEXTCHAIR_STACK_ROOT || `${process.cwd()}/stacks`;
|
||||
return {
|
||||
stackRoot,
|
||||
caddySitesDir: process.env.NEXTCHAIR_CADDY_SITES_DIR || `${stackRoot}/caddy/sites`,
|
||||
dockgeUrl: process.env.NEXTCHAIR_DOCKGE_URL || "",
|
||||
adminEmail: process.env.MANAGER_ADMIN_EMAIL || "",
|
||||
adminPassword: process.env.MANAGER_ADMIN_PASSWORD || "",
|
||||
authSecret: process.env.MANAGER_AUTH_SECRET || process.env.AUTH_SECRET || "",
|
||||
};
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import type { BusinessDeployment, BusinessStatus, ProvisionBusinessInput } from "./types";
|
||||
import { getManagerConfig } from "./manager-config";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
function registryPath() {
|
||||
return path.join(getManagerConfig().stackRoot, "registry.json");
|
||||
}
|
||||
|
||||
function containerName(slug: string) {
|
||||
return `nextchair-${slug}`;
|
||||
}
|
||||
|
||||
function healthState(value: string | undefined): BusinessStatus["health"] {
|
||||
return value === "healthy" || value === "unhealthy" || value === "starting" ? value : "unknown";
|
||||
}
|
||||
|
||||
async function readRegistry(): Promise<BusinessDeployment[]> {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(registryPath(), "utf8")) as BusinessDeployment[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function writeRegistry(items: BusinessDeployment[]) {
|
||||
await fs.mkdir(path.dirname(registryPath()), { recursive: true });
|
||||
await fs.writeFile(registryPath(), `${JSON.stringify(items, null, 2)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
async function docker(args: string[]) {
|
||||
return run("docker", args, { maxBuffer: 1024 * 1024 * 4 });
|
||||
}
|
||||
|
||||
async function inspectContainer(slug: string) {
|
||||
try {
|
||||
const result = await docker(["inspect", "-f", "{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{end}}", containerName(slug)]);
|
||||
const [container, health] = result.stdout.trim().split("|");
|
||||
return { container: container === "running" ? "running" : container ? "stopped" : "unknown", health: healthState(health) } as const;
|
||||
} catch {
|
||||
return { container: "unknown", health: "unknown" } as const;
|
||||
}
|
||||
}
|
||||
|
||||
async function databaseStatus() {
|
||||
try {
|
||||
const result = await docker(["inspect", "-f", "{{.State.Status}}", "nextchair-mysql"]);
|
||||
return result.stdout.trim() === "running" ? "online" : "offline";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
async function storageBytes(slug: string) {
|
||||
try {
|
||||
const volume = await docker(["volume", "inspect", "-f", "{{.Mountpoint}}", `nextchair-media-${slug}`]);
|
||||
const mountpoint = volume.stdout.trim();
|
||||
if (!mountpoint) return null;
|
||||
const usage = await run("du", ["-sk", mountpoint], { maxBuffer: 1024 * 64 });
|
||||
const kilobytes = usage.stdout.trim().split(/\s+/)[0];
|
||||
if (!kilobytes) return null;
|
||||
const bytes = Number.parseInt(kilobytes, 10) * 1024;
|
||||
return Number.isFinite(bytes) ? bytes : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function listBusinesses(): Promise<BusinessStatus[]> {
|
||||
const [items, database] = await Promise.all([readRegistry(), databaseStatus()]);
|
||||
return Promise.all(items.map(async (item) => {
|
||||
const [runtime, storage] = await Promise.all([inspectContainer(item.slug), storageBytes(item.slug)]);
|
||||
return { ...item, ...runtime, database, storageBytes: storage };
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getBusiness(slug: string) {
|
||||
const business = (await listBusinesses()).find((item) => item.slug === slug);
|
||||
if (!business) throw new Error("Business deployment not found.");
|
||||
return business;
|
||||
}
|
||||
|
||||
export async function createBusiness(input: ProvisionBusinessInput) {
|
||||
const existing = (await readRegistry()).some((item) => item.slug === input.slug);
|
||||
if (existing) throw new Error("A business with this slug already exists.");
|
||||
const script = path.join(process.cwd(), "scripts", "create-business.sh");
|
||||
await run(script, [input.slug, input.domain, input.image, input.name], { maxBuffer: 1024 * 1024 * 4 });
|
||||
return getBusiness(input.slug);
|
||||
}
|
||||
|
||||
async function compose(slug: string, args: string[]) {
|
||||
const composeFile = path.join(getManagerConfig().stackRoot, slug, "compose.yaml");
|
||||
await run("docker", ["compose", "-f", composeFile, ...args], { maxBuffer: 1024 * 1024 * 4 });
|
||||
}
|
||||
|
||||
export async function startBusiness(slug: string) { await compose(slug, ["up", "-d"]); return getBusiness(slug); }
|
||||
export async function stopBusiness(slug: string) { await compose(slug, ["stop"]); return getBusiness(slug); }
|
||||
export async function restartBusiness(slug: string) { await compose(slug, ["restart"]); return getBusiness(slug); }
|
||||
export async function updateBusiness(slug: string) {
|
||||
// A local tag may not exist in a registry, so a failed pull is allowed to
|
||||
// fall through to Compose using the already-built local image.
|
||||
try { await compose(slug, ["pull"]); } catch { /* local/self-hosted image */ }
|
||||
await compose(slug, ["up", "-d"]);
|
||||
return getBusiness(slug);
|
||||
}
|
||||
|
||||
export async function getBusinessLogs(slug: string) {
|
||||
const composeFile = path.join(getManagerConfig().stackRoot, slug, "compose.yaml");
|
||||
const result = await run("docker", ["compose", "-f", composeFile, "logs", "--tail", "200", `nextchair-${slug}`], { maxBuffer: 1024 * 1024 * 4 });
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
export async function removeBusiness(slug: string) {
|
||||
const items = await readRegistry();
|
||||
const target = items.find((item) => item.slug === slug);
|
||||
if (!target) throw new Error("Business deployment not found.");
|
||||
await compose(slug, ["down"]);
|
||||
await fs.rm(path.join(getManagerConfig().stackRoot, slug), { recursive: true, force: false });
|
||||
await fs.rm(path.join(getManagerConfig().caddySitesDir, `${slug}.caddy`), { force: false });
|
||||
await writeRegistry(items.filter((item) => item.slug !== slug));
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
export type BusinessDeployment = {
|
||||
name: string;
|
||||
slug: string;
|
||||
domain: string;
|
||||
image: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type BusinessStatus = BusinessDeployment & {
|
||||
container: "running" | "stopped" | "unknown";
|
||||
health: "healthy" | "unhealthy" | "starting" | "unknown";
|
||||
database: "online" | "offline" | "unknown";
|
||||
storageBytes: number | null;
|
||||
};
|
||||
|
||||
export type ProvisionBusinessInput = {
|
||||
name: string;
|
||||
slug: string;
|
||||
domain: string;
|
||||
image: string;
|
||||
};
|
||||
Reference in new issue
Block a user