diff --git a/client/app/(posts)/new/from/[id]/page.tsx b/client/app/(posts)/new/from/[id]/page.tsx index 153b47fd..47e6ee66 100644 --- a/client/app/(posts)/new/from/[id]/page.tsx +++ b/client/app/(posts)/new/from/[id]/page.tsx @@ -14,7 +14,8 @@ const NewFromExisting = async ({ const router = useRouter() if (!id) { - return router.push("/new") + router.push("/new") + return; } const post = await getPostWithFiles(id) diff --git a/client/app/(posts)/post/[id]/[id].tsx b/client/app/(posts)/post/[id]/[id].tsx deleted file mode 100644 index aacdfda7..00000000 --- a/client/app/(posts)/post/[id]/[id].tsx +++ /dev/null @@ -1,81 +0,0 @@ -import type { GetServerSideProps } from "next" - -import type { Post } from "@lib/types" -import PostPage from "@components/post-page" -import { USER_COOKIE_NAME } from "@lib/constants" - -export type PostProps = { - post: Post - isProtected?: boolean -} - -const PostView = ({ post, isProtected }: PostProps) => { - return -} - -export const getServerSideProps: GetServerSideProps = async ({ - params, - req, - res -}) => { - 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 || "", - Authorization: `Bearer ${req.cookies["drift-token"]}` - } - }) - - if (post.status === 401 || post.status === 403) { - return { - // can't access the post if it's private - redirect: { - destination: "/", - permanent: false - }, - props: {} - } - } else if (post.status === 404 || !post.ok) { - return { - redirect: { - destination: "/404", - permanent: false - }, - props: {} - } - } - - const json = (await post.json()) as Post - const isAuthor = json.users?.find( - (user) => user.id === req.cookies[USER_COOKIE_NAME] - ) - - if (json.visibility === "public" || json.visibility === "unlisted") { - const sMaxAge = 60 * 60 * 12 // half a day - res.setHeader( - "Cache-Control", - `public, s-maxage=${sMaxAge}, max-age=${sMaxAge}` - ) - } else if (json.visibility === "protected" && !isAuthor) { - return { - props: { - post: { - id: json.id, - visibility: json.visibility, - expiresAt: json.expiresAt - }, - isProtected: true - } - } - } - - return { - props: { - post: json, - key: params?.id - } - } -} - -export default PostView diff --git a/client/app/(posts)/post/[id]/head.tsx b/client/app/(posts)/post/[id]/head.tsx index 6e158d00..1d175a2d 100644 --- a/client/app/(posts)/post/[id]/head.tsx +++ b/client/app/(posts)/post/[id]/head.tsx @@ -5,10 +5,10 @@ export default async function Head({ params }: { params: { - slug: string + id: string } }) { - const post = await getPostById(params.slug) + const post = await getPostById(params.id) if (!post) { return null @@ -17,7 +17,7 @@ export default async function Head({ return ( ) diff --git a/client/app/(posts)/post/[id]/page.tsx b/client/app/(posts)/post/[id]/page.tsx new file mode 100644 index 00000000..f1afc71e --- /dev/null +++ b/client/app/(posts)/post/[id]/page.tsx @@ -0,0 +1,136 @@ +import type { GetServerSideProps } from "next" + +import type { Post } from "@lib/types" +import PostPage from "@components/post-page" +import { USER_COOKIE_NAME } from "@lib/constants" +import { notFound } from "next/navigation" +import { getPostById } from "@lib/server/prisma" +import { getCurrentUser, getSession } from "@lib/server/session" +import Header from "@components/header" + +export type PostProps = { + post: Post + isProtected?: boolean +} + +const getPost = async (id: string) => { + const post = await getPostById(id, true) + const user = await getCurrentUser() + + if (!post) { + return notFound() + } + + const isAuthor = user?.id === post?.authorId + + if (post.visibility === "public") { + return { post, isAuthor, signedIn: Boolean(user) } + } + + // must be authed to see unlisted/private + if ( + (post.visibility === "unlisted" || post.visibility === "private") && + !user + ) { + return notFound() + } + + if (post.visibility === "private" && !isAuthor) { + return notFound() + } + + if (post.visibility === "protected" && !isAuthor) { + return { + post, + isProtected: true, + isAuthor, + signedIn: Boolean(user) + } + } + + return { post, isAuthor, signedIn: Boolean(user) } +} + +const PostView = async ({ + params +}: { + params: { + id: string, + signedIn?: boolean + } +}) => { + const { post, isProtected, isAuthor } = await getPost(params.id) + return ( + <> +
+ + + ) +} + +// export const getServerSideProps: GetServerSideProps = async ({ +// params, +// req, +// res +// }) => { +// 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 || "", +// Authorization: `Bearer ${req.cookies["drift-token"]}` +// } +// }) + +// if (post.status === 401 || post.status === 403) { +// return { +// // can't access the post if it's private +// redirect: { +// destination: "/", +// permanent: false +// }, +// props: {} +// } +// } else if (post.status === 404 || !post.ok) { +// return { +// redirect: { +// destination: "/404", +// permanent: false +// }, +// props: {} +// } +// } + +// const json = (await post.json()) as Post +// const isAuthor = json.users?.find( +// (user) => user.id === req.cookies[USER_COOKIE_NAME] +// ) + +// if (json.visibility === "public" || json.visibility === "unlisted") { +// const sMaxAge = 60 * 60 * 12 // half a day +// res.setHeader( +// "Cache-Control", +// `public, s-maxage=${sMaxAge}, max-age=${sMaxAge}` +// ) +// } else if (json.visibility === "protected" && !isAuthor) { +// return { +// props: { +// post: { +// id: json.id, +// visibility: json.visibility, +// expiresAt: json.expiresAt +// }, +// isProtected: true +// } +// } +// } + +// return { +// props: { +// post: json, +// key: params?.id +// } +// } +// } + +export default PostView diff --git a/client/app/(profiles)/mine/page.tsx b/client/app/(profiles)/mine/page.tsx index 74f62452..f35be304 100644 --- a/client/app/(profiles)/mine/page.tsx +++ b/client/app/(profiles)/mine/page.tsx @@ -1,6 +1,4 @@ -import { USER_COOKIE_NAME } from "@lib/constants" -import { notFound, redirect, useRouter } from "next/navigation" -import { cookies } from "next/headers" +import { redirect } from "next/navigation" import { getPostsByUser } from "@lib/server/prisma" import PostList from "@components/post-list" import { getCurrentUser } from "@lib/server/session" diff --git a/client/app/root-layout-wrapper.tsx b/client/app/root-layout-wrapper.tsx index 98ddee7a..42ad9c9d 100644 --- a/client/app/root-layout-wrapper.tsx +++ b/client/app/root-layout-wrapper.tsx @@ -61,10 +61,7 @@ export function LayoutWrapper({ attribute="data-theme" > - + {children} diff --git a/client/components/admin/user-table.tsx b/client/components/admin/user-table.tsx index 61ae8d4b..12ef0d22 100644 --- a/client/components/admin/user-table.tsx +++ b/client/components/admin/user-table.tsx @@ -47,7 +47,7 @@ const UserTable = () => { }) } else { setToast({ - text: json.error || "Something went wrong", + text: "Something went wrong", type: "error" }) } @@ -69,7 +69,7 @@ const UserTable = () => { }) } else { setToast({ - text: json.error || "Something went wrong", + text: "Something went wrong", type: "error" }) } diff --git a/client/components/auth/index.tsx b/client/components/auth/index.tsx index 0df97392..b5b0fd34 100644 --- a/client/components/auth/index.tsx +++ b/client/components/auth/index.tsx @@ -18,61 +18,10 @@ const Auth = ({ page: "signup" | "signin" requiresServerPassword?: boolean }) => { - const router = useRouter() - - const [username, setUsername] = useState("") - const [password, setPassword] = useState("") const [serverPassword, setServerPassword] = useState("") const [errorMsg, setErrorMsg] = useState("") const signingIn = page === "signin" - const handleJson = (json: any) => { - // setCookie(USER_COOKIE_NAME, json.userId) - - router.push("/new") - } - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault() - // if ( - // !signingIn && - // (!NO_EMPTY_SPACE_REGEX.test(username) || password.length < 6) - // ) - // return setErrorMsg(ERROR_MESSAGE) - // if ( - // !signingIn && - // requiresServerPassword && - // !NO_EMPTY_SPACE_REGEX.test(serverPassword) - // ) - // return setErrorMsg(ERROR_MESSAGE) - // else setErrorMsg("") - - const reqOpts = { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ username, password, serverPassword }) - } - - try { - // signIn("credentials", { - // callbackUrl: "/new", - // redirect: false, - // username, - // password, - // serverPassword - // }) - // const signUrl = signingIn ? "/api/auth/signin" : "/api/auth/signup" - // const resp = await fetch(signUrl, reqOpts) - // const json = await resp.json() - // if (!resp.ok) throw new Error(json.error.message) - // handleJson(json) - } catch (err: any) { - setErrorMsg(err.message ?? "Something went wrong") - } - } - return (
@@ -122,7 +71,7 @@ const Auth = ({ auto width="100%" icon={} - onClick={() => signIn("github")} + onClick={() => signIn("github").catch((err) => setErrorMsg(err.message))} > Sign in with GitHub diff --git a/client/components/badges/visibility-control/index.tsx b/client/components/badges/visibility-control/index.tsx index 5a6bd665..1dfade5e 100644 --- a/client/components/badges/visibility-control/index.tsx +++ b/client/components/badges/visibility-control/index.tsx @@ -7,8 +7,8 @@ import { useCallback, useState } from "react" type Props = { postId: string - visibility: PostVisibility - setVisibility: (visibility: PostVisibility) => void + visibility: string + setVisibility: (visibility: string) => void } const VisibilityControl = ({ postId, visibility, setVisibility }: Props) => { @@ -17,12 +17,11 @@ const VisibilityControl = ({ postId, visibility, setVisibility }: Props) => { const { setToast } = useToasts() const sendRequest = useCallback( - async (visibility: PostVisibility, password?: string) => { + async (visibility: string, password?: string) => { const res = await fetch(`/server-api/posts/${postId}`, { method: "PUT", headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${getCookie(TOKEN_COOKIE_NAME)}` + "Content-Type": "application/json" }, body: JSON.stringify({ visibility, password }) }) @@ -33,7 +32,7 @@ const VisibilityControl = ({ postId, visibility, setVisibility }: Props) => { } else { const json = await res.json() setToast({ - text: json.error.message, + text: "An error occurred", type: "error" }) setPasswordModalVisible(false) @@ -63,10 +62,7 @@ const VisibilityControl = ({ postId, visibility, setVisibility }: Props) => { setSubmitting(false) } - const submitPassword = useCallback( - (password: string) => onSubmit("protected", password), - [onSubmit] - ) + const submitPassword = (password: string) => onSubmit("protected", password) return ( <> diff --git a/client/components/link/index.tsx b/client/components/link/index.tsx index c601a95d..40a66d41 100644 --- a/client/components/link/index.tsx +++ b/client/components/link/index.tsx @@ -1,4 +1,3 @@ -import { useRouter } from "next/router" import NextLink from "next/link" import styles from "./link.module.css" diff --git a/client/components/new-post/index.tsx b/client/components/new-post/index.tsx index 85cd7bc0..88fef11a 100644 --- a/client/components/new-post/index.tsx +++ b/client/components/new-post/index.tsx @@ -15,8 +15,6 @@ import DatePicker from "react-datepicker" import getTitleForPostCopy from "@lib/get-title-for-post-copy" import Description from "./description" import { PostWithFiles } from "@lib/server/prisma" -import { TOKEN_COOKIE_NAME, USER_COOKIE_NAME } from "@lib/constants" -import { getCookie } from "cookies-next" const emptyDoc = { title: "", @@ -60,15 +58,13 @@ const Post = ({ title?: string files?: DocumentType[] password?: string - userId: string parentId?: string } ) => { const res = await fetch(url, { method: "POST", headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${getCookie(TOKEN_COOKIE_NAME)}` + "Content-Type": "application/json" }, body: JSON.stringify({ title, @@ -83,8 +79,9 @@ const Post = ({ router.push(`/post/${json.id}`) } else { const json = await res.json() + console.error(json) setToast({ - text: json.error.message || "Please fill out all fields", + text: "Please fill out all fields", type: "error" }) setPasswordModalVisible(false) @@ -140,13 +137,11 @@ const Post = ({ return } - const cookieName = getCookie(USER_COOKIE_NAME) - await sendRequest("/api/posts/create", { + await sendRequest("/api/post", { title, files: docs, visibility, password, - userId: cookieName ? String(getCookie(USER_COOKIE_NAME)) : "", expiresAt: expiresAt || null, parentId: newPostParent }) @@ -260,6 +255,7 @@ const Post = ({ ) return ( + // 150 so the post dropdown doesn't overflow
<Description description={description} onChange={onChangeDescription} /> diff --git a/client/components/page-seo/index.tsx b/client/components/page-seo/index.tsx index 91368c68..9dec0100 100644 --- a/client/components/page-seo/index.tsx +++ b/client/components/page-seo/index.tsx @@ -2,7 +2,7 @@ import React from "react" type PageSeoProps = { title?: string - description?: string | null + description?: string isLoading?: boolean isPrivate?: boolean } diff --git a/client/components/post-list/index.tsx b/client/components/post-list/index.tsx index 0fa73966..9fa80784 100644 --- a/client/components/post-list/index.tsx +++ b/client/components/post-list/index.tsx @@ -121,6 +121,7 @@ const PostList = ({ morePosts, initialPosts }: Props) => { clearable placeholder="Search..." onChange={handleSearchChange} + disabled={Boolean(!posts?.length)} /> </div> {!posts && <Text type="error">Failed to load.</Text>} diff --git a/client/components/post-list/list-item.tsx b/client/components/post-list/list-item.tsx index 28574605..012638e3 100644 --- a/client/components/post-list/list-item.tsx +++ b/client/components/post-list/list-item.tsx @@ -1,4 +1,3 @@ -import NextLink from "next/link" import VisibilityBadge from "../badges/visibility-badge" import { Text, @@ -13,12 +12,13 @@ import Trash from "@geist-ui/icons/trash" import ExpirationBadge from "@components/badges/expiration-badge" import CreatedAgoBadge from "@components/badges/created-ago-badge" import Edit from "@geist-ui/icons/edit" -import { useRouter } from "next/router" +import { useRouter } from "next/navigation" import Parent from "@geist-ui/icons/arrowUpCircle" import styles from "./list-item.module.css" import Link from "@components/link" -import { PostWithFiles, File } from "@lib/server/prisma" -import { PostVisibility } from "@lib/types" +import type { PostWithFiles } from "@lib/server/prisma" +import type { PostVisibility } from "@lib/types" +import type { File } from "@lib/server/prisma" // TODO: isOwner should default to false so this can be used generically const ListItem = ({ @@ -40,6 +40,10 @@ const ListItem = ({ router.push(`/post/${post.parentId}`) } + { + console.log(post) + } + return ( <FadeIn> <li key={post.id}> @@ -49,8 +53,7 @@ const ListItem = ({ <Link colored style={{ marginRight: "var(--gap)" }} - href={`/post/[id]`} - as={`/post/${post.id}`} + href={`/post/${post.id}`} > {post.title} </Link> @@ -94,7 +97,7 @@ const ListItem = ({ </Card.Body> <Divider h="1px" my={0} /> <Card.Content> - {post.files?.map((file: File) => { + {post?.files?.map((file: File) => { return ( <div key={file.id}> <Link colored href={`/post/${post.id}#${file.title}`}> @@ -105,7 +108,7 @@ const ListItem = ({ })} </Card.Content> </Card> - </li>{" "} + </li> </FadeIn> ) } diff --git a/client/components/post-page/index.tsx b/client/components/post-page/index.tsx index 733797de..40b6e8fc 100644 --- a/client/components/post-page/index.tsx +++ b/client/components/post-page/index.tsx @@ -1,56 +1,48 @@ -import PageSeo from "@components/page-seo" +"use client" + import VisibilityBadge from "@components/badges/visibility-badge" import DocumentComponent from "@components/view-document" import styles from "./post-page.module.css" -import homeStyles from "@styles/Home.module.css" -import type { File, Post, PostVisibility } from "@lib/types" -import { - Page, - Button, - Text, - ButtonGroup, - useMediaQuery -} from "@geist-ui/core/dist" +import type { PostVisibility } from "@lib/types" +import { Button, Text, ButtonGroup, useMediaQuery } from "@geist-ui/core/dist" import { useEffect, useState } from "react" import Archive from "@geist-ui/icons/archive" import Edit from "@geist-ui/icons/edit" import Parent from "@geist-ui/icons/arrowUpCircle" import FileDropdown from "@components/file-dropdown" import ScrollToTop from "@components/scroll-to-top" -import { useRouter } from "next/router" +import { useRouter } from "next/navigation" import ExpirationBadge from "@components/badges/expiration-badge" import CreatedAgoBadge from "@components/badges/created-ago-badge" import PasswordModalPage from "./password-modal-wrapper" import VisibilityControl from "@components/badges/visibility-control" -import { USER_COOKIE_NAME } from "@lib/constants" -import { getCookie } from "cookies-next" +import { File, PostWithFiles } from "@lib/server/prisma" +import Header from "@components/header" type Props = { - post: Post + post: PostWithFiles isProtected?: boolean + isAuthor?: boolean } -const PostPage = ({ post: initialPost, isProtected }: Props) => { - const [post, setPost] = useState<Post>(initialPost) - const [visibility, setVisibility] = useState<PostVisibility>(post.visibility) +const PostPage = ({ post: initialPost, isProtected, isAuthor }: Props) => { + const [post, setPost] = useState<PostWithFiles>(initialPost) + const [visibility, setVisibility] = useState<string>(post.visibility) const [isExpired, setIsExpired] = useState( post.expiresAt ? new Date(post.expiresAt) < new Date() : null ) const [isLoading, setIsLoading] = useState(true) - const [isOwner] = useState( - post.users ? post.users[0].id === getCookie(USER_COOKIE_NAME) : false - ) const router = useRouter() const isMobile = useMediaQuery("mobile") useEffect(() => { - if (!isOwner && isExpired) { + if (!isAuthor && isExpired) { router.push("/expired") } const expirationDate = new Date(post.expiresAt ? post.expiresAt : "") - if (!isOwner && expirationDate < new Date()) { + if (!isAuthor && expirationDate < new Date()) { router.push("/expired") } else { setIsLoading(false) @@ -66,7 +58,7 @@ const PostPage = ({ post: initialPost, isProtected }: Props) => { return () => { if (interval) clearInterval(interval) } - }, [isExpired, isOwner, post.expiresAt, post.users, router]) + }, [isExpired, isAuthor, post.expiresAt, router]) const download = async () => { if (!post.files) return @@ -92,7 +84,7 @@ const PostPage = ({ post: initialPost, isProtected }: Props) => { } const viewParentClick = () => { - router.push(`/post/${post.parent!.id}`) + router.push(`/post/${post.parentId}`) } if (isLoading) { @@ -103,77 +95,74 @@ const PostPage = ({ post: initialPost, isProtected }: Props) => { return ( <> - {!isAvailable && <PasswordModalPage setPost={setPost} />} - <Page.Content className={homeStyles.main}> - <div className={styles.header}> - <span className={styles.buttons}> - <ButtonGroup - vertical={isMobile} - marginLeft={0} - marginRight={0} - marginTop={1} - marginBottom={1} + <div className={styles.header}> + <span className={styles.buttons}> + <ButtonGroup + vertical={isMobile} + marginLeft={0} + marginRight={0} + marginTop={1} + marginBottom={1} + > + <Button + auto + icon={<Edit />} + onClick={editACopy} + style={{ textTransform: "none" }} > - <Button - auto - icon={<Edit />} - onClick={editACopy} - style={{ textTransform: "none" }} - > - Edit a Copy + Edit a Copy + </Button> + {post.parent && ( + <Button auto icon={<Parent />} onClick={viewParentClick}> + View Parent </Button> - {post.parent && ( - <Button auto icon={<Parent />} onClick={viewParentClick}> - View Parent - </Button> - )} - <Button - auto - onClick={download} - icon={<Archive />} - style={{ textTransform: "none" }} - > - Download as ZIP Archive - </Button> - <FileDropdown isMobile={isMobile} files={post.files || []} /> - </ButtonGroup> - </span> - <span className={styles.title}> - <Text h3>{post.title}</Text> - <span className={styles.badges}> - <VisibilityBadge visibility={visibility} /> - <CreatedAgoBadge createdAt={post.createdAt} /> - <ExpirationBadge postExpirationDate={post.expiresAt} /> - </span> + )} + <Button + auto + onClick={download} + icon={<Archive />} + style={{ textTransform: "none" }} + > + Download as ZIP Archive + </Button> + <FileDropdown isMobile={isMobile} files={post.files || []} /> + </ButtonGroup> + </span> + <span className={styles.title}> + <Text h3>{post.title}</Text> + <span className={styles.badges}> + <VisibilityBadge visibility={visibility} /> + <CreatedAgoBadge createdAt={post.createdAt} /> + <ExpirationBadge postExpirationDate={post.expiresAt} /> </span> + </span> + </div> + {post.description && ( + <div> + <Text p>{post.description}</Text> </div> - {post.description && ( - <div> - <Text p>{post.description}</Text> - </div> - )} - {/* {post.files.length > 1 && <FileTree files={post.files} />} */} - {post.files?.map(({ id, content, title }: File) => ( - <DocumentComponent - key={id} - title={title} - initialTab={"preview"} - id={id} - content={content} + )} + {/* {post.files.length > 1 && <FileTree files={post.files} />} */} + {post.files?.map(({ id, content, title }: File) => ( + <DocumentComponent + key={id} + title={title} + initialTab={"preview"} + id={id} + content={content} + /> + ))} + {isAuthor && ( + <span className={styles.controls}> + <VisibilityControl + postId={post.id} + visibility={visibility} + setVisibility={setVisibility} /> - ))} - {isOwner && ( - <span className={styles.controls}> - <VisibilityControl - postId={post.id} - visibility={visibility} - setVisibility={setVisibility} - /> - </span> - )} - <ScrollToTop /> - </Page.Content> + </span> + )} + <ScrollToTop /> </> ) } diff --git a/client/components/post-page/password-modal-wrapper.tsx b/client/components/post-page/password-modal-wrapper.tsx index cf5bf142..405ebc05 100644 --- a/client/components/post-page/password-modal-wrapper.tsx +++ b/client/components/post-page/password-modal-wrapper.tsx @@ -1,21 +1,22 @@ import PasswordModal from "@components/new-post/password-modal" -import { Page, useToasts } from "@geist-ui/core/dist" -import { Post } from "@lib/types" -import { useRouter } from "next/router" +import { useToasts } from "@geist-ui/core/dist" +import { Post } from "@lib/server/prisma" +import { useRouter } from "next/navigation" import { useState } from "react" type Props = { setPost: (post: Post) => void + postId: Post["id"] } -const PasswordModalPage = ({ setPost }: Props) => { +const PasswordModalPage = ({ setPost, postId }: Props) => { const router = useRouter() const { setToast } = useToasts() const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(true) const onSubmit = async (password: string) => { const res = await fetch( - `/server-api/posts/authenticate?id=${router.query.id}&password=${password}`, + `/api/posts/authenticate?id=${postId}&password=${password}`, { method: "GET", headers: { diff --git a/client/components/settings/sections/profile.tsx b/client/components/settings/sections/profile.tsx index 055c840d..dae4d1c7 100644 --- a/client/components/settings/sections/profile.tsx +++ b/client/components/settings/sections/profile.tsx @@ -7,17 +7,10 @@ import { User } from "next-auth" import { useEffect, useState } from "react" const Profile = ({ user }: { user: User }) => { - const [name, setName] = useState<string>() - const [email, setEmail] = useState<string>() + const [name, setName] = useState<string>(user.name || "") + const [email, setEmail] = useState<string>(user.email || "") const [bio, setBio] = useState<string>() - useEffect(() => { - console.log(user) - // if (user?.displayName) setName(user.displayName) - if (user?.email) setEmail(user.email) - // if (user?.bio) setBio(user.bio) - }, [user]) - const { setToast } = useToasts() const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => { diff --git a/client/lib/api-middleware/with-methods.ts b/client/lib/api-middleware/with-methods.ts new file mode 100644 index 00000000..3df37312 --- /dev/null +++ b/client/lib/api-middleware/with-methods.ts @@ -0,0 +1,13 @@ +// https://github.com/shadcn/taxonomy/ + +import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next" + +export function withMethods(methods: string[], handler: NextApiHandler) { + return async function (req: NextApiRequest, res: NextApiResponse) { + if (!req.method || !methods.includes(req.method)) { + return res.status(405).end() + } + + return handler(req, res) + } +} diff --git a/client/lib/api-middleware/with-validation.ts b/client/lib/api-middleware/with-validation.ts new file mode 100644 index 00000000..f5da8203 --- /dev/null +++ b/client/lib/api-middleware/with-validation.ts @@ -0,0 +1,41 @@ +import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next" +import * as z from "zod" +import type { ZodSchema, ZodType } from "zod" + +type NextApiRequestWithParsedBody<T> = NextApiRequest & { + parsedBody?: T +} + +export type NextApiHandlerWithParsedBody<T> = ( + req: NextApiRequestWithParsedBody<T>, + res: NextApiResponse +) => ReturnType<NextApiHandler> + +export function withValidation<T extends ZodSchema>( + schema: T, + handler: NextApiHandler +): ( + req: NextApiRequest, + res: NextApiResponse +) => Promise<void | NextApiResponse<any> | NextApiHandlerWithParsedBody<T>> { + return async function (req: NextApiRequest, res: NextApiResponse) { + try { + const body = req.body + + await schema.parseAsync(body) + + ;(req as NextApiRequestWithParsedBody<T>).parsedBody = body + + return handler(req, res) as Promise<NextApiHandlerWithParsedBody<T>> + } catch (error) { + if (process.env.NODE_ENV === "development") { + console.error(error) + } + if (error instanceof z.ZodError) { + return res.status(422).json(error.issues) + } + + return res.status(422).end() + } + } +} diff --git a/client/lib/server/auth.ts b/client/lib/server/auth.ts index 9a702faa..add5d193 100644 --- a/client/lib/server/auth.ts +++ b/client/lib/server/auth.ts @@ -44,6 +44,7 @@ export const authOptions: NextAuthOptions = { // TODO: user should be defined? if (user) { token.id = user.id + token.role = "user" } return token } diff --git a/client/lib/server/get-html-from-drift-file.ts b/client/lib/server/get-html-from-drift-file.ts index bee7c351..5cee8216 100644 --- a/client/lib/server/get-html-from-drift-file.ts +++ b/client/lib/server/get-html-from-drift-file.ts @@ -28,9 +28,13 @@ export function getHtmlFromFile({ let contentToRender: string = content || "" if (!renderAsMarkdown.includes(type)) { - contentToRender = `~~~${type} + contentToRender = ` + +~~~${type} ${content} -~~~` +~~~ + +` } else { contentToRender = "\n" + content } diff --git a/client/lib/server/jwt.ts b/client/lib/server/jwt.ts index f6343eb4..418d9c92 100644 --- a/client/lib/server/jwt.ts +++ b/client/lib/server/jwt.ts @@ -47,11 +47,11 @@ export async function withJwt( select: { id: true, email: true, - displayName: true, - bio: true, - createdAt: true, - updatedAt: true, - deletedAt: true + // displayName: true, + // bio: true, + // createdAt: true, + // updatedAt: true, + // deletedAt: true } }) if (!userObj) { diff --git a/client/lib/server/prisma.ts b/client/lib/server/prisma.ts index 6933238b..1b887c65 100644 --- a/client/lib/server/prisma.ts +++ b/client/lib/server/prisma.ts @@ -4,10 +4,45 @@ declare global { import config from "@lib/config" import { Post, PrismaClient, File, User } from "@prisma/client" +import { cache } from "react" import { generateAndExpireAccessToken } from "./generate-access-token" const prisma = new PrismaClient() +// we want to update iff they exist the createdAt/updated/expired/deleted items +// the input could be an array, in which case we'd check each item in the array +// if it's an object, we'd check that object +// then we return the changed object or array + +const updateDateForItem = (item: any) => { + if (item.createdAt) { + item.createdAt = item.createdAt.toISOString() + } + if (item.updatedAt) { + item.updatedAt = item.updatedAt.toISOString() + } + if (item.expiresAt) { + item.expiresAt = item.expiresAt.toISOString() + } + if (item.deletedAt) { + item.deletedAt = item.deletedAt.toISOString() + } + return item +} + +const updateDates = (input: any) => { + if (Array.isArray(input)) { + return input.map((item) => updateDateForItem(item)) + } else { + return updateDateForItem(input) + } +} + +prisma.$use(async (params, next) => { + const result = await next(params) + return updateDates(result) +}) + export default prisma // https://next-auth.js.org/adapters/prisma @@ -30,42 +65,14 @@ export const getFilesForPost = async (postId: string) => { return files } -/** - * When passed in a postId, fetches the post and then the files. - * If passed a Post, it will fetch the files - * @param postIdOrPost Post or postId - * @returns Promise<PostWithFiles> - */ -export async function getPostWithFiles(postId: string): Promise<PostWithFiles> -export async function getPostWithFiles(postObject: Post): Promise<PostWithFiles> -export async function getPostWithFiles( - postIdOrObject: string | Post -): Promise<PostWithFiles | undefined> { - let post: Post | null - if (typeof postIdOrObject === "string") { - post = await prisma.post.findUnique({ - where: { - id: postIdOrObject - } - }) - } else { - post = postIdOrObject - } +export async function getFilesByPost(postId: string) { + const files = await prisma.file.findMany({ + where: { + postId + } + }) - if (!post) { - return undefined - } - - const files = await getFilesForPost(post.id) - - if (!files) { - return undefined - } - - return { - ...post, - files - } + return files } export async function getPostsByUser(userId: string): Promise<Post[]> @@ -77,23 +84,12 @@ export async function getPostsByUser(userId: User["id"], withFiles?: boolean) { const posts = await prisma.post.findMany({ where: { authorId: userId + }, + include: { + files: withFiles } }) - if (withFiles) { - const postsWithFiles = await Promise.all( - posts.map(async (post) => { - const files = await getPostWithFiles(post) - return { - ...post, - files - } - }) - ) - - return postsWithFiles - } - return posts } @@ -127,7 +123,11 @@ export const isUserAdmin = async (userId: User["id"]) => { return user?.role?.toLowerCase() === "admin" } -export const createUser = async (username: string, password: string, serverPassword?: string) => { +export const createUser = async ( + username: string, + password: string, + serverPassword?: string +) => { if (!username || !password) { throw new Error("Missing param") } @@ -141,9 +141,10 @@ export const createUser = async (username: string, password: string, serverPassw } // const salt = await genSalt(10) - + // the first user is the admin - const isUserAdminByDefault = config.enable_admin && (await prisma.user.count()) === 0 + const isUserAdminByDefault = + config.enable_admin && (await prisma.user.count()) === 0 const userRole = isUserAdminByDefault ? "admin" : "user" // const user = await prisma.user.create({ @@ -162,10 +163,14 @@ export const createUser = async (username: string, password: string, serverPassw } } -export const getPostById = async (postId: Post["id"]) => { +export const getPostById = async (postId: Post["id"], withFiles = false) => { + console.log("getPostById", postId) const post = await prisma.post.findUnique({ where: { id: postId + }, + include: { + files: withFiles } }) diff --git a/client/lib/server/session.ts b/client/lib/server/session.ts index dde592c3..7ea8a776 100644 --- a/client/lib/server/session.ts +++ b/client/lib/server/session.ts @@ -1,5 +1,4 @@ -import 'server-only'; import { unstable_getServerSession } from "next-auth/next" import { authOptions } from "./auth" diff --git a/client/lib/time-ago.ts b/client/lib/time-ago.ts index 66e452c7..0688d9c4 100644 --- a/client/lib/time-ago.ts +++ b/client/lib/time-ago.ts @@ -10,7 +10,6 @@ const epochs = [ ["second", 1] ] as const -// Get duration const getDuration = (timeAgoInSeconds: number) => { for (let [name, seconds] of epochs) { const interval = Math.floor(timeAgoInSeconds / seconds) diff --git a/client/lib/validations/post.ts b/client/lib/validations/post.ts new file mode 100644 index 00000000..5c870447 --- /dev/null +++ b/client/lib/validations/post.ts @@ -0,0 +1,18 @@ +import { z } from "zod" + +export const CreatePostSchema = z.object({ + title: z.string(), + description: z.string(), + files: z.array(z.object({ + title: z.string(), + content: z.string(), + })), + visibility: z.string(), + password: z.string().optional(), + expiresAt: z.number().optional().nullish(), + parentId: z.string().optional() +}) + +export const DeletePostSchema = z.object({ + id: z.string() +}) diff --git a/client/package.json b/client/package.json index c6992f16..4e2443cf 100644 --- a/client/package.json +++ b/client/package.json @@ -25,6 +25,7 @@ "marked": "^4.2.2", "next": "13.0.3-canary.4", "next-auth": "^4.16.4", + "next-joi": "^2.2.1", "next-themes": "npm:@wits/next-themes@0.2.7", "prism-react-renderer": "^1.3.5", "rc-table": "7.24.1", diff --git a/client/pages/api/file/get-html.ts b/client/pages/api/file/get-html.ts index 451140f6..cfadb1a6 100644 --- a/client/pages/api/file/get-html.ts +++ b/client/pages/api/file/get-html.ts @@ -1,52 +1,47 @@ +import { withMethods } from "@lib/api-middleware/with-methods" import { getHtmlFromFile } from "@lib/server/get-html-from-drift-file" import { parseQueryParam } from "@lib/server/parse-query-param" import prisma from "@lib/server/prisma" import { NextApiRequest, NextApiResponse } from "next" -export default async function handler( +export default withMethods(["GET"], ( req: NextApiRequest, res: NextApiResponse -) { - switch (req.method) { - case "GET": - const query = req.query - const fileId = parseQueryParam(query.fileId) - const content = parseQueryParam(query.content) - const title = parseQueryParam(query.title) +) => { + const query = req.query + const fileId = parseQueryParam(query.fileId) + const content = parseQueryParam(query.content) + const title = parseQueryParam(query.title) - if (fileId && (content || title)) { - return res.status(400).json({ error: "Too many arguments" }) + if (fileId && (content || title)) { + return res.status(400).json({ error: "Too many arguments" }) + } + + if (fileId) { + const file = await prisma.file.findUnique({ + where: { + id: fileId } + }) - if (fileId) { - // TODO: abstract to getFileById - const file = await prisma.file.findUnique({ - where: { - id: fileId - } - }) + if (!file) { + return res.status(404).json({ error: "File not found" }) + } - if (!file) { - return res.status(404).json({ error: "File not found" }) - } + return res.json(file.html) + } else { + if (!content || !title) { + return res.status(400).json({ error: "Missing arguments" }) + } - return res.json(file.html) - } else { - if (!content || !title) { - return res.status(400).json({ error: "Missing arguments" }) - } + const renderedHTML = getHtmlFromFile({ + title, + content + }) - const renderedHTML = getHtmlFromFile({ - title, - content - }) - - res.setHeader("Content-Type", "text/plain") - res.status(200).write(renderedHTML) - res.end() - return - } - default: - return res.status(405).json({ error: "Method not allowed" }) + res.setHeader("Content-Type", "text/plain") + res.status(200).write(renderedHTML) + res.end() + return } } diff --git a/client/pages/api/file/html/[id].ts b/client/pages/api/file/html/[id].ts index 6e07d360..dcc1aa28 100644 --- a/client/pages/api/file/html/[id].ts +++ b/client/pages/api/file/html/[id].ts @@ -1,24 +1,22 @@ import { NextApiRequest, NextApiResponse } from "next" +import prisma from "lib/server/prisma" +import { parseQueryParam } from "@lib/server/parse-query-param" const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => { - const { id } = req.query - const file = await fetch(`${process.env.API_URL}/files/html/${id}`, { - headers: { - "x-secret-key": process.env.SECRET_KEY || "", - Authorization: `Bearer ${req.cookies["drift-token"]}` + const file = await prisma.file.findUnique({ + where: { + id: parseQueryParam(req.query.id) } }) - if (file.ok) { - const json = await file.text() - const data = json - // serve the file raw as plain text - res.setHeader("Content-Type", "text/plain; charset=utf-8") - res.setHeader("Cache-Control", "s-maxage=86400") - res.status(200).write(data, "utf-8") - res.end() - } else { - res.status(404).send("File not found") + + if (!file) { + return res.status(404).end() } + + res.setHeader("Content-Type", "text/plain") + res.setHeader("Cache-Control", "public, max-age=4800") + console.log(file.html) + return res.status(200).write(file.html) } export default getRawFile diff --git a/client/pages/api/post/index.ts b/client/pages/api/post/index.ts new file mode 100644 index 00000000..b63d1145 --- /dev/null +++ b/client/pages/api/post/index.ts @@ -0,0 +1,136 @@ +// nextjs typescript api handler + +import { withCurrentUser } from "@lib/api-middleware/with-current-user" +import { withMethods } from "@lib/api-middleware/with-methods" +import { + NextApiHandlerWithParsedBody, + withValidation +} from "@lib/api-middleware/with-validation" +import { authOptions } from "@lib/server/auth" +import { CreatePostSchema } from "@lib/validations/post" +import { Post } from "@prisma/client" +import prisma, { getPostById } from "lib/server/prisma" +import { NextApiRequest, NextApiResponse } from "next" +import { unstable_getServerSession } from "next-auth/next" +import { File } from "lib/server/prisma" +import * as crypto from "crypto" +import { getHtmlFromFile } from "@lib/server/get-html-from-drift-file" +import { getSession } from "next-auth/react" +import { parseQueryParam } from "@lib/server/parse-query-param" + +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method === "POST") { + return await handlePost(req, res) + } else { + return await handleGet(req, res) + } +} + +export default withMethods(["POST", "GET"], handler) + +async function handlePost(req: NextApiRequest, res: NextApiResponse<any>) { + try { + const session = await unstable_getServerSession(req, res, authOptions) + + const files = req.body.files as File[] + const fileTitles = files.map((file) => file.title) + const missingTitles = fileTitles.filter((title) => title === "") + if (missingTitles.length > 0) { + throw new Error("All files must have a title") + } + + if (files.length === 0) { + throw new Error("You must submit at least one file") + } + + let hashedPassword: string = "" + if (req.body.visibility === "protected") { + hashedPassword = crypto + .createHash("sha256") + .update(req.body.password) + .digest("hex") + } + + const postFiles = files.map((file) => { + const html = getHtmlFromFile(file) + + return { + title: file.title, + content: file.content, + sha: crypto + .createHash("sha256") + .update(file.content) + .digest("hex") + .toString(), + html: html, + userId: session?.user.id + // postId: post.id + } + }) as File[] + + const post = await prisma.post.create({ + data: { + title: req.body.title, + description: req.body.description, + visibility: req.body.visibility, + password: hashedPassword, + expiresAt: req.body.expiresAt, + // authorId: session?.user.id, + author: { + connect: { + id: session?.user.id + } + }, + files: { + create: postFiles + } + } + }) + + return res.json(post) + } catch (error) { + return res.status(500).json(error) + } +} + +async function handleGet(req: NextApiRequest, res: NextApiResponse<any>) { + const id = parseQueryParam(req.query.id) + const files = req.query.files ? parseQueryParam(req.query.files) : true + + if (!id) { + return res.status(400).json({ error: "Missing id" }) + } + + const post = await getPostById(id, Boolean(files)) + + if (!post) { + return res.status(404).json({ message: "Post not found" }) + } + + if (post.visibility === "public") { + res.setHeader("Cache-Control", "s-maxage=86400, stale-while-revalidate") + return res.json(post) + } else if (post.visibility === "unlisted") { + res.setHeader("Cache-Control", "s-maxage=1, stale-while-revalidate") + } + + const session = await getSession({ req }) + + // the user can always go directly to their own post + if (session?.user.id === post.authorId) { + return res.json(post) + } + + if (post.visibility === "protected") { + return { + isProtected: true, + post: { + id: post.id, + visibility: post.visibility, + title: post.title + } + } + } + + return res.status(404).json({ message: "Post not found" }) +} diff --git a/client/pnpm-lock.yaml b/client/pnpm-lock.yaml index 1126ac91..6997f362 100644 --- a/client/pnpm-lock.yaml +++ b/client/pnpm-lock.yaml @@ -26,6 +26,7 @@ specifiers: marked: ^4.2.2 next: 13.0.3-canary.4 next-auth: ^4.16.4 + next-joi: ^2.2.1 next-themes: npm:@wits/next-themes@0.2.7 next-unused: 0.0.6 prettier: 2.6.2 @@ -61,6 +62,7 @@ dependencies: marked: 4.2.2 next: 13.0.3-canary.4_biqbaboplfbrettd7655fr4n2y next-auth: 4.16.4_hsmqkug4agizydugca45idewda + next-joi: 2.2.1_next@13.0.3-canary.4 next-themes: /@wits/next-themes/0.2.7_hsmqkug4agizydugca45idewda prism-react-renderer: 1.3.5_react@18.2.0 rc-table: 7.24.1_biqbaboplfbrettd7655fr4n2y @@ -2732,6 +2734,15 @@ packages: uuid: 8.3.2 dev: false + /next-joi/2.2.1_next@13.0.3-canary.4: + resolution: {integrity: sha512-m6/rDj9a9sp0CeMGy3np/7T2663QFinfiTY4MuJ9LEicU+6SiDim4wnsqG5CfzI4IQX4tupN6jSCtsv0t2EWnQ==} + peerDependencies: + joi: '>=17.1.1' + next: '>=9.5.1' + dependencies: + next: 13.0.3-canary.4_biqbaboplfbrettd7655fr4n2y + dev: false + /next-unused/0.0.6: resolution: {integrity: sha512-dHFNNBanFq4wvYrULtsjfWyZ6BzOnr5VYI9EYMGAZYF2vkAhFpj2JOuT5Wu2o3LbFSG92PmAZnSUF/LstF82pA==} hasBin: true diff --git a/client/prisma/schema.prisma b/client/prisma/schema.prisma index c008285c..f5f64c76 100644 --- a/client/prisma/schema.prisma +++ b/client/prisma/schema.prisma @@ -57,7 +57,7 @@ model Post { parentId String? description String? author User? @relation(fields: [authorId], references: [id]) - authorId String? + authorId String files File[] @@map("posts") diff --git a/client/styles/globals.css b/client/styles/globals.css index a914c885..2fa1eb06 100644 --- a/client/styles/globals.css +++ b/client/styles/globals.css @@ -161,3 +161,9 @@ code { #__next { isolation: isolate; } + +/* TODO: this should not be necessary. */ +main { + margin-top: 0 !important; + padding-top: 0 !important; +}