Compare commits

...

24 Commits

Author SHA1 Message Date
614adcb9f8 use UK date format
All checks were successful
Build / build (push) Successful in 3m5s
2025-04-22 13:25:28 +02:00
a5b8e40761 wording
All checks were successful
Build / build (push) Successful in 3m9s
2025-04-22 12:56:20 +02:00
e56bb8ac1c fx
Some checks failed
Build / build (push) Has been cancelled
2025-04-22 12:55:10 +02:00
d9e9824146 cleanup tags on edit
All checks were successful
Build / build (push) Successful in 3m11s
2025-04-22 12:48:23 +02:00
8b46bf354e ensure login for clipboard
All checks were successful
Build / build (push) Successful in 4m33s
2025-04-22 12:22:06 +02:00
86ec00de4c fix
All checks were successful
Build / build (push) Successful in 10s
2025-04-08 00:40:18 +02:00
49917e1eb0 deploy db
All checks were successful
Build / build (push) Successful in 16s
2025-04-08 00:36:11 +02:00
edc575b153 add clipboard
All checks were successful
Build / build (push) Successful in 3m28s
2025-04-08 00:23:36 +02:00
f28c2defc4 test
Some checks failed
Build / build (push) Failing after 1m30s
2025-04-05 22:32:49 +02:00
42c8a9e28a test 2025-04-05 22:15:21 +02:00
5a3ce73ce8 test
Some checks failed
Build / build (push) Failing after 15m24s
2025-04-05 22:14:18 +02:00
82ce4b345a couple more fixes
All checks were successful
Build / build (push) Successful in 2m25s
2025-04-05 01:12:38 +02:00
f640afbcb1 fix
All checks were successful
Build / build (push) Successful in 2m30s
2025-04-05 00:30:22 +02:00
952d9541b9 fix
All checks were successful
Build / build (push) Successful in 2m28s
2025-04-05 00:04:56 +02:00
a223fc709e fix
All checks were successful
Build / build (push) Successful in 2m26s
2025-04-04 23:47:04 +02:00
64e5fd3354 fix
All checks were successful
Build / build (push) Successful in 2m27s
2025-04-04 23:38:08 +02:00
434536d1f2 use authentik for auth
All checks were successful
Build / build (push) Successful in 3m31s
2025-04-04 23:29:57 +02:00
4656df7fca fix wrong env name
All checks were successful
Build / build (push) Successful in 3m5s
2025-04-03 13:25:44 +02:00
c96525e1fc unused 2025-04-03 13:25:00 +02:00
4406de6986 move SubmitContact closer 2025-04-03 13:24:54 +02:00
5c139e83f5 update home text
Some checks failed
Build / build (push) Has been cancelled
2025-04-03 13:21:58 +02:00
3fc41e6fc4 fix width 2025-04-03 13:21:29 +02:00
b1422269bb use breaks in markdown
All checks were successful
Build / build (push) Successful in 2m32s
2025-04-03 00:20:47 +02:00
dcde0cb893 oops quick fix
All checks were successful
Build / build (push) Successful in 2m33s
2025-04-03 00:14:33 +02:00
31 changed files with 434 additions and 335 deletions

View File

@ -16,6 +16,7 @@ RUN cp -r .next/static .next/standalone/.next/
FROM node:current-alpine AS production FROM node:current-alpine AS production
COPY --from=build /app/.next/standalone /app COPY --from=build /app/.next/standalone /app
COPY --from=build /app/prisma /app/prisma
EXPOSE 3000 EXPOSE 3000
WORKDIR /app WORKDIR /app
CMD ["node", "server.js"] CMD ["/bin/sh", "-c", "npx --yes prisma migrate deploy && node server.js"]

149
package-lock.json generated
View File

