From ecd06a2258af28a95960d6f1c2da25de205f8020 Mon Sep 17 00:00:00 2001 From: Max Leiter Date: Mon, 21 Mar 2022 18:51:19 -0700 Subject: [PATCH] client: finish protected posts --- client/components/header/index.tsx | 6 +- client/components/new-post/index.tsx | 13 +- client/components/new-post/password/index.tsx | 5 +- client/lib/hooks/use-signed-in.ts | 7 +- client/lib/types.d.ts | 16 +++ client/pages/_app.tsx | 1 - client/pages/post/[id].tsx | 18 +-- client/pages/post/protected/[id].tsx | 134 ++++++++++++++++++ server/src/routes/posts.ts | 29 +++- 9 files changed, 195 insertions(+), 34 deletions(-) create mode 100644 client/pages/post/protected/[id].tsx diff --git a/client/components/header/index.tsx b/client/components/header/index.tsx index c90291dc..cf1f37af 100644 --- a/client/components/header/index.tsx +++ b/client/components/header/index.tsx @@ -1,5 +1,4 @@ import { Page, ButtonGroup, Button, useBodyScroll, useMediaQuery, Tabs, Spacer } from "@geist-ui/core"; -import { DriftProps } from "../../pages/_app"; import { useEffect, useState } from "react"; import styles from './header.module.css'; import { useRouter } from "next/router"; @@ -15,6 +14,7 @@ import NewIcon from '@geist-ui/icons/plusCircle'; import YourIcon from '@geist-ui/icons/list' import MoonIcon from '@geist-ui/icons/moon'; import SunIcon from '@geist-ui/icons/sun'; +import type { ThemeProps } from "@lib/types"; type Tab = { name: string @@ -26,13 +26,13 @@ type Tab = { } -const Header = ({ changeTheme, theme }: DriftProps) => { +const Header = ({ changeTheme, theme }: ThemeProps) => { const router = useRouter(); const [selectedTab, setSelectedTab] = useState(router.pathname === '/' ? 'home' : router.pathname.split('/')[1]); const [expanded, setExpanded] = useState(false) const [, setBodyHidden] = useBodyScroll(null, { scrollLayer: true }) const isMobile = useMediaQuery('xs', { match: 'down' }) - const isSignedIn = useSignedIn() + const { signedIn: isSignedIn } = useSignedIn() const [pages, setPages] = useState([]) useEffect(() => { diff --git a/client/components/new-post/index.tsx b/client/components/new-post/index.tsx index a962bb8e..4c68c504 100644 --- a/client/components/new-post/index.tsx +++ b/client/components/new-post/index.tsx @@ -41,17 +41,15 @@ const Post = () => { } else { const json = await res.json() setToast({ - text: json.message, + text: json.error.message, type: 'error' }) + setPasswordModalVisible(false) + setSubmitting(false) } }, [docs, router, setToast, title]) - const closePasswordModel = () => { - setPasswordModalVisible(false) - } - const [isSubmitting, setSubmitting] = useState(false) const remove = (id: string) => { @@ -59,12 +57,12 @@ const Post = () => { } const onSubmit = async (visibility: PostVisibility, password?: string) => { - setSubmitting(true) - + console.log(visibility, password, passwordModalVisible) if (visibility === 'protected' && !password) { setPasswordModalVisible(true) return } + setSubmitting(true) await sendRequest('/server-api/posts/create', { title, @@ -77,6 +75,7 @@ const Post = () => { const onClosePasswordModal = () => { setPasswordModalVisible(false) + setSubmitting(false) } const updateTitle = useCallback((title: string, id: string) => { diff --git a/client/components/new-post/password/index.tsx b/client/components/new-post/password/index.tsx index 2305e945..fca2bba8 100644 --- a/client/components/new-post/password/index.tsx +++ b/client/components/new-post/password/index.tsx @@ -2,12 +2,13 @@ import { Input, Modal, Note, Spacer } from "@geist-ui/core" import { useState } from "react" type Props = { + warning?: boolean isOpen: boolean onClose: () => void onSubmit: (password: string) => void } -const PasswordModal = ({ isOpen, onClose, onSubmit: onSubmitAfterVerify }: Props) => { +const PasswordModal = ({ isOpen, onClose, onSubmit: onSubmitAfterVerify, warning }: Props) => { const [password, setPassword] = useState() const [confirmPassword, setConfirmPassword] = useState() const [error, setError] = useState() @@ -30,7 +31,7 @@ const PasswordModal = ({ isOpen, onClose, onSubmit: onSubmitAfterVerify }: Props { Enter a password - {!error && + {!error && warning && This doesn't protect your post from the server administrator. } {error && diff --git a/client/lib/hooks/use-signed-in.ts b/client/lib/hooks/use-signed-in.ts index c19faeb4..32884247 100644 --- a/client/lib/hooks/use-signed-in.ts +++ b/client/lib/hooks/use-signed-in.ts @@ -3,16 +3,17 @@ import { useEffect, useState } from "react"; const useSignedIn = () => { const [signedIn, setSignedIn] = useState(typeof window === 'undefined' ? false : !!Cookies.get("drift-token")); + const token = Cookies.get("drift-token") useEffect(() => { - if (Cookies.get("drift-token")) { + if (token) { setSignedIn(true); } else { setSignedIn(false); } - }, []); + }, [token]); - return signedIn; + return { signedIn, token }; } export default useSignedIn; diff --git a/client/lib/types.d.ts b/client/lib/types.d.ts index c10b748a..a6cbd13c 100644 --- a/client/lib/types.d.ts +++ b/client/lib/types.d.ts @@ -10,3 +10,19 @@ export type Document = { content: string id: string } + +type File = { + id: string + title: string + content: string +} + +type Files = File[] + +export type Post = { + id: string + title: string + description: string + visibility: PostVisibility + files: Files +} diff --git a/client/pages/_app.tsx b/client/pages/_app.tsx index 809a8834..a5aeebab 100644 --- a/client/pages/_app.tsx +++ b/client/pages/_app.tsx @@ -14,7 +14,6 @@ type AppProps

= { pageProps: P; } & Omit, "pageProps">; -export type DriftProps = ThemeProps function MyApp({ Component, pageProps }: AppProps) { const [themeType, setThemeType] = useSharedState('theme', Cookies.get('drift-theme') || 'light') diff --git a/client/pages/post/[id].tsx b/client/pages/post/[id].tsx index 20c91bc8..b181b342 100644 --- a/client/pages/post/[id].tsx +++ b/client/pages/post/[id].tsx @@ -6,24 +6,10 @@ import VisibilityBadge from "@components/visibility-badge"; import PageSeo from "components/page-seo"; import styles from './styles.module.css'; import type { GetStaticPaths, GetStaticProps } from "next"; -import { PostVisibility, ThemeProps } from "@lib/types"; - -type File = { - id: string - title: string - content: string -} - -type Files = File[] +import { Post, ThemeProps } from "@lib/types"; export type PostProps = ThemeProps & { - post: { - id: string - title: string - description: string - visibility: PostVisibility - files: Files - } + post: Post } const Post = ({ post, theme, changeTheme }: PostProps) => { diff --git a/client/pages/post/protected/[id].tsx b/client/pages/post/protected/[id].tsx new file mode 100644 index 00000000..72c49790 --- /dev/null +++ b/client/pages/post/protected/[id].tsx @@ -0,0 +1,134 @@ +import { Button, Page, Text, useToasts } 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 { Post, ThemeProps } from "@lib/types"; +import PasswordModal from "@components/new-post/password"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import Cookies from "js-cookie"; + +const Post = ({ theme, changeTheme }: ThemeProps) => { + const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(true); + const [post, setPost] = useState() + const router = useRouter() + const { setToast } = useToasts() + const download = async () => { + if (!post) return; + 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() + } + + useEffect(() => { + if (router.isReady) { + const fetchPostWithAuth = async () => { + const resp = await fetch(`/server-api/posts/${router.query.id}`, { + headers: { + Authorization: `Bearer ${Cookies.get('drift-token')}` + } + }) + if (!resp.ok) return + const post = await resp.json() + + if (!post) return + setPost(post) + } + fetchPostWithAuth() + } + }, [router.isReady, router.query.id]) + + const onSubmit = async (password: string) => { + const res = await fetch(`/server-api/posts/${router.query.id}?password=${password}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + } + }) + + if (!res.ok) { + setToast({ + type: "error", + text: "Wrong password" + }) + return + } + + const data = await res.json() + if (data) { + if (data.error) { + setToast({ + text: data.error, + type: "error" + }) + } else { + setPost(data) + setIsPasswordModalOpen(false) + } + } + } + + const onClose = () => { + setIsPasswordModalOpen(false); + } + + if (!router.isReady) { + return <> + } + + if (!post) { + return + } + + return ( + + + +

+ + + {/* {!isLoading && } */} +
+
+ {post.title} + +
+ +
+ {post.files.map(({ id, content, title }: { id: any, content: string, title: string }) => ( + + ))} +
+ + ) +} + +export default Post + diff --git a/server/src/routes/posts.ts b/server/src/routes/posts.ts index fde52b6b..23089597 100644 --- a/server/src/routes/posts.ts +++ b/server/src/routes/posts.ts @@ -27,9 +27,19 @@ posts.post('/create', jwt, async (req, res, next) => { throw new Error("Please provide a visibility.") } + if (req.body.visibility === 'protected' && !req.body.password) { + throw new Error("Please provide a password.") + } + + let hashedPassword: string = '' + if (req.body.visibility === 'protected') { + hashedPassword = crypto.createHash('sha256').update(req.body.password).digest('hex'); + } + const newPost = new Post({ title: req.body.title, visibility: req.body.visibility, + password: hashedPassword, }) await newPost.save() @@ -98,7 +108,7 @@ posts.get("/mine", jwt, secretKey, async (req: UserJwtRequest, res, next) => { } }) -posts.get("/:id", secretKey, async (req, res, next) => { +posts.get("/:id", async (req, res, next) => { try { const post = await Post.findOne({ where: { @@ -117,18 +127,33 @@ posts.get("/:id", secretKey, async (req, res, next) => { }, ] }) + if (!post) { throw new Error("Post not found.") } + if (post.visibility === 'public' || post?.visibility === 'unlisted') { - res.json(post); + secretKey(req, res, () => { + res.json(post); + }) } else if (post.visibility === 'private') { jwt(req as UserJwtRequest, res, () => { res.json(post); }) } else if (post.visibility === 'protected') { + const { password } = req.query + if (!password || typeof password !== 'string') { + return jwt(req as UserJwtRequest, res, () => { + res.json(post); + }) + } + const hash = crypto.createHash('sha256').update(password).digest('hex').toString() + if (hash !== post.password) { + return res.status(400).json({ error: "Incorrect password." }) + } + res.json(post); } } catch (e) {