PHP Rebuild
CI / ci (push) Canceled after 0s

Initial PHP rebuild of the app
This commit is contained in:
Stefan Lazic committed 2026-09-05 08:21:33 +01:00
1 parent 8a2b211069
commit 2ef3654841
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;
}
-11
View File
@@ -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));
}
-22
View File
@@ -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;
};