@ -13,10 +13,9 @@
"argon2": "^0.41.1", "argon2": "^0.41.1",
"cheerio": "^1.0.0", "cheerio": "^1.0.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"jose": "^6.0.10",
"lucide-react": "^0.485.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"next": "15.2.4", "next": "15.2.4",
"next-auth": "^5.0.0-beta.25",
"nodemailer": "^6.10.0", "nodemailer": "^6.10.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
@ -38,6 +37,46 @@
"typescript": "^5" "typescript": "^5"
} }
}, },
"node_modules/@auth/core": {
"version": "0.37.2",
"resolved": "https://registry.npmjs.org/@auth/core/-/core-0.37.2.tgz",
"integrity": "sha512-kUvzyvkcd6h1vpeMAojK2y7+PAV5H+0Cc9+ZlKYDFhDY31AlvsB+GW5vNO4qE3Y07KeQgvNO9U0QUx/fN62kBw==",
"license": "ISC",
"dependencies": {
"@panva/hkdf": "^1.2.1",
"@types/cookie": "0.6.0",
"cookie": "0.7.1",
"jose": "^5.9.3",
"oauth4webapi": "^3.0.0",
"preact": "10.11.3",
"preact-render-to-string": "5.2.3"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"nodemailer": "^6.8.0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/@auth/core/node_modules/jose": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz",
"integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@babel/runtime": { "node_modules/@babel/runtime": {
"version": "7.27.0", "version": "7.27.0",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.0.tgz",
@ -862,6 +901,15 @@
"node": ">=12.4.0" "node": ">=12.4.0"
} }
}, },
"node_modules/@panva/hkdf": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
"integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/@phc/format": { "node_modules/@phc/format": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
@ -933,6 +981,12 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
@ -2028,6 +2082,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -3875,15 +3938,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/jose": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.10.tgz",
"integrity": "sha512-skIAxZqcMkOrSwjJvplIPYrlXGpxTPnro2/QWTDCxAdWQrSTV5/KqspMWmi5WAx5+ULswASJiZ0a+1B/Lxt9cw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -4041,15 +4095,6 @@
"loose-envify": "cli.js" "loose-envify": "cli.js"
} }
}, },
"node_modules/lucide-react": {
"version": "0.485.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.485.0.tgz",
"integrity": "sha512-NvyQJ0LKyyCxL23nPKESlr/jmz8r7fJO1bkuptSNYSy0s8VVj4ojhX0YAgmE1e0ewfxUZjIlZpvH+otfTnla8Q==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/markdown-it": { "node_modules/markdown-it": {
"version": "14.1.0", "version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
@ -4216,6 +4261,33 @@
} }
} }
}, },
"node_modules/next-auth": {
"version": "5.0.0-beta.25",
"resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.25.tgz",
"integrity": "sha512-2dJJw1sHQl2qxCrRk+KTQbeH+izFbGFPuJj5eGgBZFYyiYYtvlrBeUw1E/OJJxTRjuxbSYGnCTkUIRsIIW0bog==",
"license": "ISC",
"dependencies": {
"@auth/core": "0.37.2"
},
"peerDependencies": {
"@simplewebauthn/browser": "^9.0.1",
"@simplewebauthn/server": "^9.0.2",
"next": "^14.0.0-0 || ^15.0.0-0",
"nodemailer": "^6.6.5",
"react": "^18.2.0 || ^19.0.0-0"
},
"peerDependenciesMeta": {
"@simplewebauthn/browser": {
"optional": true
},
"@simplewebauthn/server": {
"optional": true
},
"nodemailer": {
"optional": true
}
}
},
"node_modules/node-addon-api": { "node_modules/node-addon-api": {
"version": "8.3.1", "version": "8.3.1",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.1.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.3.1.tgz",
@ -4257,6 +4329,15 @@
"url": "https://github.com/fb55/nth-check?sponsor=1" "url": "https://github.com/fb55/nth-check?sponsor=1"
} }
}, },
"node_modules/oauth4webapi": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.4.0.tgz",
"integrity": "sha512-5lcbectYuzQHvh0Ni7Epvc13sMVq7BxWUlHEYHaNko64OA1hcats0Huq30vZjqCZULcVE/PZxAGGPansfRAWKQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -4581,6 +4662,28 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/preact": {
"version": "10.11.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
"integrity": "sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/preact-render-to-string": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-5.2.3.tgz",
"integrity": "sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==",
"license": "MIT",
"dependencies": {
"pretty-format": "^3.8.0"
},
"peerDependencies": {
"preact": ">=10"
}
},
"node_modules/prelude-ls": { "node_modules/prelude-ls": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@ -4591,6 +4694,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/pretty-format": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz",
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
"license": "MIT"
},
"node_modules/prop-types": { "node_modules/prop-types": {
"version": "15.8.1", "version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",

View File

@ -14,10 +14,9 @@
"argon2": "^0.41.1", "argon2": "^0.41.1",
"cheerio": "^1.0.0", "cheerio": "^1.0.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"jose": "^6.0.10",
"lucide-react": "^0.485.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"next": "15.2.4", "next": "15.2.4",
"next-auth": "^5.0.0-beta.25",
"nodemailer": "^6.10.0", "nodemailer": "^6.10.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",

View File

@ -0,0 +1,7 @@
-- CreateTable
CREATE TABLE "Clipboard" (
"id" SERIAL NOT NULL,
"content" TEXT NOT NULL,
CONSTRAINT "Clipboard_pkey" PRIMARY KEY ("id")
);

View File

@ -30,3 +30,8 @@ model User {
username String @unique username String @unique
password String password String
} }
model Clipboard {
id Int @id @default(autoincrement())
content String @db.Text
}

View File

@ -1,16 +1,14 @@
/* eslint-disable react/no-unescaped-entities */
export default function About() { export default function About() {
return ( return (
<> <>
so hey, i'm naresh! so hey, i&apos;m naresh!
<br /> <br />
<br /> <br />
i'm an engineer and i love building things and tinkering on things. i&apos;m an engineer and i love building things and tinkering on things.
<br /> <br />
<br /> i like going on runs and going for rides on my bike. <br /> i like going on runs and going for rides on my bike.
<br /> <br />
<br /> umm, maybe i'll write more here later. <br /> umm, maybe i&apos;ll write more here later.
</> </>
); );
} }

View File

@ -0,0 +1,2 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View File

@ -0,0 +1,38 @@
"use client";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
export default function ActionButtons({ loggedIn }: { loggedIn: boolean }) {
const router = useRouter();
return loggedIn ? (
<div
style={{
marginTop: "2rem",
padding: "0.5rem 1rem",
backgroundColor: "#333",
width: "fit-content",
userSelect: "none",
cursor: "pointer",
}}
onClick={() => router.push("/blog/write")}
>
write a post
</div>
) : (
<div
style={{
marginTop: "2rem",
padding: "0.3rem 0.5rem",
backgroundColor: "#333",
width: "fit-content",
userSelect: "none",
cursor: "pointer",
fontSize: "0.8rem",
}}
onClick={() => signIn()}
>
login
</div>
);
}

View File

@ -114,7 +114,9 @@ export default function PostSummary({
<div> <div>
<div> <div>
<span>published: </span> <span>published: </span>
<span>{metadata.publishedDate.toLocaleDateString()}</span> <span>
{metadata.publishedDate.toLocaleDateString("en-UK")}
</span>
</div> </div>
{loggedIn && ( {loggedIn && (
<div <div
@ -191,36 +193,6 @@ export default function PostSummary({
</span> </span>
</div> </div>
</div> </div>
{loggedIn ? (
<div
style={{
marginTop: "2rem",
padding: "0.5rem 1rem",
backgroundColor: "#333",
width: "fit-content",
userSelect: "none",
cursor: "pointer",
}}
onClick={() => router.push("/blog/write")}
>
write a post
</div>
) : (
<div
style={{
marginTop: "2rem",
padding: "0.3rem 0.5rem",
backgroundColor: "#333",
width: "fit-content",
userSelect: "none",
cursor: "pointer",
fontSize: "0.8rem",
}}
onClick={() => router.push("/blog/login")}
>
login
</div>
)}
</> </>
); );
} }

View File

@ -7,7 +7,8 @@ import React from "react";
import PostSummary from "./PostSummary"; import PostSummary from "./PostSummary";
import Pagination from "./Pagination"; import Pagination from "./Pagination";
import TagOverview from "./TagOverview"; import TagOverview from "./TagOverview";
import { isLoggedIn } from "@/components/auth"; import ActionButtons from "./ActionButtons";
import { auth } from "@/auth";
export default async function Blog({ export default async function Blog({
params, params,
@ -19,6 +20,7 @@ export default async function Blog({
const pageNumber = await getPageNumber(searchParams); const pageNumber = await getPageNumber(searchParams);
const { tag } = await params; const { tag } = await params;
const currentTag = tag != null && tag.length >= 1 ? tag[0] : null; const currentTag = tag != null && tag.length >= 1 ? tag[0] : null;
if ((tag?.length ?? 0) > 1) { if ((tag?.length ?? 0) > 1) {
notFound(); notFound();
} }
@ -27,10 +29,8 @@ export default async function Blog({
pageNumber, pageNumber,
currentTag currentTag
); );
if (pageNumber > numberOfPages) {
notFound(); const loggedIn = (await auth())?.user != null;
}
const loggedIn = await isLoggedIn();
const tags = await getTags(loggedIn); const tags = await getTags(loggedIn);
return ( return (
@ -45,13 +45,24 @@ export default async function Blog({
<span style={{ fontSize: 18 }}>naresh writes</span> <span style={{ fontSize: 18 }}>naresh writes</span>
<span style={{ fontSize: 12 }}>...occasionally</span> <span style={{ fontSize: 12 }}>...occasionally</span>
</div> </div>
<TagOverview tags={tags} currentTag={currentTag} /> {pageNumber > numberOfPages ? (
{metadata <div style={{ marginTop: "1rem" }}>
.filter((m) => loggedIn || !m.is_draft) ah fuck...
.map((m) => ( <br /> it seems like i haven&apos;t written anything yet
<PostSummary metadata={m} key={m.slug} loggedIn={loggedIn} /> <br /> so much for a blog, eh?
))} </div>
<Pagination numberOfPages={numberOfPages} pageNumber={pageNumber} /> ) : (
<>
<TagOverview tags={tags} currentTag={currentTag} />
{metadata
.filter((m) => loggedIn || !m.is_draft)
.map((m) => (
<PostSummary metadata={m} key={m.slug} loggedIn={loggedIn} />
))}
<Pagination numberOfPages={numberOfPages} pageNumber={pageNumber} />
</>
)}
<ActionButtons loggedIn={loggedIn} />
</> </>
); );
} }

View File

@ -1,74 +0,0 @@
"use client";
import Form from "next/form";
import { handleLogin } from "./action";
export default function FormComponent() {
return (
<Form action={handleLogin}>
<div
style={{
display: "flex",
flexDirection: "column",
gap: "1rem",
fontSize: 14,
}}
>
<div style={{ display: "flex", flexDirection: "column" }}>
Username:
<input
type="text"
style={{
height: "2rem",
backgroundColor: "#333",
borderStyle: "none",
color: "#eee",
fontSize: 16,
padding: "0.5rem 1rem",
}}
name="username"
required
/>
</div>
<div style={{ display: "flex", flexDirection: "column" }}>
Password:
<input
type="password"
style={{
height: "2rem",
backgroundColor: "#333",
borderStyle: "none",
color: "#eee",
fontSize: 16,
padding: "0.5rem 1rem",
}}
name="password"
required
/>
</div>
<div>
<button
style={{
backgroundColor: "#333",
borderStyle: "none",
color: "#eee",
fontSize: "1rem",
padding: "0.5rem 1rem",
transition: "background-color 0.3s linear",
cursor: "pointer",
}}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = "#444";
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = "#333";
}}
type="submit"
>
Login
</button>
</div>
</div>
</Form>
);
}

View File

@ -1,27 +0,0 @@
"use server";
import { PrismaClient } from "@prisma/client";
import argon2 from "argon2";
import { setSession } from "@/components/auth";
import { redirect, RedirectType } from "next/navigation";
export async function handleLogin(data: FormData) {
const prisma = new PrismaClient();
const username = data.get("username")?.toString();
const password = data.get("password")?.toString();
if (!username || !password) {
throw new Error("Missing username or password");
}
const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
redirect("/blog/login?error=Invalid%20credentials", RedirectType.replace);
}
if (await argon2.verify(user.password, password)) {
setSession();
redirect("/blog/write", RedirectType.replace);
} else {
redirect("/blog/login?error=Invalid%20credentials", RedirectType.replace);
}
}

View File

@ -1,12 +0,0 @@
"use server";
import { redirect } from "next/navigation";
import { isLoggedIn } from "@/components/auth";
import FormComponent from "./Form";
export default async function Login() {
if (await isLoggedIn()) {
redirect("/blog/write");
}
return <FormComponent />;
}

View File

@ -100,7 +100,9 @@ export default function PostDisplay({
<div> <div>
<div> <div>
<span>published: </span> <span>published: </span>
<span>{post.publishedDate.toLocaleDateString()}</span> <span>
{post.publishedDate.toLocaleDateString("en-UK")}
</span>
</div> </div>
{loggedIn && ( {loggedIn && (
<div <div

View File

@ -1,7 +1,7 @@
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { getPost } from "../../action"; import { getPost } from "../../action";
import PostDisplay from "./PostDisplay"; import PostDisplay from "./PostDisplay";
import { isLoggedIn } from "@/components/auth"; import { auth } from "@/auth";
export default async function Post({ export default async function Post({
params, params,
@ -13,7 +13,7 @@ export default async function Post({
if (!post) { if (!post) {
notFound(); notFound();
} }
const loggedIn = await isLoggedIn(); const loggedIn = (await auth())?.user != null;
return ( return (
<> <>
<PostDisplay post={post} loggedIn={loggedIn} /> <PostDisplay post={post} loggedIn={loggedIn} />

View File

@ -6,6 +6,7 @@ import { KeyboardEvent } from "react";
const md = new MarkdownIt({ const md = new MarkdownIt({
linkify: true, linkify: true,
typographer: true, typographer: true,
breaks: true,
highlight: (str, lang) => { highlight: (str, lang) => {
if (lang && hljs.getLanguage(lang)) { if (lang && hljs.getLanguage(lang)) {
try { try {

View File

@ -1,16 +1,16 @@
import { notFound, redirect } from "next/navigation"; import { notFound } from "next/navigation";
import { Post } from "../../types"; import { Post } from "../../types";
import Write from "../Write"; import Write from "../Write";
import { isLoggedIn } from "@/components/auth";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { auth, signIn } from "@/auth";
export default async function WritePage({ export default async function WritePage({
params, params,
}: { }: {
params: Promise<{ slug: string[] | undefined }>; params: Promise<{ slug: string[] | undefined }>;
}) { }) {
if (!(await isLoggedIn())) { if ((await auth())?.user == null) {
redirect("/blog/login"); await signIn();
} }
const slug = (await params).slug?.[0]; const slug = (await params).slug?.[0];

View File

@ -5,6 +5,7 @@ import MarkdownIt from "markdown-it";
import hljs from "highlight.js"; import hljs from "highlight.js";
import * as cheerio from "cheerio"; import * as cheerio from "cheerio";
import slugify from "slugify"; import slugify from "slugify";
import { auth } from "@/auth";
export async function savePostServer( export async function savePostServer(
title: string, title: string,
@ -13,11 +14,16 @@ export async function savePostServer(
is_draft: boolean, is_draft: boolean,
existingSlug?: string existingSlug?: string
) { ) {
if ((await auth())?.user == null) {
throw new Error("Not authenticated");
}
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const md = new MarkdownIt({ const md = new MarkdownIt({
linkify: true, linkify: true,
typographer: true, typographer: true,
breaks: true,
highlight: (str, lang) => { highlight: (str, lang) => {
if (lang && hljs.getLanguage(lang)) { if (lang && hljs.getLanguage(lang)) {
try { try {
@ -33,7 +39,22 @@ export async function savePostServer(
const slug = slugify(title, { lower: true, strict: true }); const slug = slugify(title, { lower: true, strict: true });
if (existingSlug) { if (existingSlug) {
const post = await prisma.post.findUnique({
where: { slug: existingSlug },
include: { tags: true },
});
if (!post) {
throw new Error("Post not found");
}
await prisma.post.delete({ where: { slug: existingSlug } }); await prisma.post.delete({ where: { slug: existingSlug } });
for (const tag of post.tags) {
const postsWithTag = await prisma.post.count({
where: { tags: { some: { id: tag.id } } },
});
if (postsWithTag == 0) {
await prisma.tag.delete({ where: { id: tag.id } });
}
}
} }
await prisma.post.create({ await prisma.post.create({

View File

@ -0,0 +1,46 @@
"use client";
import { useState } from "react";
import { updateClipboard } from "./action";
export default function ClipboardComponent({
initialContent,
}: {
initialContent: string;
}) {
const [clipboardContent, setClipboardContent] = useState(initialContent);
const [typingTimeout, setTypingTimeout] = useState<NodeJS.Timeout | null>(
null
);
const contentChanged = (content: string) => {
setClipboardContent(content);
if (typingTimeout) {
clearTimeout(typingTimeout);
}
const timeout = setTimeout(async () => {
await updateClipboard(content);
}, 500);
setTypingTimeout(timeout);
};
return (
<>
<textarea
style={{
width: "100%",
resize: "none",
borderStyle: "none",
backgroundColor: "#333",
height: "20rem",
maxHeight: "80vh",
color: "#eee",
}}
onChange={(e) => contentChanged(e.target.value)}
onBlur={async (e) => {
await updateClipboard(e.target.value);
}}
value={clipboardContent}
></textarea>
</>
);
}

View File

@ -0,0 +1,19 @@
"use server";
import { PrismaClient } from "@prisma/client";
export async function updateClipboard(content: string) {
const prisma = new PrismaClient();
await prisma.clipboard.upsert({
where: { id: 1 },
update: { content },
create: { content },
});
}
export async function getClipboard(): Promise<string> {
const prisma = new PrismaClient();
const clipboard = await prisma.clipboard.findUnique({
where: { id: 1 },
});
return clipboard?.content || "";
}

View File

@ -0,0 +1,18 @@
"use server";
import { auth, signIn } from "@/auth";
import { getClipboard } from "./action";
import ClipboardComponent from "./ClipboardComponent";
export default async function ClipboardPage() {
if ((await auth())?.user == null) {
await signIn();
}
const clipboard = await getClipboard();
return (
<>
<ClipboardComponent initialContent={clipboard} />
</>
);
}

View File

@ -1,4 +1,80 @@
"use server"; "use server";
import { createTransport } from "nodemailer";
export async function getReCaptchaSiteKey() { export async function getReCaptchaSiteKey() {
return process.env.RECAPTCHA_SITE_KEY; return process.env.RECAPTCHA_SITE_KEY;
} }
const RECAPTCHA_SERVER_KEY = process.env.RECAPTCHA_SERVER_KEY;
export type SubmitContactReturn = { error: string } | { success: true };
export default async function SubmitContact(
prevState: unknown,
data: FormData
): Promise<SubmitContactReturn> {
if (!RECAPTCHA_SERVER_KEY) {
console.error(
"RECAPTCHA_SERVER_KEY is not set. Please check your environment variables."
);
throw new Error("Server error: RECAPTCHA not configure correctly.");
}
const name = data.get("name");
const email = data.get("email");
const message = data.get("message");
const recaptcha = data.get("g-recaptcha-response");
if (!name || !email || !message) {
return { error: "All fields are required." };
}
if (!recaptcha) {
return { error: "Please complete the reCAPTCHA." };
}
try {
const recaptchaResponse = await fetch(
`https://www.google.com/recaptcha/api/siteverify`,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: `secret=${RECAPTCHA_SERVER_KEY}&response=${recaptcha}`,
}
);
const recaptchaData = await recaptchaResponse.json();
if (!recaptchaData.success) {
return { error: "reCAPTCHA verification failed." };
}
} catch (e) {
console.error(e);
return { error: "Could not reach reCAPTCHA for verification." };
}
try {
const transporter = createTransport({
host: process.env.MAIL_HOST ?? "localhost",
port: process.env.MAIL_PORT ? parseInt(process.env.MAIL_PORT) : undefined,
secure: true,
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASS,
},
});
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: process.env.MAIL_TO,
subject: `From ${name}`,
replyTo: email.toString(),
text: `Name:${name}\nEmail: ${email}\nMessage:\n ${message}`,
});
} catch (e) {
console.error(e);
return { error: "Failed to send email." };
}
return { success: true };
}

View File

@ -1,9 +1,8 @@
"use client"; "use client";
import SubmitContact from "@/components/contact";
import { useActionState, useEffect, useState } from "react"; import { useActionState, useEffect, useState } from "react";
import ReCAPTCHA from "react-google-recaptcha"; import ReCAPTCHA from "react-google-recaptcha";
import { getReCaptchaSiteKey } from "./action"; import SubmitContact, { getReCaptchaSiteKey } from "./action";
export default function ContactPage() { export default function ContactPage() {
const [recaptchaSiteKey, setRecaptchaSiteKey] = useState<string | undefined>( const [recaptchaSiteKey, setRecaptchaSiteKey] = useState<string | undefined>(

View File

@ -4,17 +4,20 @@ import Title from "@/components/Title";
import Navbar from "@/components/Navbar"; import Navbar from "@/components/Navbar";
import { bodyFont } from "@/components/fonts"; import { bodyFont } from "@/components/fonts";
import Link from "next/link"; import Link from "next/link";
import { auth } from "@/auth";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "nrx.sh", title: "nrx.sh",
description: "naresh's site", description: "naresh's site",
}; };
export default function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const isLoggedIn = (await auth())?.user != null;
return ( return (
<html lang="en"> <html lang="en">
<body> <body>
@ -28,7 +31,7 @@ export default function RootLayout({
> >
<Title /> <Title />
<Navbar /> <Navbar isLoggedIn={isLoggedIn} />
<div <div
className={bodyFont.className} className={bodyFont.className}
style={{ style={{
@ -49,10 +52,11 @@ export default function RootLayout({
marginTop: "2rem", marginTop: "2rem",
textAlign: "center", textAlign: "center",
color: "#888", color: "#888",
maxWidth: "80vw",
}} }}
className={bodyFont.className} className={bodyFont.className}
> >
this site is built from scratch using <b>next.js</b> - it is&nbsp; i built this site from scratch using <b>next.js</b> - it is&nbsp;
<Link <Link
style={{ color: "#88f" }} style={{ color: "#88f" }}
href={"https://git.nrx.sh/naresh/nrx.sh"} href={"https://git.nrx.sh/naresh/nrx.sh"}

View File

@ -1,5 +1,3 @@
/* eslint-disable react/no-unescaped-entities */
import { titleFont } from "@/components/fonts"; import { titleFont } from "@/components/fonts";
import Link from "next/link"; import Link from "next/link";
@ -11,11 +9,11 @@ export default function NotFound() {
</span> </span>
<br /> <br />
<br /> <br />
shit... something is fucked and now you're here shit... something is fucked and now you&apos;re here
<br /> <br />
i'll look into it, at some point i&apos;ll look into it, at some point
<br /> <br />
but for now, maybe you'd like to but for now, maybe you&apos;d like to
<br /> <br />
<br /> <br />
<Link <Link

View File

@ -1,5 +1,3 @@
/* eslint-disable react/no-unescaped-entities */
"use client"; "use client";
import React from "react"; import React from "react";
@ -9,12 +7,14 @@ export default function App() {
<> <>
hi, hi,
<br /> <br />
<br /> thanks for stopping by. <br /> how nice of you to stop by.
<br /> <br />
<br /> this is still a work in progress, so it's a little sparse. <br /> this cute little site is my own little playground. my own corner of
the web for me to share and show off.
<br /> <br />
<br /> feel free to poke around and see what you find.
<br /> <br />
<br /> naresh. <br /> &mdash; naresh
</> </>
); );
} }

6
src/auth.ts Normal file
View File

@ -0,0 +1,6 @@
import NextAuth from "next-auth";
import Authentik from "next-auth/providers/authentik";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [Authentik],
});

View File

@ -4,6 +4,7 @@ import { useState } from "react";
import React from "react"; import React from "react";
import { navBarFont } from "./fonts"; import { navBarFont } from "./fonts";
import { import {
ADMIN_PAGES,
PAGES, PAGES,
Pages, Pages,
pathNameFromSelectedPage, pathNameFromSelectedPage,
@ -11,7 +12,7 @@ import {
} from "./pages"; } from "./pages";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
export default function Navbar() { export default function Navbar({ isLoggedIn }: { isLoggedIn: boolean }) {
const [hoveredPage, setHoveredPage] = useState<Pages | null>(null); const [hoveredPage, setHoveredPage] = useState<Pages | null>(null);
const pathName = usePathname(); const pathName = usePathname();
@ -41,7 +42,10 @@ export default function Navbar() {
justifyContent: "center", justifyContent: "center",
}} }}
> >
{PAGES.map((page, index) => ( {PAGES.filter((p) => {
if (!isLoggedIn && ADMIN_PAGES.includes(p)) return false;
return true;
}).map((page, index) => (
<React.Fragment key={page}> <React.Fragment key={page}>
<div <div
style={navbarItem(page)} style={navbarItem(page)}

View File

@ -1,49 +0,0 @@
"use server";
import { jwtVerify, SignJWT } from "jose";
import { cookies } from "next/headers";
const SECRET_KEY = process.env.SESSION_SECRET;
const encodedKey = new TextEncoder().encode(SECRET_KEY);
export type SessionPayload = { admin: true };
export async function encrypt(payload: SessionPayload) {
return new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(encodedKey);
}
export async function decrypt(
token: string | undefined = ""
): Promise<SessionPayload | null> {
try {
const { payload } = await jwtVerify(token, encodedKey, {
algorithms: ["HS256"],
});
return payload as SessionPayload;
} catch {
return null;
}
}
export async function isLoggedIn(): Promise<boolean> {
const cookieStore = (await cookies()).get("session")?.value;
const session = await decrypt(cookieStore);
if (session != null && session.admin) {
return true;
}
return false;
}
export async function setSession(): Promise<void> {
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
(await cookies()).set("session", await encrypt({ admin: true }), {
httpOnly: true,
secure: true,
expires: expiresAt,
sameSite: "lax",
path: "/",
});
}

View File

@ -1,76 +0,0 @@
"use server";
import { createTransport } from "nodemailer";
const RECAPTCHA_SECRET_KEY = process.env.RECAPTCHA_SECRET_KEY;
export type SubmitContactReturn = { error: string } | { success: true };
export default async function SubmitContact(
prevState: unknown,
data: FormData
): Promise<SubmitContactReturn> {
if (!RECAPTCHA_SECRET_KEY) {
console.error(
"RECAPTCHA_SECRET_KEY is not set. Please check your environment variables."
);
throw new Error("Server error: RECAPTCHA not configure correctly.");
}
const name = data.get("name");
const email = data.get("email");
const message = data.get("message");
const recaptcha = data.get("g-recaptcha-response");
if (!name || !email || !message) {
return { error: "All fields are required." };
}
if (!recaptcha) {
return { error: "Please complete the reCAPTCHA." };
}
try {
const recaptchaResponse = await fetch(
`https://www.google.com/recaptcha/api/siteverify`,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: `secret=${RECAPTCHA_SECRET_KEY}&response=${recaptcha}`,
}
);
const recaptchaData = await recaptchaResponse.json();
if (!recaptchaData.success) {
return { error: "reCAPTCHA verification failed." };
}
} catch (e) {
console.error(e);
return { error: "Could not reach reCAPTCHA for verification." };
}
try {
const transporter = createTransport({
host: process.env.MAIL_HOST ?? "localhost",
port: process.env.MAIL_PORT ? parseInt(process.env.MAIL_PORT) : undefined,
secure: true,
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASS,
},
});
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: process.env.MAIL_TO,
subject: `From ${name}`,
replyTo: email.toString(),
text: `Name:${name}\nEmail: ${email}\nMessage:\n ${message}`,
});
} catch (e) {
console.error(e);
return { error: "Failed to send email." };
}
return { success: true };
}

View File

@ -1,5 +1,6 @@
export type Pages = "home" | "about" | "links" | "contact" | "blog"; export type Pages = "home" | "about" | "links" | "contact" | "blog" | "clipboard";
export const PAGES: Pages[] = ["home", "about", "blog", "links", "contact"]; export const PAGES: Pages[] = ["home", "about", "blog", "links", "contact", "clipboard"];
export const ADMIN_PAGES: Pages[] = ["clipboard"]
export function selectedPageFromPathName(pathName: string): Pages { export function selectedPageFromPathName(pathName: string): Pages {
if (pathName === "/") { if (pathName === "/") {