Skip to content
Merged
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
26 changes: 26 additions & 0 deletions apps/studio/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,4 +193,30 @@ test.describe("Studio smoke", () => {
await expect(source).toBeVisible();
await expect(source).toHaveValue(/<CustomBlock \/>/u);
});

test("formatting controls cannot run in source mode", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New article" }).click();
const toolbar = page.getByRole("toolbar", { name: "Editor formatting" });
await expect(toolbar.getByRole("button", { name: "Bold", exact: true })).toBeEnabled();

await page.getByRole("button", { name: "Source", exact: true }).click();

// In source mode the rich-text controls are not rendered, so none of them
// can execute against the hidden editor.
for (const name of ["Bold", "Underline", "Insert internal link"]) {
await expect(toolbar.getByRole("button", { name, exact: true })).toHaveCount(0);
}
});

test("undo and redo stay disabled with nothing to undo", async ({ page }) => {
await enterDemoMode(page);
await page.getByRole("button", { name: "New article" }).click();
const toolbar = page.getByRole("toolbar", { name: "Editor formatting" });
const undo = toolbar.getByRole("button", { name: "Undo", exact: true });
await expect(undo).toBeDisabled();
// Forcing a click past the native disabled state must not run the command.
await undo.click({ force: true });
await expect(undo).toBeDisabled();
});
});
30 changes: 30 additions & 0 deletions apps/studio/server/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,36 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
res.status(401).json({ ok: false, error: "Authentication required." });
}

/**
* A request is a "hard" demo request when the deployment is forced into demo
* mode or the session itself was created through demo entry. This is stricter
* than {@link isRequestDemoSession}: a real (non-demo) authenticated user whose
* publisher is not configured yet is NOT treated as demo, so they can still run
* legitimate setup writes such as config generation.
*/
export function isHardDemoRequest(req: Request): boolean {
return isDemoModeForced() || isDemoSession(getSessionToken(req));
}

/**
* Guards routes that mutate real files or configuration. Demo mode must stay
* read/demo-only, so demo sessions (and forced-demo deployments) are rejected
* before any real write happens. Pair with {@link requireAuth}, which rejects
* unauthenticated requests first.
*/
export function requireNonDemo(req: Request, res: Response, next: NextFunction): void {
if (isHardDemoRequest(req)) {
res.status(403).json({
ok: false,
error:
"This action is disabled in demo mode. Demo mode never changes real files or configuration.",
});
return;
}

next();
}

