From f9e9c6fe068c84d13bfb6b1b59b7ff0b3c30d6fb Mon Sep 17 00:00:00 2001 From: Max Leiter Date: Fri, 11 Mar 2022 18:48:40 -0800 Subject: [PATCH] client: add downloading and viewing raw files (#21) --- README.md | 8 ++-- client/components/Link.tsx | 3 +- client/components/auth/index.tsx | 4 +- .../components/document/document.module.css | 10 ++++ .../{ => document}/formatting-icons/index.tsx | 8 ++-- client/components/document/index.tsx | 48 +++++++++++++++++-- client/components/header/index.tsx | 1 + client/components/my-posts/index.tsx | 2 +- .../new-post/drag-and-drop/index.tsx | 46 +++++++++--------- client/components/new-post/index.tsx | 2 +- client/lib/hooks/use-signed-in.ts | 2 +- client/next.config.js | 6 ++- client/package.json | 1 + client/pages/api/raw/[id].ts | 24 ++++++++++ client/pages/post/[id].tsx | 30 ++++++++++-- client/yarn.lock | 5 ++ server/src/app.ts | 3 +- server/src/routes/files.ts | 29 +++++++++++ server/src/routes/index.ts | 1 + server/src/routes/posts.ts | 5 +- 20 files changed, 189 insertions(+), 49 deletions(-) rename client/components/{ => document}/formatting-icons/index.tsx (97%) create mode 100644 client/pages/api/raw/[id].ts create mode 100644 server/src/routes/files.ts diff --git a/README.md b/README.md index 4106b5d4..289fb9e5 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ # Drift - -Drift is a self-hostable Gist clone. It's also a major work-in-progress, but is (almost, no database yet) completely functional. +Drift is a self-hostable Gist clone. It's also a major work-in-progress, but is (almost, no database yet) completely functional. You can try a demo at https://drift.maxleiter.com. The demo is built on master but has no database, so files and accounts can be wiped at any time. -If you want to contribute, need support, or want to stay updated, you can join the IRC channel at #drift on irc.libera.chat or [reach me on twitter](https://twitter.com/Max_Leiter). If you don't have an IRC client yet, you can use a webclient [here](https://demo.thelounge.chat/#/connect?join=%23drift&nick=drift-user&realname=Drift%20User). +If you want to contribute, need support, or want to stay updated, you can join the IRC channel at #drift on irc.libera.chat or [reach me on twitter](https://twitter.com/Max_Leiter). If you don't have an IRC client yet, you can use a webclient [here](https://demo.thelounge.chat/#/connect?join=%23drift&nick=drift-user&realname=Drift%20User). ## Current status + Drit is a major work in progress. Below is a (rough) list of completed and envisioned features. If you want to help address any of them, please let me know regardless of your experience and I'll be happy to assist. - [x] creating and sharing private, public, unlisted posts @@ -17,7 +17,7 @@ Drit is a major work in progress. Below is a (rough) list of completed and envis - [x] responsive UI - [x] user auth - [ ] SSO via HTTP header (Issue: [#11](https://github.com/MaxLeiter/Drift/issues/11)) -- [ ] downloading files (individually and entire posts) +- [x] downloading files (individually and entire posts) - [ ] password protected posts - [ ] sqlite database (should be very easy to set-up; the ORM is just currently set to memory for ease of development) - [ ] non-node backend diff --git a/client/components/Link.tsx b/client/components/Link.tsx index 5bc8772f..97a285ec 100644 --- a/client/components/Link.tsx +++ b/client/components/Link.tsx @@ -3,9 +3,8 @@ import { useRouter } from "next/router"; const Link = (props: LinkProps) => { const { basePath } = useRouter(); - const propHrefWithoutLeadingSlash = props.href && props.href.startsWith("/") ? props.href.substr(1) : props.href; + const propHrefWithoutLeadingSlash = props.href && props.href.startsWith("/") ? props.href.substring(1) : props.href; const href = basePath ? `${basePath}/${propHrefWithoutLeadingSlash}` : props.href; - (href) return } diff --git a/client/components/auth/index.tsx b/client/components/auth/index.tsx index 9040c56b..d56f991b 100644 --- a/client/components/auth/index.tsx +++ b/client/components/auth/index.tsx @@ -35,7 +35,7 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => { e.preventDefault() if (signingIn) { try { - const resp = await fetch('/api/auth/signin', reqOpts) + const resp = await fetch('/server-api/auth/signin', reqOpts) const json = await resp.json() handleJson(json) } catch (err: any) { @@ -43,7 +43,7 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => { } } else { try { - const resp = await fetch('/api/auth/signup', reqOpts) + const resp = await fetch('/server-api/auth/signup', reqOpts) const json = await resp.json() handleJson(json) } catch (err: any) { diff --git a/client/components/document/document.module.css b/client/components/document/document.module.css index 60f6e539..a062e667 100644 --- a/client/components/document/document.module.css +++ b/client/components/document/document.module.css @@ -29,3 +29,13 @@ .textarea { height: 100%; } + +.actionWrapper { + position: relative; + z-index: 1; +} + +.actionWrapper .actions { + position: absolute; + right: 0; +} diff --git a/client/components/formatting-icons/index.tsx b/client/components/document/formatting-icons/index.tsx similarity index 97% rename from client/components/formatting-icons/index.tsx rename to client/components/document/formatting-icons/index.tsx index a7c91787..d5e15fe1 100644 --- a/client/components/formatting-icons/index.tsx +++ b/client/components/document/formatting-icons/index.tsx @@ -1,6 +1,7 @@ import { ButtonGroup, Button } from "@geist-ui/core" import { Bold, Italic, Link, Image as ImageIcon } from '@geist-ui/icons' import { RefObject, useCallback, useMemo } from "react" +import styles from '../document.module.css' // TODO: clean up @@ -122,11 +123,8 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject - +
+ {formattingActions.map(({ icon, name, action }) => (
) +} + + +const Document = ({ remove, editable, title, content, setTitle, setContent, initialTab = 'edit', skeleton, id }: Props) => { const codeEditorRef = useRef(null) const [tab, setTab] = useState(initialTab) const height = editable ? "500px" : '100%' @@ -47,6 +77,13 @@ const Document = ({ remove, editable, title, content, setTitle, setContent, init } } } + + const rawLink = useMemo(() => { + if (id) { + return `/file/raw/${id}` + } + }, [id]) + if (skeleton) { return <> @@ -82,6 +119,7 @@ const Document = ({ remove, editable, title, content, setTitle, setContent, init
{tab === 'edit' && editable && } + {rawLink && } {/* */} diff --git a/client/components/header/index.tsx b/client/components/header/index.tsx index e2eea796..ca03fdb0 100644 --- a/client/components/header/index.tsx +++ b/client/components/header/index.tsx @@ -175,6 +175,7 @@ const Header = ({ changeTheme, theme }: DriftProps) => { auto type="abort" onClick={() => setExpanded(!expanded)} + aria-label="Menu" > diff --git a/client/components/my-posts/index.tsx b/client/components/my-posts/index.tsx index a4352840..c3394f2c 100644 --- a/client/components/my-posts/index.tsx +++ b/client/components/my-posts/index.tsx @@ -9,7 +9,7 @@ const fetcher = (url: string) => fetch(url, { }).then(r => r.json()) const MyPosts = () => { - const { data, error } = useSWR('/api/users/mine', fetcher) + const { data, error } = useSWR('/server-api/users/mine', fetcher) return } diff --git a/client/components/new-post/drag-and-drop/index.tsx b/client/components/new-post/drag-and-drop/index.tsx index 9e5eeab4..770d99c3 100644 --- a/client/components/new-post/drag-and-drop/index.tsx +++ b/client/components/new-post/drag-and-drop/index.tsx @@ -94,35 +94,37 @@ const allowedFileExtensions = [ 'webmanifest', ] -// TODO: this shouldn't need to know about docs -function FileDropzone({ setDocs, docs }: { setDocs: (docs: Document[]) => void, docs: Document[] }) { +function FileDropzone({ setDocs, docs }: { setDocs: React.Dispatch>, docs: Document[] }) { const { palette } = useTheme() - const onDrop = useCallback((acceptedFiles) => { - acceptedFiles.forEach((file: File) => { - const reader = new FileReader() + const { setToast } = useToasts() + const onDrop = useCallback(async (acceptedFiles) => { + const newDocs = await Promise.all(acceptedFiles.map((file: File) => { + return new Promise((resolve, reject) => { + const reader = new FileReader() - reader.onabort = () => console.log('file reading was aborted') - reader.onerror = () => console.log('file reading has failed') - reader.onload = () => { - const content = reader.result as string - if (docs.length === 1 && docs[0].content === '') { - setDocs([{ + reader.onabort = () => setToast({ text: 'File reading was aborted', type: 'error' }) + reader.onerror = () => setToast({ text: 'File reading failed', type: 'error' }) + reader.onload = () => { + const content = reader.result as string + resolve({ title: file.name, content, id: generateUUID() - }]) - } else { - setDocs([...docs, { - title: file.name, - content, - id: generateUUID() - }]) + }) } - } - reader.readAsText(file) - }) + reader.readAsText(file) + }) + })) - }, [docs, setDocs]) + if (docs.length === 1) { + if (docs[0].content === '') { + setDocs(newDocs) + return + } + } + + setDocs((oldDocs) => [...oldDocs, ...newDocs]) + }, [setDocs, setToast, docs]) const validator = (file: File) => { // TODO: make this configurable diff --git a/client/components/new-post/index.tsx b/client/components/new-post/index.tsx index aaaa1cf5..e26b47d6 100644 --- a/client/components/new-post/index.tsx +++ b/client/components/new-post/index.tsx @@ -31,7 +31,7 @@ const Post = () => { const onSubmit = async (visibility: string) => { setSubmitting(true) - const response = await fetch('/api/posts/create', { + const response = await fetch('/server-api/posts/create', { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/client/lib/hooks/use-signed-in.ts b/client/lib/hooks/use-signed-in.ts index cfede715..e1ec3c45 100644 --- a/client/lib/hooks/use-signed-in.ts +++ b/client/lib/hooks/use-signed-in.ts @@ -16,7 +16,7 @@ const useSignedIn = ({ redirectIfNotAuthed = false }: { redirectIfNotAuthed?: bo async function checkToken() { const token = localStorage.getItem('drift-token') if (token) { - const response = await fetch('/api/auth/verify-token', { + const response = await fetch('/server-api/auth/verify-token', { method: 'GET', headers: { 'Authorization': `Bearer ${token}` diff --git a/client/next.config.js b/client/next.config.js index 3aaadd88..16eb5923 100644 --- a/client/next.config.js +++ b/client/next.config.js @@ -10,9 +10,13 @@ const nextConfig = { async rewrites() { return [ { - source: "/api/:path*", + source: "/server-api/:path*", destination: `${process.env.API_URL}/:path*`, }, + { + source: "/file/raw/:id", + destination: `/api/raw/:id`, + }, ]; }, }; diff --git a/client/package.json b/client/package.json index 71d47a7a..908d2541 100644 --- a/client/package.json +++ b/client/package.json @@ -12,6 +12,7 @@ "@fec/remark-a11y-emoji": "^3.1.0", "@geist-ui/core": "^2.3.5", "@geist-ui/icons": "^1.0.1", + "client-zip": "^2.0.0", "comlink": "^4.3.1", "dotenv": "^16.0.0", "next": "12.1.0", diff --git a/client/pages/api/raw/[id].ts b/client/pages/api/raw/[id].ts new file mode 100644 index 00000000..62ef2745 --- /dev/null +++ b/client/pages/api/raw/[id].ts @@ -0,0 +1,24 @@ +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}`) + 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 { + res.setHeader("Content-Disposition", `inline; filename="${title}"`) + } + + res.status(200).send(content) + } else { + res.status(404).send("File not found") + } +} + +export default getRawFile diff --git a/client/pages/post/[id].tsx b/client/pages/post/[id].tsx index d44254d5..5a868903 100644 --- a/client/pages/post/[id].tsx +++ b/client/pages/post/[id].tsx @@ -1,4 +1,4 @@ -import { Page, Text } from "@geist-ui/core"; +import { Button, Page, Text } from "@geist-ui/core"; import Skeleton from 'react-loading-skeleton'; import { useRouter } from "next/router"; @@ -19,7 +19,7 @@ const Post = ({ theme, changeTheme }: ThemeProps) => { async function fetchPost() { setIsLoading(true); if (router.query.id) { - const post = await fetch(`/api/posts/${router.query.id}`, { + const post = await fetch(`/server-api/posts/${router.query.id}`, { method: "GET", headers: { "Content-Type": "application/json", @@ -46,6 +46,23 @@ const Post = ({ theme, changeTheme }: ThemeProps) => { fetchPost() }, [router, router.query.id]) + 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 ( @@ -62,10 +79,17 @@ const Post = ({ theme, changeTheme }: ThemeProps) => { {!error && isLoading && <> } - {!isLoading && post && <>{post.title} + {!isLoading && post && <> +
+ {post.title} + +
{post.files.map(({ id, content, title }: { id: any, content: string, title: string }) => ( { + try { + const file = await File.findOne({ + where: { + id: req.params.id + }, + 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/index.ts b/server/src/routes/index.ts index 506eedb7..d4922148 100644 --- a/server/src/routes/index.ts +++ b/server/src/routes/index.ts @@ -1,3 +1,4 @@ export { auth } from './auth'; export { posts } from './posts'; export { users } from './users'; +export { files } from './files'; diff --git a/server/src/routes/posts.ts b/server/src/routes/posts.ts index 84d8f681..a528f310 100644 --- a/server/src/routes/posts.ts +++ b/server/src/routes/posts.ts @@ -69,7 +69,7 @@ posts.get("/:id", async (req: UserJwtRequest, res, next) => { { model: File, as: "files", - attributes: ["id", "title", "content", "sha"], + attributes: ["id", "title", "content", "sha", "createdAt", "updatedAt"], }, { model: User, @@ -80,8 +80,11 @@ posts.get("/:id", async (req: UserJwtRequest, res, next) => { }) if (post?.visibility === 'public' || post?.visibility === 'unlisted') { + res.setHeader("Cache-Control", "public, max-age=86400"); res.json(post); } else { + // TODO: should this be `private, `? + res.setHeader("Cache-Control", "max-age=86400"); jwt(req, res, () => { res.json(post); });