diff --git a/.dockerignore b/.dockerignore index f84f32b..35b7e05 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,4 +6,5 @@ !package.json !yarn.lock !public +!prisma !src \ No newline at end of file diff --git a/.env b/.env index 14cb230..d89c8ba 100644 --- a/.env +++ b/.env @@ -1,2 +1,4 @@ NEXT_PUBLIC_GITEA_USERNAME=Guusvanmeerveld NEXT_PUBLIC_GITEA_SERVER=git.guusvanmeerveld.dev +DATABASE_URL=postgresql://portfolio:portfolio@localhost:5432/portfolio?schema=public +# ALLOW_REGISTRATION=true \ No newline at end of file diff --git a/.gitignore b/.gitignore index c81e972..efaa0a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,38 @@ -node_modules +# compiled output +/dist +/node_modules +/.next +**/migrations/ + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# OS +.DS_Store + +# Tests +/coverage +/.nyc_output + .env.local -.next -yarn-error.log \ No newline at end of file + +# IDEs and editors +/.idea +.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 3fe9a79..3289475 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,18 @@ -FROM node:alpine AS deps +FROM node:lts-alpine AS deps WORKDIR /app -COPY package.json yarn.lock ./ +COPY package.json yarn.lock prisma ./ +RUN npx prisma generate RUN yarn install --frozen-lockfile -FROM node:alpine AS builder +FROM node:lts-alpine AS builder WORKDIR /app COPY . . COPY --from=deps /app/node_modules ./node_modules ENV NEXT_TELEMETRY_DISABLED 1; RUN yarn build && yarn install --production --ignore-scripts --prefer-offline -FROM node:alpine AS runner +FROM node:lts-alpine AS runner WORKDIR /app ENV NODE_ENV production @@ -24,6 +25,7 @@ COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/package.json ./package.json +COPY --from=builder /app/prisma ./prisma USER nextjs diff --git a/docker-compose.yml b/docker-compose.yml index 49587d1..fc29a83 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,23 @@ version: "3" services: app: build: . - container_name: app + container_name: portfolio + environment: + DATABASE_URL: "postgres://portfolio:portfolio@db:5432/portfolio" ports: - 3000:3000 + + db: + image: postgres:14 + container_name: portfolio-db + environment: + POSTGRES_USER: "portfolio" + POSTGRES_PASSWORD: "portfolio" + POSTGRES_DB: "portfolio" + ports: + - 5432:5432 + volumes: + - db:/var/lib/postgresql/data + +volumes: + db: \ No newline at end of file diff --git a/package.json b/package.json index 109a821..1fd3935 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,11 @@ "full-test": "yarn test-build && yarn lint && yarn stylelint" }, "dependencies": { + "@prisma/client": "4.10.1", "axios": "^1.1.2", + "bcrypt": "^5.1.0", "configcat-node": "^8.0.0", + "iron-session": "^6.3.1", "next": "^12.1.0", "next-seo": "^4.24.0", "next-themes": "^0.2.1", @@ -23,12 +26,14 @@ "zod": "^3.20.6" }, "devDependencies": { + "@types/bcrypt": "^5.0.0", "@types/node": "^15.12.1", "@types/react": "^17.0.9", "@types/react-dom": "^17.0.6", "eslint": "^7.28.0", "eslint-config-next": "13.1.6", "prettier": "^2.3.1", + "prisma": "^4.10.1", "sass": "^1.34.1", "typescript": "^4.3.2" } diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..f565ef7 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,31 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + password String + name String + posts Post[] +} + +model Post { + id Int @id @default(autoincrement()) + title String + content String? + published Boolean @default(false) + createdAt DateTime @default(now()) + tags String[] + + author User @relation(fields: [authorId], references: [id]) + authorId Int +} diff --git a/src/components/Post.tsx b/src/components/Post.tsx new file mode 100644 index 0000000..a21fa40 --- /dev/null +++ b/src/components/Post.tsx @@ -0,0 +1,54 @@ +import { FC } from "react"; + +import Link from "next/link"; + +import { Post, User } from "@prisma/client"; + +import multipleClassNames from "@utils/multipleClassNames"; + +import styles from "./post.module.scss"; + +const Post: FC<{ + post: Post & { + author: User; + }; +}> = ({ post }) => { + return ( +
+
+

{post.title}

