Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ GITHUB_BRANCH=main
# Optional overrides for sourcedraft.config.json
CMS_CONTENT_DIR=
CMS_MEDIA_DIR=
CMS_PUBLIC_MEDIA_PATH=
CMS_ADAPTER=
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,12 @@ Details: [docs/security.md](docs/security.md)
| | `sourcedraft.config.json` | `.env` |
|---|---------------------------|--------|
| **Purpose** | Project settings safe to commit | Secrets and private targets |
| **Examples** | `contentDir`, `mediaDir`, `categories`, `adapter` | `GITHUB_TOKEN`, `GITHUB_OWNER`, `GITHUB_REPO`, `SOURCEDRAFT_ADMIN_PASSWORD` |
| **Examples** | `contentDir`, `mediaDir`, `publicMediaPath`, `categories`, `adapter` | `GITHUB_TOKEN`, `GITHUB_OWNER`, `GITHUB_REPO`, `SOURCEDRAFT_ADMIN_PASSWORD` |
| **Shared in git?** | Yes (copy from `sourcedraft.config.example.json`) | Never |

Optional env vars (`CMS_CONTENT_DIR`, `CMS_MEDIA_DIR`, `CMS_ADAPTER`, etc.) can override values from the JSON file. Secrets always stay in `.env`.
Optional env vars (`CMS_CONTENT_DIR`, `CMS_MEDIA_DIR`, `CMS_PUBLIC_MEDIA_PATH`, `CMS_ADAPTER`, etc.) can override values from the JSON file. Secrets always stay in `.env`.

`mediaDir` is where images are committed in your site repo. `publicMediaPath` is the URL path Studio inserts into posts (for example `/images`).

Reference: [docs/configuration.md](docs/configuration.md)

Expand Down
2 changes: 1 addition & 1 deletion apps/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
"dev": "concurrently -n web,api -c blue,gray \"vite\" \"tsx watch server/index.ts\"",
"dev:web": "vite",
"dev:server": "tsx watch server/index.ts",
"build": "tsc -b && vite build",
"build": "tsc -b && vite build && tsc -p server/tsconfig.json",
"build:server": "tsc -p server/tsconfig.json",
"start:server": "node dist-server/index.js",
"lint": "eslint .",
Expand Down
65 changes: 52 additions & 13 deletions apps/studio/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { randomBytes, timingSafeEqual } from "node:crypto";
import type { NextFunction, Request, Response } from "express";

const SESSION_COOKIE = "sourcedraft_session";
/** 24 hours — in-memory MVP sessions, not durable account auth. */
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;