export async function login(
req: Request,
password: string,
Expand Down
2 changes: 2 additions & 0 deletions apps/studio/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
login,
logout,
requireAuth,
requireNonDemo,
} from "./auth.js";
import { loadPublicConfig, loadPublishEnv } from "./config.js";
import { listDemoPostsHandler, loadDemoPost } from "./demoPosts.js";
Expand Down Expand Up @@ -138,6 +139,7 @@ app.post(
writeLimiter,
requireSameSiteRequest,
requireAuth,
requireNonDemo,
(_req, res) => {
const result = runGenerateConfig();
if (!result.ok) {
Expand Down
106 changes: 106 additions & 0 deletions apps/studio/server/requireNonDemo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import assert from "node:assert/strict";
import { afterEach, describe, it } from "node:test";
import type { NextFunction, Request, Response } from "express";
import {
createSession,
isHardDemoRequest,
isRequestDemoSession,
requireNonDemo,
} from "./auth.js";

const SESSION_COOKIE = "sourcedraft_session";
const ENV_KEYS = [
"SOURCEDRAFT_DEMO_MODE",
"CMS_PUBLISHER",
"GITHUB_TOKEN",
"GITHUB_OWNER",
"GITHUB_REPO",
] as const;

const original = new Map<string, string | undefined>();

function mockRequest(cookie?: string): Request {
return { headers: cookie ? { cookie } : {} } as Request;
}

function mockResponse() {
const result: { statusCode?: number; body?: { ok?: boolean; error?: string } } =
{};
const res = {
status(code: number) {
result.statusCode = code;
return this;
},
json(payload: { ok?: boolean; error?: string }) {
result.body = payload;
return this;
},
} as unknown as Response;
return { res, result };
}

function run(req: Request) {
const { res, result } = mockResponse();
let nextCalled = false;
const next: NextFunction = () => {
nextCalled = true;
};
requireNonDemo(req, res, next);
return { nextCalled, result };
}

describe("requireNonDemo", () => {
afterEach(() => {
for (const key of ENV_KEYS) {
const value = original.get(key);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
original.clear();
});

function clearEnv(): void {
for (const key of ENV_KEYS) {
original.set(key, process.env[key]);
delete process.env[key];
}
}

it("rejects forced demo deployments before any real write", () => {
clearEnv();
process.env.SOURCEDRAFT_DEMO_MODE = "true";

const { nextCalled, result } = run(mockRequest());
assert.equal(nextCalled, false);
assert.equal(result.statusCode, 403);
assert.equal(result.body?.ok, false);
});

it("rejects demo sessions", () => {
clearEnv();
const token = createSession({ demo: true });

const { nextCalled, result } = run(mockRequest(`${SESSION_COOKIE}=${token}`));
assert.equal(nextCalled, false);
assert.equal(result.statusCode, 403);
});

it("allows a real authenticated session even before a publisher is configured", () => {
clearEnv();
const token = createSession();
const req = mockRequest(`${SESSION_COOKIE}=${token}`);

// With no publisher configured, the broad demo-routing check still reports
// demo for read fallbacks, but the write guard must NOT block a real user
// who is trying to set up their config.
assert.equal(isRequestDemoSession(req), true);
assert.equal(isHardDemoRequest(req), false);

const { nextCalled, result } = run(req);
assert.equal(nextCalled, true);
assert.equal(result.statusCode, undefined);
});
});
3 changes: 2 additions & 1 deletion apps/studio/src/components/PublishGate.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { PublishMode } from "@sourcedraft/publishers";
import type { ValidationIssue } from "@sourcedraft/core";
import type { ArticleFormState } from "../lib/articleForm";
import { canSubmitPublish } from "../lib/publishGate";
import { PublishChecklist } from "./PublishChecklist";

type PublishGateProps = {
Expand Down Expand Up @@ -126,7 +127,7 @@ export function PublishGate({
onPublishModeChange,
onPublish,
}: PublishGateProps) {
const canPublish = ready && !publishing && (githubReady || demoMode);
const canPublish = canSubmitPublish({ ready, publishing, githubReady, demoMode });
const reason = disabledReason(ready, githubReady, publishing, demoMode);

return (
Expand Down
14 changes: 11 additions & 3 deletions apps/studio/src/editor/EditorToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useEditorState, type Editor } from "@tiptap/react";
import type { PostSummary } from "../lib/posts.js";
import { InternalLinkPicker } from "../components/InternalLinkPicker.js";
import { editorDocToBody } from "./markdownRoundtrip.js";
import { isToolbarButtonEnabled } from "./toolbarButton.js";

export type LatestMediaUpload = {
publicPath: string;
Expand Down Expand Up @@ -168,8 +169,8 @@ export function EditorToolbar({
}

function insertFileLink(currentEditor: Editor) {
let path = "";
let filename = "Document";
let path: string;
let filename: string;

if (latestUpload?.kind === "pdf") {
path = latestUpload.publicPath;
Expand Down Expand Up @@ -422,11 +423,14 @@ export function EditorToolbar({
aria-label={button.ariaLabel}
aria-pressed={button.active ?? undefined}
title={button.title ?? button.label}
disabled={button.disabled || editorMode !== "rich"}
disabled={!isToolbarButtonEnabled(button.disabled, editorMode)}
onMouseDown={(event) => {
event.preventDefault();
}}
onClick={() => {
if (!isToolbarButtonEnabled(button.disabled, editorMode)) {
return;
}
runEditorAction(button.action);
}}
>
Expand All @@ -440,10 +444,14 @@ export function EditorToolbar({
aria-label="Insert internal link"
title="Internal link"
aria-expanded={internalLinkOpen}
disabled={!isToolbarButtonEnabled(false, editorMode)}
onMouseDown={(event) => {
event.preventDefault();
}}
onClick={() => {
if (!isToolbarButtonEnabled(false, editorMode)) {
return;
}
setInternalLinkOpen(true);
}}
>
Expand Down
5 changes: 5 additions & 0 deletions apps/studio/src/editor/SourceDraftEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,10 @@ export function SourceDraftEditor({
const bodyVersion = useRef(body);
const slashHandlerRef = useRef<(command: SlashCommandId) => void>(() => {});

// Build the slash extension once and dispatch to the latest handler through
// slashHandlerRef, so the Tiptap extension is not rebuilt on every render.
// The React Compiler flags this intentional latest-ref pattern.
/* eslint-disable react-hooks/preserve-manual-memoization, react-hooks/refs */
const slashExtension = useMemo(
() =>
createSlashCommandsExtension(
Expand Down Expand Up @@ -109,6 +113,7 @@ export function SourceDraftEditor({
),
[],
);
/* eslint-enable react-hooks/preserve-manual-memoization, react-hooks/refs */

const editor = useEditor({
extensions: [
Expand Down
20 changes: 20 additions & 0 deletions apps/studio/src/editor/toolbarButton.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { isToolbarButtonEnabled } from "./toolbarButton.js";

describe("isToolbarButtonEnabled", () => {
it("enables a non-disabled button in rich mode", () => {
assert.equal(isToolbarButtonEnabled(false, "rich"), true);
assert.equal(isToolbarButtonEnabled(undefined, "rich"), true);
});

it("disables an individually-disabled button even in rich mode", () => {
assert.equal(isToolbarButtonEnabled(true, "rich"), false);
});

it("disables every control in source mode", () => {
assert.equal(isToolbarButtonEnabled(false, "source"), false);
assert.equal(isToolbarButtonEnabled(undefined, "source"), false);
assert.equal(isToolbarButtonEnabled(true, "source"), false);
});
});
14 changes: 14 additions & 0 deletions apps/studio/src/editor/toolbarButton.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export type EditorMode = "rich" | "source";

/**
* A toolbar control may only run its command when it is not individually
* disabled and the editor is in rich mode. Centralizing the rule keeps the
* `disabled` attribute and the click handler in sync, so a disabled button can
* never execute its action even if the native disabled state is bypassed.
*/
export function isToolbarButtonEnabled(
disabled: boolean | undefined,
editorMode: EditorMode,
): boolean {
return disabled !== true && editorMode === "rich";
}
67 changes: 67 additions & 0 deletions apps/studio/src/lib/publishGate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { canSubmitPublish, isRealPublish } from "./publishGate.js";

describe("publish gating", () => {
it("treats only a connected, non-demo Studio as a real publish", () => {
assert.equal(isRealPublish({ githubReady: true, demoMode: false }), true);
assert.equal(isRealPublish({ githubReady: true, demoMode: true }), false);
assert.equal(isRealPublish({ githubReady: false, demoMode: false }), false);
});

it("allows demo mode to submit (simulation only), never a real publish", () => {
const inputs = {
ready: true,
publishing: false,
githubReady: false,
demoMode: true,
};
assert.equal(canSubmitPublish(inputs), true);
assert.equal(isRealPublish(inputs), false);
});

it("allows a configured non-demo Studio to publish for real", () => {
assert.equal(
canSubmitPublish({
ready: true,
publishing: false,
githubReady: true,
demoMode: false,
}),
true,
);
});

it("blocks publish when not connected and not in demo mode", () => {
assert.equal(
canSubmitPublish({
ready: true,
publishing: false,
githubReady: false,
demoMode: false,
}),
false,
);
});

it("blocks publish for invalid or in-flight articles", () => {
assert.equal(
canSubmitPublish({
ready: false,
publishing: false,
githubReady: true,
demoMode: false,
}),
false,
);
assert.equal(
canSubmitPublish({
ready: true,
publishing: true,
githubReady: true,
demoMode: false,
}),
false,
);
});
});
Loading
Loading