Skip to content

Commit cab3958

Browse files
fredrivettclaude
andauthored
Add /words daily writing space (#12)
* Add /words daily writing space A new section for daily writing entries with a 100-word goal. Posts commit as MDX files via the GitHub API on submit. Public posts render on the listing and detail page; private posts show metadata only (date, time, word count) until signed in. - /words listing with stats (total words, entries, days written, avg) - /words/[slug] for individual posts (404s for private without cookie) - /words/new with a textarea editor + markdown formatting toolbar, live word count, and localStorage draft autosave - /words/login: password gate; 90-day signed cookie via jose - /api/words/publish: cookie-gated, commits to fredrivett.com via octokit - New env vars: WORDS_PASSWORD, WORDS_SESSION_SECRET, WORDS_GITHUB_TOKEN Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Tidy /words header: use FredHead and drop sign-in link Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Validate `next` redirect on /words/login Reject anything that isn't a same-origin path so a crafted ?next=//evil.com can't redirect after sign-in. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Drop ensureDir from /words read paths The serverless filesystem is read-only outside /tmp, so calling mkdirSync during SSR would 500 the page on a fresh deploy. Read paths now just return empty when _words/ is missing, and a .gitkeep ensures the directory ships in builds. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * Date /words slugs in London time Building the slug from UTC meant a 23:30 BST entry was already dated to tomorrow. Use Intl.DateTimeFormat with Europe/London so the slug matches local wall-clock, midnight to midnight. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 5bf8ed3 commit cab3958

17 files changed

Lines changed: 1471 additions & 0 deletions

File tree

.env.example

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,11 @@ CONVERTKIT_FORM_ID= # your_form_id_here
77

88
# HereNowFyi Widget Configuration
99
NEXT_PUBLIC_HERENOW_BASE_URL=https://www.herenow.fyi
10+
11+
# /words — daily writing space
12+
# Password used to sign in at /words/login
13+
WORDS_PASSWORD=
14+
# Random string, 32+ chars (e.g. `openssl rand -hex 32`)
15+
WORDS_SESSION_SECRET=
16+
# Fine-grained PAT with Contents: Read/Write on fredrivett/fredrivett.com
17+
WORDS_GITHUB_TOKEN=

_words/.gitkeep

Whitespace-only changes.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,12 @@
3737
"clsx": "^2.1.1",
3838
"date-fns": "^2.28.0",
3939
"gray-matter": "^4.0.3",
40+
"jose": "^6.2.3",
4041
"lucide-react": "^0.543.0",
4142
"next": "13.4.19",
4243
"next-mdx-remote": "^6.0.0",
4344
"next-seo": "^5.1.0",
45+
"octokit": "^5.0.5",
4446
"react": "^18.2.0",
4547
"react-dom": "^18.2.0",
4648
"react-tweet": "^3.2.2",

src/app/api/words/login/route.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
3+
import { buildSessionCookie, createSessionToken } from "lib/words-auth";
4+
5+
export async function POST(request: NextRequest) {
6+
const expected = process.env.WORDS_PASSWORD;
7+
if (!expected) {
8+
return NextResponse.json(
9+
{ error: "WORDS_PASSWORD is not configured" },
10+
{ status: 500 },
11+
);
12+
}
13+
14+
let password = "";
15+
try {
16+
const body = await request.json();
17+
password = String(body?.password ?? "");
18+
} catch {
19+
return NextResponse.json({ error: "Bad request" }, { status: 400 });
20+
}
21+
22+
if (password !== expected) {
23+
return NextResponse.json({ error: "Wrong password" }, { status: 401 });
24+
}
25+
26+
const token = await createSessionToken();
27+
const res = NextResponse.json({ ok: true });
28+
res.headers.append("Set-Cookie", buildSessionCookie(token));
29+
return res;
30+
}

src/app/api/words/logout/route.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { NextResponse } from "next/server";
2+
3+
import { buildClearCookie } from "lib/words-auth";
4+
5+
export async function POST() {
6+
const res = NextResponse.json({ ok: true });
7+
res.headers.append("Set-Cookie", buildClearCookie());
8+
return res;
9+
}

src/app/api/words/publish/route.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { Octokit } from "octokit";
3+
4+
import { isAuthedFromCookieHeader } from "lib/words-auth";
5+
6+
import {
7+
buildFrontmatter,
8+
buildWordSlug,
9+
countWords,
10+
WORDS_GOAL,
11+
WordVisibility,
12+
} from "utils/words-shared";
13+
14+
const REPO_OWNER = "fredrivett";
15+
const REPO_NAME = "fredrivett.com";
16+
const REPO_BRANCH = "main";
17+
18+
export async function POST(request: NextRequest) {
19+
const cookieHeader = request.headers.get("cookie");
20+
const authed = await isAuthedFromCookieHeader(cookieHeader);
21+
if (!authed) {
22+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
23+
}
24+
25+
const token = process.env.WORDS_GITHUB_TOKEN;
26+
if (!token) {
27+
return NextResponse.json(
28+
{ error: "WORDS_GITHUB_TOKEN is not configured" },
29+
{ status: 500 },
30+
);
31+
}
32+
33+
let body: { body?: string; visibility?: string; title?: string } = {};
34+
try {
35+
body = await request.json();
36+
} catch {
37+
return NextResponse.json({ error: "Bad request" }, { status: 400 });
38+
}
39+
40+
const text = String(body.body ?? "").trim();
41+
if (!text) {
42+
return NextResponse.json({ error: "Body is required" }, { status: 400 });
43+
}
44+
45+
const wordCount = countWords(text);
46+
if (wordCount < WORDS_GOAL) {
47+
return NextResponse.json(
48+
{ error: `Need at least ${WORDS_GOAL} words (got ${wordCount})` },
49+
{ status: 400 },
50+
);
51+
}
52+
53+
const visibility: WordVisibility =
54+
body.visibility === "private" ? "private" : "public";
55+
const title = body.title ? String(body.title).trim() || null : null;
56+
57+
const now = new Date();
58+
const slug = buildWordSlug(now);
59+
const date = slug.slice(0, 10);
60+
const time = `${slug.slice(11, 13)}:${slug.slice(13, 15)}`;
61+
const path = `_words/${slug}.mdx`;
62+
63+
const frontmatter = buildFrontmatter({
64+
date,
65+
time,
66+
wordCount,
67+
visibility,
68+
title,
69+
});
70+
const fileContents = `${frontmatter}${text}\n`;
71+
72+
const octokit = new Octokit({ auth: token });
73+
const message = `words: ${date} ${time}${title ? ` — ${title}` : ""}`;
74+
75+
try {
76+
const { data } = await octokit.rest.repos.createOrUpdateFileContents({
77+
owner: REPO_OWNER,
78+
repo: REPO_NAME,
79+
path,
80+
message,
81+
content: Buffer.from(fileContents, "utf8").toString("base64"),
82+
branch: REPO_BRANCH,
83+
});
84+
85+
return NextResponse.json({
86+
ok: true,
87+
slug,
88+
commitUrl: data.commit.html_url,
89+
});
90+
} catch (err) {
91+
const msg = err instanceof Error ? err.message : "Unknown error";
92+
return NextResponse.json(
93+
{ error: `GitHub commit failed: ${msg}` },
94+
{ status: 500 },
95+
);
96+
}
97+
}

src/lib/words-auth.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { jwtVerify, SignJWT } from "jose";
2+
3+
const COOKIE_NAME = "words_session";
4+
const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 90;
5+
6+
function getSecret(): Uint8Array {
7+
const secret = process.env.WORDS_SESSION_SECRET;
8+
if (!secret) {
9+
throw new Error("WORDS_SESSION_SECRET is not set");
10+
}
11+
if (secret.length < 32) {
12+
throw new Error("WORDS_SESSION_SECRET must be at least 32 chars");
13+
}
14+
return new TextEncoder().encode(secret);
15+
}
16+
17+
export async function createSessionToken(): Promise<string> {
18+
return new SignJWT({ sub: "owner" })
19+
.setProtectedHeader({ alg: "HS256" })
20+
.setIssuedAt()
21+
.setExpirationTime(`${COOKIE_MAX_AGE_SECONDS}s`)
22+
.sign(getSecret());
23+
}
24+
25+
export async function verifySessionToken(token: string): Promise<boolean> {
26+
try {
27+
await jwtVerify(token, getSecret(), { algorithms: ["HS256"] });
28+
return true;
29+
} catch {
30+
return false;
31+
}
32+
}
33+
34+
export function buildSessionCookie(token: string): string {
35+
const parts = [
36+
`${COOKIE_NAME}=${token}`,
37+
"Path=/",
38+
"HttpOnly",
39+
"SameSite=Lax",
40+
`Max-Age=${COOKIE_MAX_AGE_SECONDS}`,
41+
];
42+
if (process.env.NODE_ENV === "production") {
43+
parts.push("Secure");
44+
}
45+
return parts.join("; ");
46+
}
47+
48+
export function buildClearCookie(): string {
49+
const parts = [
50+
`${COOKIE_NAME}=`,
51+
"Path=/",
52+
"HttpOnly",
53+
"SameSite=Lax",
54+
"Max-Age=0",
55+
];
56+
if (process.env.NODE_ENV === "production") {
57+
parts.push("Secure");
58+
}
59+
return parts.join("; ");
60+
}
61+
62+
export function readSessionCookieFromHeader(
63+
cookieHeader: string | undefined | null,
64+
): string | null {
65+
if (!cookieHeader) return null;
66+
const match = cookieHeader
67+
.split(/;\s*/)
68+
.map((c) => {
69+
const eq = c.indexOf("=");
70+
return eq === -1
71+
? null
72+
: { name: c.slice(0, eq), value: c.slice(eq + 1) };
73+
})
74+
.find((c) => c?.name === COOKIE_NAME);
75+
return match ? match.value : null;
76+
}
77+
78+
export async function isAuthedFromCookieHeader(
79+
cookieHeader: string | undefined | null,
80+
): Promise<boolean> {
81+
const token = readSessionCookieFromHeader(cookieHeader);
82+
if (!token) return false;
83+
return verifySessionToken(token);
84+
}
85+
86+
export const WORDS_COOKIE_NAME = COOKIE_NAME;

src/navigation/Nav.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ const Nav = () => (
3737
<NavLink href="/now" className="text-sm sm:text-base">
3838
/now
3939
</NavLink>
40+
<NavLink href="/words" className="text-sm sm:text-base">
41+
/words
42+
</NavLink>
4043
<NavLink href="/shelf" className="text-sm sm:text-base">
4144
/shelf
4245
</NavLink>

src/pages/words/[slug].tsx

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import React from "react";
2+
3+
import { format } from "date-fns";
4+
import { GetServerSideProps } from "next";
5+
import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote";
6+
import { serialize } from "next-mdx-remote/serialize";
7+
import Link from "next/link";
8+
import remarkGfm from "remark-gfm";
9+
10+
import { Meta } from "layout/Meta";
11+
import { isAuthedFromCookieHeader } from "lib/words-auth";
12+
import { Main } from "templates/Main";
13+
14+
import Container from "components/Container";
15+
import { ExternalLink } from "components/ExternalLink";
16+
17+
import { getWordBySlug } from "utils/Words";
18+
import { WordVisibility } from "utils/words-shared";
19+
20+
type Props = {
21+
slug: string;
22+
date: string;
23+
time: string;
24+
wordCount: number;
25+
visibility: WordVisibility;
26+
title: string | null;
27+
mdxSource: MDXRemoteSerializeResult;
28+
};
29+
30+
const WordPage = (props: Props) => {
31+
const dateLabel = props.date
32+
? format(new Date(`${props.date}T00:00:00Z`), "EEEE d MMMM yyyy")
33+
: "";
34+
const headingTitle = props.title || dateLabel;
35+
const metaTitle = props.title
36+
? `${props.title} · /words`
37+
: `/words/${props.slug}`;
38+
39+
return (
40+
<Main
41+
className="text-lg py-4 md:py-8 lg:py-16"
42+
meta={<Meta title={metaTitle} description="A daily writing entry" />}
43+
>
44+
<Container maxWidth="prose">
45+
<p className="mb-2">
46+
<Link href="/words">← Words</Link>
47+
</p>
48+
<div className="text-sm opacity-60 mb-1">
49+
{dateLabel} · {props.time} · {props.wordCount} words
50+
{props.visibility === "private" && (
51+
<span className="ml-2 uppercase tracking-wide">private</span>
52+
)}
53+
</div>
54+
<h1 className="mb-6">{headingTitle}</h1>
55+
<div className="blog-post c_blog-content">
56+
<MDXRemote {...props.mdxSource} components={{ a: ExternalLink }} />
57+
</div>
58+
</Container>
59+
</Main>
60+
);
61+
};
62+
63+
export const getServerSideProps: GetServerSideProps<Props> = async ({
64+
params,
65+
req,
66+
res,
67+
}) => {
68+
const slug = String(params?.slug ?? "");
69+
const entry = getWordBySlug(slug);
70+
if (!entry) {
71+
return { notFound: true };
72+
}
73+
74+
if (entry.visibility === "private") {
75+
const authed = await isAuthedFromCookieHeader(req.headers.cookie);
76+
if (!authed) {
77+
return { notFound: true };
78+
}
79+
res.setHeader("Cache-Control", "no-store");
80+
}
81+
82+
const mdxSource = await serialize(entry.content, {
83+
mdxOptions: { remarkPlugins: [remarkGfm] },
84+
});
85+
86+
return {
87+
props: {
88+
slug: entry.slug,
89+
date: entry.date,
90+
time: entry.time,
91+
wordCount: entry.wordCount,
92+
visibility: entry.visibility,
93+
title: entry.title,
94+
mdxSource,
95+
},
96+
};
97+
};
98+
99+
export default WordPage;

0 commit comments

Comments
 (0)