type SessionRecord = {
Expand Down Expand Up @@ -37,19 +38,53 @@ function readCookie(req: Request, name: string): string | null {
return null;
}

function setSessionCookie(res: Response, token: string): void {
export function isSecureCookieEnvironment(req: Request): boolean {
const explicit = process.env.STUDIO_SECURE_COOKIES?.trim().toLowerCase();
if (explicit === "true") {
return true;
}
if (explicit === "false") {
return false;
}

const forwardedProto = req.headers["x-forwarded-proto"];
if (typeof forwardedProto === "string") {
const proto = forwardedProto.split(",")[0]?.trim().toLowerCase();
if (proto === "https") {
return true;
}
}

return process.env.NODE_ENV === "production";
}

function buildSessionCookie(
value: string,
maxAge: number,
req: Request,
): string {
const parts = [
`${SESSION_COOKIE}=${encodeURIComponent(value)}`,
"Path=/",
"HttpOnly",
"SameSite=Lax",
`Max-Age=${maxAge}`,
];

if (isSecureCookieEnvironment(req)) {
parts.push("Secure");
}

return parts.join("; ");
}

function setSessionCookie(req: Request, res: Response, token: string): void {
const maxAge = Math.floor(SESSION_TTL_MS / 1000);
res.setHeader(
"Set-Cookie",
`${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}`,
);
res.setHeader("Set-Cookie", buildSessionCookie(token, maxAge, req));
}

function clearSessionCookie(res: Response): void {
res.setHeader(
"Set-Cookie",
`${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`,
);
function clearSessionCookie(req: Request, res: Response): void {
res.setHeader("Set-Cookie", buildSessionCookie("", 0, req));
}

function purgeExpiredSessions(): void {
Expand Down Expand Up @@ -135,7 +170,11 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
next();
}

export function login(password: string, res: Response): { ok: boolean; error?: string } {
export function login(
req: Request,
password: string,
res: Response,
): { ok: boolean; error?: string } {
if (!isAuthConfigured()) {
return { ok: false, error: "Studio auth is not configured." };
}
Expand All @@ -145,11 +184,11 @@ export function login(password: string, res: Response): { ok: boolean; error?: s
}

const token = createSession();
setSessionCookie(res, token);
setSessionCookie(req, res, token);
return { ok: true };
}

export function logout(req: Request, res: Response): void {
destroySession(getSessionToken(req));
clearSessionCookie(res);
clearSessionCookie(req, res);
}
29 changes: 27 additions & 2 deletions apps/studio/server/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { loadSourceDraftConfig } from "@sourcedraft/config";
import {
derivePublicMediaPath,
loadSourceDraftConfig,
normalizePublicMediaPath,
} from "@sourcedraft/config";
import type { SourceDraftConfig } from "@sourcedraft/config";

export type SupportedAdapter = "astro-mdx" | "markdown";
Expand All @@ -10,6 +14,7 @@ export type PublishEnvConfig = {
branch: string;
contentDir: string;
mediaDir: string;
publicMediaPath: string;
adapter: SupportedAdapter;
categories: string[];
};
Expand All @@ -32,6 +37,22 @@ export function loadProjectConfig(): SourceDraftConfig {
return loadSourceDraftConfig();
}

function resolvePublicMediaPath(
mediaDir: string,
project: SourceDraftConfig,
): string {
const envOverride = process.env.CMS_PUBLIC_MEDIA_PATH?.trim();
if (envOverride) {
return normalizePublicMediaPath(envOverride);
}

if (project.publicMediaPathExplicit !== undefined) {
return project.publicMediaPathExplicit;
}

return derivePublicMediaPath(mediaDir);
}

export function loadPublishEnv(): PublishEnvResult {
const project = loadProjectConfig();

Expand All @@ -43,6 +64,7 @@ export function loadPublishEnv(): PublishEnvResult {
const contentDir =
process.env.CMS_CONTENT_DIR?.trim() || project.contentDir;
const mediaDir = process.env.CMS_MEDIA_DIR?.trim() || project.mediaDir;
const publicMediaPath = resolvePublicMediaPath(mediaDir, project);
const rawAdapter = process.env.CMS_ADAPTER?.trim() || project.adapter;
const adapter = resolveAdapter(rawAdapter);

Expand Down Expand Up @@ -74,6 +96,7 @@ export function loadPublishEnv(): PublishEnvResult {
branch,
contentDir,
mediaDir,
publicMediaPath,
adapter,
categories: project.categories,
},
Expand All @@ -84,13 +107,15 @@ export function loadPublicConfig(): Omit<PublishEnvConfig, "token"> {
const project = loadProjectConfig();
const rawAdapter = process.env.CMS_ADAPTER?.trim() || project.adapter;
const adapter = resolveAdapter(rawAdapter) ?? "astro-mdx";
const mediaDir = process.env.CMS_MEDIA_DIR?.trim() || project.mediaDir;

return {
owner: process.env.GITHUB_OWNER?.trim() || "",
repo: process.env.GITHUB_REPO?.trim() || "",
branch: process.env.GITHUB_BRANCH?.trim() || project.defaultBranch,
contentDir: process.env.CMS_CONTENT_DIR?.trim() || project.contentDir,
mediaDir: process.env.CMS_MEDIA_DIR?.trim() || project.mediaDir,
mediaDir,
publicMediaPath: resolvePublicMediaPath(mediaDir, project),
adapter,
categories: project.categories,
};
Expand Down
35 changes: 21 additions & 14 deletions apps/studio/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import { uploadMedia } from "./media.js";
import { listPosts, loadPost } from "./posts.js";
import { publishArticle, type PublishRequestBody } from "./publish.js";
import { requireSameSiteRequest } from "./requestProtection.js";

const envPaths = [
resolve(process.cwd(), ".env"),
Expand Down Expand Up @@ -41,9 +42,9 @@
});
});

app.post("/api/auth/login", (req, res) => {
app.post("/api/auth/login", requireSameSiteRequest, (req, res) => {
const password = typeof req.body?.password === "string" ? req.body.password : "";
const result = login(password, res);
const result = login(req, password, res);

if (!result.ok) {
res.status(result.error === "Invalid password." ? 401 : 500).json({
Expand All @@ -56,22 +57,23 @@
res.json({ ok: true });
});

app.post("/api/auth/logout", (req, res) => {
app.post("/api/auth/logout", requireSameSiteRequest, (req, res) => {
logout(req, res);
res.json({ ok: true });
});

app.get("/api/config", requireAuth, (_req, res) => {
const runtime = loadPublicConfig();

res.json({
adapter: runtime.adapter,
contentDir: runtime.contentDir,
mediaDir: runtime.mediaDir,
publicMediaPath: runtime.publicMediaPath,
defaultBranch: runtime.branch,
categories: runtime.categories,
githubOwner: runtime.owner,
githubRepo: runtime.repo,

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
});
});

Expand All @@ -95,18 +97,23 @@
res.status(result.status).json(result.body);
});

app.post("/api/media/upload", requireAuth, async (req, res) => {
const envResult = loadPublishEnv();
if (!envResult.ok) {
res.status(500).json({ ok: false, error: envResult.error });
return;
}

const result = await uploadMedia(req, envResult.config);
res.status(result.status).json(result.body);
});
app.post(
"/api/media/upload",
requireSameSiteRequest,
requireAuth,

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
async (req, res) => {
const envResult = loadPublishEnv();
if (!envResult.ok) {
res.status(500).json({ ok: false, error: envResult.error });
return;
}

const result = await uploadMedia(req, envResult.config);
res.status(result.status).json(result.body);
},
);

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
app.post("/api/publish", requireAuth, async (req, res) => {
app.post("/api/publish", requireSameSiteRequest, requireAuth, async (req, res) => {

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.
const envResult = loadPublishEnv();
if (!envResult.ok) {
res.status(500).json({ ok: false, error: envResult.error });
Expand All @@ -118,7 +125,7 @@
envResult.config,
);
res.status(result.status).json(result.body);
});

Check failure

Code scanning / CodeQL

Missing rate limiting High

This route handler performs
authorization
, but is not rate-limited.

app.listen(port, () => {
console.log(`SourceDraft publish API listening on http://localhost:${port}`);
Expand Down
9 changes: 2 additions & 7 deletions apps/studio/server/media.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { randomBytes } from "node:crypto";
import type { Request } from "express";
import Busboy from "busboy";
import { joinPublicMediaPath } from "@sourcedraft/config";
import { createGitHubPublisher } from "@sourcedraft/github-publisher";
import type { PublishEnvConfig } from "./config.js";

Expand Down Expand Up @@ -38,12 +39,6 @@ function normalizeMediaDir(mediaDir: string): string {
return mediaDir.replace(/^\/+/u, "").replace(/\/+$/u, "").trim();
}

function mediaPublicPath(mediaDir: string, filename: string): string {
const normalized = normalizeMediaDir(mediaDir);
const leaf = normalized.split("/").pop() ?? "media";
return `/${leaf}/${filename}`;
}

function sanitizeFilename(filename: string): string {
const base = filename.split(/[/\\]/u).pop() ?? "upload";
const cleaned = base
Expand Down Expand Up @@ -249,7 +244,7 @@ export async function uploadMedia(
`-${uniqueSuffix}$1`,
);
const repoPath = `${mediaDir}/${repoFilename}`;
const publicPath = mediaPublicPath(mediaDir, repoFilename);
const publicPath = joinPublicMediaPath(env.publicMediaPath, repoFilename);

const publisher = createGitHubPublisher({
token: env.token,
Expand Down
Loading