From 90fa28ad6521454b279c501a2493d9d6e74f46c9 Mon Sep 17 00:00:00 2001 From: Max Leiter Date: Mon, 21 Mar 2022 17:20:41 -0700 Subject: [PATCH] post generation rework with static paths/props --- README.md | 2 + client/.env.local | 1 + client/components/my-posts/index.tsx | 14 +- client/components/new-post/index.tsx | 3 +- client/components/post-list/list-item.tsx | 3 +- .../preview/react-markdown-preview.tsx | 2 +- client/lib/get-post-path.ts | 13 ++ client/pages/api/raw/[id].ts | 13 +- client/pages/api/revalidate.ts | 24 ++++ client/pages/mine.tsx | 34 ++++- client/pages/post/[id].tsx | 80 +++++------ client/pages/post/private/[id].tsx | 128 ++++++++++++++++++ server/{ => src}/lib/config.ts | 0 server/{ => src}/lib/middleware/jwt.ts | 0 server/src/lib/middleware/secret-key.ts | 14 ++ server/{ => src}/lib/models/File.ts | 0 server/{ => src}/lib/models/Post.ts | 0 server/{ => src}/lib/models/PostAuthor.ts | 0 server/{ => src}/lib/models/User.ts | 0 server/{ => src}/lib/sequelize.ts | 0 server/src/routes/auth.ts | 6 +- server/src/routes/files.ts | 14 +- server/src/routes/posts.ts | 39 ++++-- server/src/routes/users.ts | 8 +- server/src/server.ts | 4 +- server/tsconfig.json | 2 +- 26 files changed, 302 insertions(+), 102 deletions(-) create mode 100644 client/lib/get-post-path.ts create mode 100644 client/pages/api/revalidate.ts create mode 100644 client/pages/post/private/[id].tsx rename server/{ => src}/lib/config.ts (100%) rename server/{ => src}/lib/middleware/jwt.ts (100%) create mode 100644 server/src/lib/middleware/secret-key.ts rename server/{ => src}/lib/models/File.ts (100%) rename server/{ => src}/lib/models/Post.ts (100%) rename server/{ => src}/lib/models/PostAuthor.ts (100%) rename server/{ => src}/lib/models/User.ts (100%) rename server/{ => src}/lib/sequelize.ts (100%) diff --git a/README.md b/README.md index 74a7f5cd..cb12ef79 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ You can change these to your liking. - `API_URL`: defaults to localhost:3001, but allows you to host the front-end separately from the backend on a service like Vercel or Netlify - `WELCOME_CONTENT`: a markdown string (with \n newlines) that's rendered on the home page - `WELCOME_TITLE`: the file title for the post on the homepage. +- `SECRET_KEY`: a secret key used for validating API requests that is never exposed to the browser `server/.env`: @@ -38,6 +39,7 @@ You can change these to your liking. - `JWT_SECRET`: a secure token for JWT tokens. You can generate one [here](https://www.grc.com/passwords.htm). - `MEMORY_DB`: if `true`, a sqlite database will not be created and changes will only exist in memory. Mainly for the demo. - `REGISTRATION_PASSWORD`: if MEMORY_DB is not `true`, the user will be required to provide this password to sign-up, in addition to their username and account password. If it's not set, no password will be required. +- `SECRET_KEY`: the same secret key as the client ## Current status diff --git a/client/.env.local b/client/.env.local index 3bcbece7..a4e28f86 100644 --- a/client/.env.local +++ b/client/.env.local @@ -1,3 +1,4 @@ API_URL=http://localhost:3000 WELCOME_TITLE="Welcome to Drift" WELCOME_CONTENT="### Drift is a self-hostable clone of GitHub Gist. \nIt is a simple way to share code and text snippets with your friends, with support for the following:\n \n - Render GitHub Extended Markdown (including images)\n - User authentication\n - Private, public, and secret posts\n \n If you want to signup, you can join at [/signup](/signup) as long as you have a passcode provided by the administrator (which you don\'t need for this demo).\n **This demo is on a memory-only database, so accounts and pastes can be deleted at any time.**\n You can find the source code on [GitHub](https://github.com/MaxLeiter/drift).\n \n Drift was inspired by [this tweet](https://twitter.com/emilyst/status/1499858264346935297):\n > What is the absolute closest thing to GitHub Gist that can be self-hosted?\n In terms of design and functionality. Hosts images and markdown, rendered. Creates links that can be private or public. Uses/requires registration. I have looked at dozens of pastebin-like things." +SECRET_KEY=secret \ No newline at end of file diff --git a/client/components/my-posts/index.tsx b/client/components/my-posts/index.tsx index 15ea5892..80fbdba3 100644 --- a/client/components/my-posts/index.tsx +++ b/client/components/my-posts/index.tsx @@ -1,17 +1,7 @@ -import useSWR from "swr" import PostList from "../post-list" -import Cookies from "js-cookie" -const fetcher = (url: string) => fetch(url, { - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${Cookies.get("drift-token")}` - }, -}).then(r => r.json()) - -const MyPosts = () => { - const { data, error } = useSWR('/server-api/users/mine', fetcher) - return +const MyPosts = ({ posts, error }: { posts: any, error: any }) => { + return } export default MyPosts diff --git a/client/components/new-post/index.tsx b/client/components/new-post/index.tsx index cd77a43e..a962bb8e 100644 --- a/client/components/new-post/index.tsx +++ b/client/components/new-post/index.tsx @@ -9,6 +9,7 @@ import Title from './title'; import Cookies from 'js-cookie' import type { PostVisibility, Document as DocumentType } from '@lib/types'; import PasswordModal from './password'; +import getPostPath from '@lib/get-post-path'; const Post = () => { const { setToast } = useToasts() @@ -36,7 +37,7 @@ const Post = () => { if (res.ok) { const json = await res.json() - router.push(`/post/${json.id}`) + router.push(getPostPath(json.visibility, json.id)) } else { const json = await res.json() setToast({ diff --git a/client/components/post-list/list-item.tsx b/client/components/post-list/list-item.tsx index 30c37ecd..c034117f 100644 --- a/client/components/post-list/list-item.tsx +++ b/client/components/post-list/list-item.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react" import timeAgo from "@lib/time-ago" import ShiftBy from "../shift-by" import VisibilityBadge from "../visibility-badge" +import getPostPath from "@lib/get-post-path" const FilenameInput = ({ title }: { title: string }) => { - + {post.title} diff --git a/client/components/preview/react-markdown-preview.tsx b/client/components/preview/react-markdown-preview.tsx index b02ac3e1..5507aae2 100644 --- a/client/components/preview/react-markdown-preview.tsx +++ b/client/components/preview/react-markdown-preview.tsx @@ -1,6 +1,6 @@ import ReactMarkdown from "react-markdown" import remarkGfm from "remark-gfm" -import { PrismAsyncLight as SyntaxHighlighter } from 'react-syntax-highlighter'; +import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/prism-async-light'; import rehypeSlug from 'rehype-slug' import rehypeAutolinkHeadings from 'rehype-autolink-headings' diff --git a/client/lib/get-post-path.ts b/client/lib/get-post-path.ts new file mode 100644 index 00000000..47745319 --- /dev/null +++ b/client/lib/get-post-path.ts @@ -0,0 +1,13 @@ +import type { PostVisibility } from "./types" + +export default function getPostPath(visibility: PostVisibility, id: string) { + switch (visibility) { + case "private": + return `/post/private/${id}` + case "protected": + return `/post/protected/${id}` + case "unlisted": + case "public": + return `/post/${id}` + } +} diff --git a/client/pages/api/raw/[id].ts b/client/pages/api/raw/[id].ts index 62ef2745..f8f4d512 100644 --- a/client/pages/api/raw/[id].ts +++ b/client/pages/api/raw/[id].ts @@ -2,13 +2,20 @@ import { NextApiRequest, NextApiResponse } from "next" const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => { const { id, download } = req.query - const file = await fetch(`${process.env.API_URL}/files/raw/${id}`) + const file = await fetch(`${process.env.API_URL}/files/raw/${id}`, { + headers: { + 'Accept': 'text/plain', + 'x-secret-key': process.env.SECRET_KEY || '' + } + }) + res.setHeader("Content-Type", "text/plain") + res.setHeader('Cache-Control', 's-maxage=86400'); + if (file.ok) { const data = await file.json() const { title, content } = data // serve the file raw as plain text - res.setHeader("Content-Type", "text/plain") - res.setHeader('Cache-Control', 's-maxage=86400'); + if (download) { res.setHeader("Content-Disposition", `attachment; filename="${title}"`) } else { diff --git a/client/pages/api/revalidate.ts b/client/pages/api/revalidate.ts new file mode 100644 index 00000000..07c62794 --- /dev/null +++ b/client/pages/api/revalidate.ts @@ -0,0 +1,24 @@ +import { NextApiRequest, NextApiResponse } from "next" + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.headers['x-secret-key'] !== process.env.SECRET_KEY) { + return res.status(401).send('Unauthorized') + } + + const { path } = req.query + + if (!path || typeof path !== 'string') { + return res.status(400).json({ + error: "Missing path" + }) + } + + try { + await res.unstable_revalidate(path) + return res.json({ revalidated: true }) + } catch (err) { + // If there was an error, Next.js will continue + // to show the last successfully generated page + return res.status(500).send('Error revalidating') + } +} diff --git a/client/pages/mine.tsx b/client/pages/mine.tsx index b273d548..9e502c20 100644 --- a/client/pages/mine.tsx +++ b/client/pages/mine.tsx @@ -3,18 +3,48 @@ import { Page } from '@geist-ui/core' import Header from '@components/header' import MyPosts from '@components/my-posts' +import cookie from "cookie"; +import { GetServerSideProps } from 'next'; +import { ThemeProps } from '@lib/types'; -const Home = ({ theme, changeTheme }: { theme: "light" | "dark", changeTheme: () => void }) => { +const Home = ({ posts, error, theme, changeTheme }: ThemeProps & { posts: any; error: any; }) => { return (
- + ) } +// get server side props +export const getServerSideProps: GetServerSideProps = async ({ req }) => { + const driftToken = cookie.parse(req.headers.cookie || '')[`drift-token`] + if (!driftToken) { + return { + redirect: { + destination: '/', + permanent: false, + } + } + } + + const posts = await fetch('http://localhost:3000/server-api/users/mine', { + method: "GET", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${driftToken}` + } + }) + + return { + props: { + posts: await posts.json(), + error: posts.status !== 200, + } + } +} export default Home diff --git a/client/pages/post/[id].tsx b/client/pages/post/[id].tsx index 5ddeb3f8..20c91bc8 100644 --- a/client/pages/post/[id].tsx +++ b/client/pages/post/[id].tsx @@ -1,13 +1,11 @@ import { Button, Page, Text } from "@geist-ui/core"; -import { useRouter } from "next/router"; -import Document from '../../components/document' -import Header from "../../components/header"; -import VisibilityBadge from "../../components/visibility-badge"; +import Document from '@components/document' +import Header from "@components/header"; +import VisibilityBadge from "@components/visibility-badge"; import PageSeo from "components/page-seo"; import styles from './styles.module.css'; -import cookie from "cookie"; -import { GetServerSideProps, GetStaticPaths, GetStaticProps } from "next"; +import type { GetStaticPaths, GetStaticProps } from "next"; import { PostVisibility, ThemeProps } from "@lib/types"; type File = { @@ -51,7 +49,7 @@ const Post = ({ post, theme, changeTheme }: PostProps) => { @@ -83,53 +81,37 @@ const Post = ({ post, theme, changeTheme }: PostProps) => { ) } -export const getServerSideProps: GetServerSideProps = async (context) => { - const headers = context.req.headers - const host = headers.host - const driftToken = cookie.parse(headers.cookie || '')[`drift-token`] - let driftTheme = cookie.parse(headers.cookie || '')[`drift-theme`] - if (driftTheme !== "light" && driftTheme !== "dark") { - driftTheme = "light" - } - - if (context.query.id) { - const post = await fetch('http://' + host + `/server-api/posts/${context.query.id}`, { - method: "GET", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${driftToken}` - } - }) - - if (!post.ok || post.status !== 200) { - return { - redirect: { - destination: '/', - permanent: false, - }, - } +export const getStaticPaths: GetStaticPaths = async () => { + const posts = await fetch(process.env.API_URL + `/posts/`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "x-secret-key": process.env.SECRET_KEY || "", } - try { - const json = await post.json(); - const maxAge = 60 * 60 * 24 * 365; - context.res.setHeader( - 'Cache-Control', - `${json.visibility === "public" ? "public" : "private"}, s-maxage=${maxAge}` - ) - return { - props: { - post: json - } - } - } catch (e) { - console.log(e) + }) + + const json = await posts.json() + const filtered = json.filter((post: any) => post.visibility === "public" || post.visibility === "unlisted") + const paths = filtered.map((post: any) => ({ + params: { id: post.id } + })) + + return { paths, fallback: 'blocking' } +} + +export const getStaticProps: GetStaticProps = async ({ params }) => { + const post = await fetch(process.env.API_URL + `/posts/${params?.id}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "x-secret-key": process.env.SECRET_KEY || "", } - } + }) return { props: { - post: null - } + post: await post.json() + }, } } diff --git a/client/pages/post/private/[id].tsx b/client/pages/post/private/[id].tsx new file mode 100644 index 00000000..2dfdb3c0 --- /dev/null +++ b/client/pages/post/private/[id].tsx @@ -0,0 +1,128 @@ +import { Button, Page, Text } from "@geist-ui/core"; + +import Document from '@components/document' +import Header from "@components/header"; +import VisibilityBadge from "@components/visibility-badge"; +import PageSeo from "components/page-seo"; +import styles from '../styles.module.css'; +import cookie from "cookie"; +import type { GetServerSideProps } from "next"; +import { PostVisibility, ThemeProps } from "@lib/types"; + +type File = { + id: string + title: string + content: string +} + +type Files = File[] + +export type PostProps = ThemeProps & { + post: { + id: string + title: string + description: string + visibility: PostVisibility + files: Files + } +} + +const Post = ({ post, theme, changeTheme }: PostProps) => { + const download = async () => { + const clientZip = require("client-zip") + + const blob = await clientZip.downloadZip(post.files.map((file: any) => { + return { + name: file.title, + input: file.content, + lastModified: new Date(file.updatedAt) + } + })).blob() + const link = document.createElement("a") + link.href = URL.createObjectURL(blob) + link.download = `${post.title}.zip` + link.click() + link.remove() + } + + return ( + + + + +
+ + + {/* {!isLoading && } */} +
+
+ {post.title} + +
+ +
+ {post.files.map(({ id, content, title }: { id: any, content: string, title: string }) => ( + + ))} +
+ + ) +} + +export const getServerSideProps: GetServerSideProps = async (context) => { + const headers = context.req.headers + const host = headers.host + const driftToken = cookie.parse(headers.cookie || '')[`drift-token`] + + if (context.query.id) { + const post = await fetch('http://' + host + `/server-api/posts/${context.query.id}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${driftToken}` + } + }) + + if (!post.ok || post.status !== 200) { + return { + redirect: { + destination: '/', + permanent: false, + }, + } + } + try { + const json = await post.json(); + + return { + props: { + post: json + } + } + } catch (e) { + console.log(e) + } + } + + return { + props: { + post: null + } + } +} + +export default Post + diff --git a/server/lib/config.ts b/server/src/lib/config.ts similarity index 100% rename from server/lib/config.ts rename to server/src/lib/config.ts diff --git a/server/lib/middleware/jwt.ts b/server/src/lib/middleware/jwt.ts similarity index 100% rename from server/lib/middleware/jwt.ts rename to server/src/lib/middleware/jwt.ts diff --git a/server/src/lib/middleware/secret-key.ts b/server/src/lib/middleware/secret-key.ts new file mode 100644 index 00000000..241fe647 --- /dev/null +++ b/server/src/lib/middleware/secret-key.ts @@ -0,0 +1,14 @@ +import { NextFunction, Request, Response } from 'express'; + +const key = process.env.SECRET_KEY; +if (!key) { + throw new Error('SECRET_KEY is not set.'); +} + +export default function authenticateToken(req: Request, res: Response, next: NextFunction) { + const requestKey = req.headers['x-secret-key'] + if (requestKey !== key) { + return res.sendStatus(401) + } + next() +} diff --git a/server/lib/models/File.ts b/server/src/lib/models/File.ts similarity index 100% rename from server/lib/models/File.ts rename to server/src/lib/models/File.ts diff --git a/server/lib/models/Post.ts b/server/src/lib/models/Post.ts similarity index 100% rename from server/lib/models/Post.ts rename to server/src/lib/models/Post.ts diff --git a/server/lib/models/PostAuthor.ts b/server/src/lib/models/PostAuthor.ts similarity index 100% rename from server/lib/models/PostAuthor.ts rename to server/src/lib/models/PostAuthor.ts diff --git a/server/lib/models/User.ts b/server/src/lib/models/User.ts similarity index 100% rename from server/lib/models/User.ts rename to server/src/lib/models/User.ts diff --git a/server/lib/sequelize.ts b/server/src/lib/sequelize.ts similarity index 100% rename from server/lib/sequelize.ts rename to server/src/lib/sequelize.ts diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 049445c0..9e77a93a 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -1,9 +1,9 @@ import { Router } from 'express' import { genSalt, hash, compare } from "bcrypt" -import { User } from '../../lib/models/User' +import { User } from '../lib/models/User' import { sign } from 'jsonwebtoken' -import config from '../../lib/config' -import jwt from '../../lib/middleware/jwt' +import config from '../lib/config' +import jwt from '../lib/middleware/jwt' const NO_EMPTY_SPACE_REGEX = /^\S*$/ diff --git a/server/src/routes/files.ts b/server/src/routes/files.ts index 7a23751a..13efe551 100644 --- a/server/src/routes/files.ts +++ b/server/src/routes/files.ts @@ -1,10 +1,11 @@ import { Router } from 'express' +import secretKey from '../lib/middleware/secret-key'; // import { Movie } from '../models/Post' -import { File } from '../../lib/models/File' +import { File } from '../lib/models/File' export const files = Router() -files.get("/raw/:id", async (req, res, next) => { +files.get("/raw/:id", secretKey, async (req, res, next) => { try { const file = await File.findOne({ where: { @@ -12,18 +13,9 @@ files.get("/raw/:id", async (req, res, next) => { }, attributes: ["title", "content"], }) - // TODO: fix post inclusion - // if (file?.post.visibility === 'public' || file?.post.visibility === 'unlisted') { - res.setHeader("Cache-Control", "public, max-age=86400"); res.json(file); - // } else { - // TODO: should this be `private, `? - // res.setHeader("Cache-Control", "max-age=86400"); - // res.json(file); - // } } catch (e) { next(e); } }); - diff --git a/server/src/routes/posts.ts b/server/src/routes/posts.ts index 6456458e..70f54bf6 100644 --- a/server/src/routes/posts.ts +++ b/server/src/routes/posts.ts @@ -1,10 +1,11 @@ import { Router } from 'express' // import { Movie } from '../models/Post' -import { File } from '../../lib/models/File' -import { Post } from '../../lib/models/Post'; -import jwt, { UserJwtRequest } from '../../lib/middleware/jwt'; +import { File } from '../lib/models/File' +import { Post } from '../lib/models/Post'; +import jwt, { UserJwtRequest } from '../lib/middleware/jwt'; import * as crypto from "crypto"; -import { User } from '../../lib/models/User'; +import { User } from '../lib/models/User'; +import secretKey from '../lib/middleware/secret-key'; export const posts = Router() @@ -57,7 +58,18 @@ posts.post('/create', jwt, async (req, res, next) => { } }); -posts.get("/:id", async (req: UserJwtRequest, res, next) => { +posts.get("/", secretKey, async (req, res, next) => { + try { + const posts = await Post.findAll({ + attributes: ["id", "title", "visibility", "createdAt"], + }) + res.json(posts); + } catch (e) { + next(e); + } +}); + +posts.get("/:id", secretKey, async (req, res, next) => { try { const post = await Post.findOne({ where: { @@ -76,16 +88,19 @@ posts.get("/:id", async (req: UserJwtRequest, res, next) => { }, ] }) + if (!post) { + throw new Error("Post not found.") + } - if (post?.visibility === 'public' || post?.visibility === 'unlisted') { - res.setHeader("Cache-Control", "public, max-age=86400"); + if (post.visibility === 'public' || post?.visibility === 'unlisted') { res.json(post); - } else { - // TODO: should this be `private, `? - res.setHeader("Cache-Control", "max-age=86400"); - jwt(req, res, () => { + } else if (post.visibility === 'private') { + console.log("here") + jwt(req as UserJwtRequest, res, () => { res.json(post); - }); + }) + } else if (post.visibility === 'protected') { + } } catch (e) { diff --git a/server/src/routes/users.ts b/server/src/routes/users.ts index 3ffefd01..67c793f5 100644 --- a/server/src/routes/users.ts +++ b/server/src/routes/users.ts @@ -1,9 +1,9 @@ import { Router } from 'express' // import { Movie } from '../models/Post' -import { User } from '../../lib/models/User' -import { File } from '../../lib/models/File' -import jwt, { UserJwtRequest } from '../../lib/middleware/jwt' -import { Post } from '../../lib/models/Post' +import { User } from '../lib/models/User' +import { File } from '../lib/models/File' +import jwt, { UserJwtRequest } from '../lib/middleware/jwt' +import { Post } from '../lib/models/Post' export const users = Router() diff --git a/server/src/server.ts b/server/src/server.ts index 2e96ea8d..b3670024 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,7 +1,7 @@ import { createServer } from 'http'; import { app } from './app'; -import config from '../lib/config'; -import { sequelize } from '../lib/sequelize'; +import config from './lib/config'; +import { sequelize } from './lib/sequelize'; (async () => { await sequelize.sync(); diff --git a/server/tsconfig.json b/server/tsconfig.json index e5fbe651..765d8a8c 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -15,6 +15,6 @@ "strictPropertyInitialization": true, "outDir": "dist" }, - "include": ["lib/**/*.ts", "index.ts", "src/**/*.ts"], + "include": ["index.ts", "src/**/*.ts"], "exclude": ["node_modules"] }