+ + {post.tags.map((tag) => ( + + {tag} + + ))} + {post.content && ( +

+ {post.content?.length > 300 + ? post.content.slice(0, 300) + : post.content}{" "} + Continue reading +

+ )} +
+
+
Posted on {new Date(post.createdAt).toLocaleDateString()}
+
By {post.author.name}
+
+
+ ); +}; + +export default Post; diff --git a/src/components/Tags.tsx b/src/components/Tags.tsx new file mode 100644 index 0000000..ff81008 --- /dev/null +++ b/src/components/Tags.tsx @@ -0,0 +1,15 @@ +import { FC } from "react"; + +const Tags: FC<{ tags: string[] }> = ({ tags }) => { + return ( + <> + {tags.map((tag) => ( + + {tag} + + ))} + + ); +}; + +export default Tags; diff --git a/src/components/post.module.scss b/src/components/post.module.scss new file mode 100644 index 0000000..7d23038 --- /dev/null +++ b/src/components/post.module.scss @@ -0,0 +1,7 @@ +.body { + padding: 1rem; +} + +.info { + text-align: right; +} \ No newline at end of file diff --git a/src/models/login.ts b/src/models/login.ts new file mode 100644 index 0000000..238dc7a --- /dev/null +++ b/src/models/login.ts @@ -0,0 +1,7 @@ +import z from "zod"; + +export const LoginCredentials = z.object({ + username: z.string().email(), + password: z.string().min(8).max(128), + rememberMe: z.literal("on").optional() +}); diff --git a/src/models/post.ts b/src/models/post.ts new file mode 100644 index 0000000..520e108 --- /dev/null +++ b/src/models/post.ts @@ -0,0 +1,8 @@ +import z from "zod"; + +export const Post = z.object({ + title: z.string(), + content: z.string(), + tags: z.string(), + published: z.literal("on").optional() +}); diff --git a/src/models/signup.ts b/src/models/signup.ts new file mode 100644 index 0000000..4e076f1 --- /dev/null +++ b/src/models/signup.ts @@ -0,0 +1,7 @@ +import z from "zod"; + +export const SignupCredentials = z.object({ + email: z.string().email(), + password: z.string().min(8).max(128), + name: z.string().max(32) +}); diff --git a/src/pages/api/blog/login.ts b/src/pages/api/blog/login.ts new file mode 100644 index 0000000..10af76e --- /dev/null +++ b/src/pages/api/blog/login.ts @@ -0,0 +1,52 @@ +import bcrypt from "bcrypt"; +import { NextApiHandler } from "next"; + +import { LoginCredentials } from "@models/login"; + +import { withIronSession } from "@utils/session"; +import { methodNotAllowed, unauthorized } from "@utils/errors"; +import prisma from "@utils/prisma"; + +const handle: NextApiHandler = async (req, res) => { + if (req.method?.toUpperCase() != "POST") { + res.status(405).json(methodNotAllowed); + return; + } + + const loginCredentials = LoginCredentials.safeParse(req.body); + + if (!loginCredentials.success) { + res.status(403).json({ ok: false, error: loginCredentials.error }); + return; + } + + const email = loginCredentials.data.username; + + const user = await prisma.user.findUnique({ where: { email } }); + + if (user === null) { + res.status(401).json(unauthorized); + return; + } + + const password = loginCredentials.data.password; + + const isCorrect = await new Promise((resolve, reject) => + bcrypt.compare(password, user.password, (err, result) => { + if (err) reject(err); + else if (result !== undefined) resolve(result); + }) + ); + + if (!isCorrect) { + res.status(401).json(unauthorized); + } + + req.session.user = user; + + await req.session.save(); + + res.redirect("/blog"); +}; + +export default withIronSession(handle); diff --git a/src/pages/api/blog/logout.ts b/src/pages/api/blog/logout.ts new file mode 100644 index 0000000..f4bd35d --- /dev/null +++ b/src/pages/api/blog/logout.ts @@ -0,0 +1,26 @@ +import { NextApiHandler } from "next"; + +import { withIronSession } from "@utils/session"; +import { methodNotAllowed, unauthorized } from "@utils/errors"; + +const handle: NextApiHandler = (req, res) => { + if (req.method?.toUpperCase() != "GET") { + res.status(405).json(methodNotAllowed); + return; + } + + const user = req.session.user; + + if (user === undefined) { + res.status(401).json(unauthorized); + return; + } + + req.session.destroy(); + + req.session.user = undefined; + + res.redirect("/blog"); +}; + +export default withIronSession(handle); diff --git a/src/pages/api/blog/new.ts b/src/pages/api/blog/new.ts new file mode 100644 index 0000000..ba43365 --- /dev/null +++ b/src/pages/api/blog/new.ts @@ -0,0 +1,47 @@ +import { NextApiHandler } from "next"; + +import { methodNotAllowed, unauthorized } from "@utils/errors"; +import { withIronSession } from "@utils/session"; +import { Post } from "@models/post"; + +import prisma from "@utils/prisma"; + +const handle: NextApiHandler = async (req, res) => { + if (req.method?.toUpperCase() != "POST") { + res.status(405).json(methodNotAllowed); + return; + } + + const postData = Post.safeParse(req.body); + + if (!postData.success) { + res.status(403).json({ ok: false, error: postData.error }); + return; + } + + const user = req.session.user; + + if (user === undefined) { + res.status(401).json(unauthorized); + return; + } + + const data = postData.data; + + await prisma.user.update({ + where: { id: user.id }, + data: { + posts: { + create: { + ...data, + published: data.published !== undefined, + tags: data.tags.trim().split(" ") + } + } + } + }); + + res.redirect("/blog"); +}; + +export default withIronSession(handle); diff --git a/src/pages/api/blog/signup.ts b/src/pages/api/blog/signup.ts new file mode 100644 index 0000000..24f7927 --- /dev/null +++ b/src/pages/api/blog/signup.ts @@ -0,0 +1,61 @@ +import bcrypt from "bcrypt"; + +import { NextApiHandler } from "next"; + +import prisma from "@utils/prisma"; + +import { SignupCredentials } from "@models/signup"; +import { withIronSession } from "@utils/session"; +import { methodNotAllowed } from "@utils/errors"; +import { registrationIsEnabled, saltRoundsForPassword } from "@utils/config"; + +const handle: NextApiHandler = async (req, res) => { + if (!registrationIsEnabled) { + res + .status(403) + .json({ ok: false, error: "Registration is not enabled on this server" }); + return; + } + + if (req.method?.toUpperCase() != "POST") { + res.status(405).json(methodNotAllowed); + return; + } + + const signupCredentials = SignupCredentials.safeParse(req.body); + + if (!signupCredentials.success) { + res.status(403).json({ ok: false, error: signupCredentials.error }); + return; + } + + const password: string = await new Promise((resolve, reject) => + bcrypt.hash( + signupCredentials.data.password, + saltRoundsForPassword, + (err, hash) => { + if (err) return reject(err); + else if (hash) return resolve(hash); + } + ) + ); + + prisma.user + .create({ + data: { + email: signupCredentials.data.email, + name: signupCredentials.data.name, + password + } + }) + .then(async (user) => { + req.session.user = user; + + await req.session.save(); + + res.redirect("/blog"); + }) + .catch((error) => res.status(500).json({ ok: false, error })); +}; + +export default withIronSession(handle); diff --git a/src/pages/blog/[id].module.scss b/src/pages/blog/[id].module.scss new file mode 100644 index 0000000..f685f50 --- /dev/null +++ b/src/pages/blog/[id].module.scss @@ -0,0 +1,3 @@ +.body { + padding: 10rem 0; +} \ No newline at end of file diff --git a/src/pages/blog/[id].tsx b/src/pages/blog/[id].tsx new file mode 100644 index 0000000..e14a09c --- /dev/null +++ b/src/pages/blog/[id].tsx @@ -0,0 +1,72 @@ +import { NextSeo } from "next-seo"; +import { NextPage } from "next"; + +import { Post, User } from "@prisma/client"; + +import Layout from "@components/Layout"; +import Tags from "@components/Tags"; + +import { withSessionSsr } from "@utils/session"; +import prisma from "@utils/prisma"; + +import styles from "./[id].module.scss"; + +const PostPage: NextPage<{ + post: Post & { + author: User; + }; + user: User | null; +}> = ({ post }) => { + return ( + + +
+
+
+
+
+

{post.title}

+

+ by {post.author.name} on{" "} + {new Date(post.createdAt).toLocaleDateString()} +

+ +

+ {post.content} +

+
+
+
+
+ + ); +}; + +export const getServerSideProps = withSessionSsr(async ({ req, params }) => { + if (!params?.id || Array.isArray(params?.id)) return { notFound: true }; + + const postId = parseInt(params?.id); + + if (Number.isNaN(postId)) return { notFound: true }; + + const post = await prisma.post.findUnique({ + where: { id: postId }, + include: { author: true } + }); + + if (post === null) return { notFound: true }; + + const user = req.session.user ?? null; + + return { + props: { + user, + post: { + ...post, + createdAt: post.createdAt.toString() + } + } + }; +}); + +export default PostPage; diff --git a/src/pages/blog/blog.module.scss b/src/pages/blog/blog.module.scss new file mode 100644 index 0000000..d19a4bb --- /dev/null +++ b/src/pages/blog/blog.module.scss @@ -0,0 +1,4 @@ +.body { + padding: 10rem 1rem; + min-height: 100vh; +} \ No newline at end of file diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx new file mode 100644 index 0000000..684c3ef --- /dev/null +++ b/src/pages/blog/index.tsx @@ -0,0 +1,88 @@ +import Link from "next/link"; +import { NextPage } from "next"; +import { NextSeo } from "next-seo"; + +import { Post, User } from "@prisma/client"; + +import Layout from "@components/Layout"; + +import { withSessionSsr } from "@utils/session"; +import prisma from "@utils/prisma"; + +import PostComponent from "@components/Post"; + +import styles from "./blog.module.scss"; + +const Blog: NextPage<{ + posts: (Post & { + author: User; + })[]; + user: User | null; +}> = ({ posts, user }) => { + return ( + + +
+
+
+
+

Latest posts

+
+
+
+ {user && ( +
+
+ New post ·{" "} + Logout +
+
+ )} + {posts.length < 1 && ( +
+
+

No posts yet

+
+
+ )} + {posts.length > 0 && + posts.map((post) => )} +
+
+ + ); +}; + +export const getServerSideProps = withSessionSsr( + async ({ + req + // query + }) => { + const user = req.session.user ?? null; + + // let cursor = 0; + // if (!Array.isArray(query.cursor) && query.cursor !== undefined) { + // cursor = parseInt(query.cursor); + // } + + const posts = await prisma.post.findMany({ + where: { published: user ? undefined : true }, + orderBy: { createdAt: "desc" }, + take: 5, + include: { author: true } + }); + + return { + props: { + user, + posts: posts.map((post) => ({ + ...post, + createdAt: post.createdAt.toString(), + content: post.content?.split("\n")[0] + })) + } + }; + } +); + +export default Blog; diff --git a/src/pages/blog/login.module.scss b/src/pages/blog/login.module.scss new file mode 100644 index 0000000..f9aae6c --- /dev/null +++ b/src/pages/blog/login.module.scss @@ -0,0 +1,21 @@ +.body { + height: 100vh; + padding: 10rem 0; +} + +.title { + text-align: center; +} + +.divider { + margin: 0 1rem; +} + +.loginButton, +.signupButton { + width: 100%; +} + +.signupButton { + margin-top: .5rem; +} \ No newline at end of file diff --git a/src/pages/blog/login.tsx b/src/pages/blog/login.tsx new file mode 100644 index 0000000..2c501ef --- /dev/null +++ b/src/pages/blog/login.tsx @@ -0,0 +1,137 @@ +import { GetStaticProps, NextPage } from "next"; +import { NextSeo } from "next-seo"; + +import Layout from "@components/Layout"; + +import multipleClassNames from "@utils/multipleClassNames"; + +import styles from "./login.module.scss"; +import { registrationIsEnabled } from "@utils/config"; + +const Login: NextPage<{ registrationIsEnabled: boolean }> = ({ + registrationIsEnabled +}) => { + return ( + + +
+
+
+

Login to blog

+
+
+ + + + + + +
+ +
+ {registrationIsEnabled && ( + <> +
+
+

Create new account

+
+
+ + + + + + + + + +
+
+
+ + )} +
+
+ + ); +}; + +export const getStaticProps: GetStaticProps = async () => { + return { props: { registrationIsEnabled } }; +}; + +export default Login; diff --git a/src/pages/blog/new.module.scss b/src/pages/blog/new.module.scss new file mode 100644 index 0000000..e59a88b --- /dev/null +++ b/src/pages/blog/new.module.scss @@ -0,0 +1,4 @@ +.body { + height: 100vh; + padding: 10rem 0; +} \ No newline at end of file diff --git a/src/pages/blog/new.tsx b/src/pages/blog/new.tsx new file mode 100644 index 0000000..ea6d0db --- /dev/null +++ b/src/pages/blog/new.tsx @@ -0,0 +1,98 @@ +import { NextPage } from "next"; +import { NextSeo } from "next-seo"; + +import { User } from "@prisma/client"; + +import Layout from "@components/Layout"; + +import { withSessionSsr } from "@utils/session"; + +import styles from "./new.module.scss"; + +const NewPostPage: NextPage<{ user: User }> = ({ user }) => { + return ( + + +
+
+
+
+

Create new post

+
Logged in as {user.name}
+
+
+ + + + + + + +