client: add downloading and viewing raw files (#21)

This commit is contained in:
Max Leiter 2022-03-11 18:48:40 -08:00 committed by GitHub
parent 606e38e192
commit f9e9c6fe06
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 189 additions and 49 deletions

View file

@ -1,6 +1,5 @@
# <img src="client/public/assets/logo.png" height="32px" alt="" /> Drift # <img src="client/public/assets/logo.png" height="32px" alt="" /> 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. 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.
@ -8,6 +7,7 @@ You can try a demo at https://drift.maxleiter.com. The demo is built on master b
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 ## 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. 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 - [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] responsive UI
- [x] user auth - [x] user auth
- [ ] SSO via HTTP header (Issue: [#11](https://github.com/MaxLeiter/Drift/issues/11)) - [ ] 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 - [ ] password protected posts
- [ ] sqlite database (should be very easy to set-up; the ORM is just currently set to memory for ease of development) - [ ] sqlite database (should be very easy to set-up; the ORM is just currently set to memory for ease of development)
- [ ] non-node backend - [ ] non-node backend

View file

@ -3,9 +3,8 @@ import { useRouter } from "next/router";
const Link = (props: LinkProps) => { const Link = (props: LinkProps) => {
const { basePath } = useRouter(); 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; const href = basePath ? `${basePath}/${propHrefWithoutLeadingSlash}` : props.href;
(href)
return <GeistLink {...props} href={href} /> return <GeistLink {...props} href={href} />
} }

View file

@ -35,7 +35,7 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => {
e.preventDefault() e.preventDefault()
if (signingIn) { if (signingIn) {
try { try {
const resp = await fetch('/api/auth/signin', reqOpts) const resp = await fetch('/server-api/auth/signin', reqOpts)
const json = await resp.json() const json = await resp.json()
handleJson(json) handleJson(json)
} catch (err: any) { } catch (err: any) {
@ -43,7 +43,7 @@ const Auth = ({ page }: { page: "signup" | "signin" }) => {
} }
} else { } else {
try { try {
const resp = await fetch('/api/auth/signup', reqOpts) const resp = await fetch('/server-api/auth/signup', reqOpts)
const json = await resp.json() const json = await resp.json()
handleJson(json) handleJson(json)
} catch (err: any) { } catch (err: any) {

View file

@ -29,3 +29,13 @@
.textarea { .textarea {
height: 100%; height: 100%;
} }
.actionWrapper {
position: relative;
z-index: 1;
}
.actionWrapper .actions {
position: absolute;
right: 0;
}

View file

@ -1,6 +1,7 @@
import { ButtonGroup, Button } from "@geist-ui/core" import { ButtonGroup, Button } from "@geist-ui/core"
import { Bold, Italic, Link, Image as ImageIcon } from '@geist-ui/icons' import { Bold, Italic, Link, Image as ImageIcon } from '@geist-ui/icons'
import { RefObject, useCallback, useMemo } from "react" import { RefObject, useCallback, useMemo } from "react"
import styles from '../document.module.css'
// TODO: clean up // TODO: clean up
@ -122,11 +123,8 @@ const FormattingIcons = ({ textareaRef, setText }: { textareaRef?: RefObject<HTM
], [handleBoldClick, handleImageClick, handleItalicClick, handleLinkClick]) ], [handleBoldClick, handleImageClick, handleItalicClick, handleLinkClick])
return ( return (
<div style={{ position: 'relative', zIndex: 1 }}> <div className={styles.actionWrapper}>
<ButtonGroup style={{ <ButtonGroup className={styles.actions}>
position: 'absolute',
right: 0,
}}>
{formattingActions.map(({ icon, name, action }) => ( {formattingActions.map(({ icon, name, action }) => (
<Button auto scale={2 / 3} px={0.6} aria-label={name} key={name} icon={icon} onMouseDown={(e) => e.preventDefault()} onClick={action} /> <Button auto scale={2 / 3} px={0.6} aria-label={name} key={name} icon={icon} onMouseDown={(e) => e.preventDefault()} onClick={action} />
))} ))}

View file

@ -1,10 +1,11 @@
import { Button, Card, Input, Spacer, Tabs, Textarea } from "@geist-ui/core" import { Button, ButtonGroup, Card, Input, Spacer, Tabs, Textarea, Tooltip } from "@geist-ui/core"
import { ChangeEvent, memo, useMemo, useRef, useState } from "react" import { ChangeEvent, memo, useCallback, useMemo, useRef, useState } from "react"
import styles from './document.module.css' import styles from './document.module.css'
import MarkdownPreview from '../preview' import MarkdownPreview from '../preview'
import { Trash } from '@geist-ui/icons' import { Trash, Download, ExternalLink } from '@geist-ui/icons'
import FormattingIcons from "../formatting-icons" import FormattingIcons from "./formatting-icons"
import Skeleton from "react-loading-skeleton" import Skeleton from "react-loading-skeleton"
// import Link from "next/link"
type Props = { type Props = {
editable?: boolean editable?: boolean
remove?: () => void remove?: () => void
@ -14,9 +15,38 @@ type Props = {
setContent?: (content: string) => void setContent?: (content: string) => void
initialTab?: "edit" | "preview" initialTab?: "edit" | "preview"
skeleton?: boolean skeleton?: boolean
id?: string
} }
const Document = ({ remove, editable, title, content, setTitle, setContent, initialTab = 'edit', skeleton }: Props) => { const DownloadButton = ({ rawLink }: { rawLink?: string }) => {
return (<div className={styles.actionWrapper}>
<ButtonGroup className={styles.actions}>
<Tooltip text="Download">
<a href={`${rawLink}?download=true`} target="_blank" rel="noopener noreferrer">
<Button
scale={2 / 3} px={0.6}
icon={<Download />}
auto
aria-label="Download"
/>
</a>
</Tooltip>
<Tooltip text="Open raw in new tab">
<a href={rawLink} target="_blank" rel="noopener noreferrer">
<Button
scale={2 / 3} px={0.6}
icon={<ExternalLink />}
auto
aria-label="Open raw file in new tab"
/>
</a>
</Tooltip>
</ButtonGroup>
</div>)
}
const Document = ({ remove, editable, title, content, setTitle, setContent, initialTab = 'edit', skeleton, id }: Props) => {
const codeEditorRef = useRef<HTMLTextAreaElement>(null) const codeEditorRef = useRef<HTMLTextAreaElement>(null)
const [tab, setTab] = useState(initialTab) const [tab, setTab] = useState(initialTab)
const height = editable ? "500px" : '100%' 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) { if (skeleton) {
return <> return <>
<Spacer height={1} /> <Spacer height={1} />
@ -82,6 +119,7 @@ const Document = ({ remove, editable, title, content, setTitle, setContent, init
</div> </div>
<div className={styles.descriptionContainer}> <div className={styles.descriptionContainer}>
{tab === 'edit' && editable && <FormattingIcons setText={setContent} textareaRef={codeEditorRef} />} {tab === 'edit' && editable && <FormattingIcons setText={setContent} textareaRef={codeEditorRef} />}
{rawLink && <DownloadButton rawLink={rawLink} />}
<Tabs onChange={handleTabChange} initialValue={initialTab} hideDivider leftSpace={0}> <Tabs onChange={handleTabChange} initialValue={initialTab} hideDivider leftSpace={0}>
<Tabs.Item label={editable ? "Edit" : "Raw"} value="edit"> <Tabs.Item label={editable ? "Edit" : "Raw"} value="edit">
{/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */} {/* <textarea className={styles.lineCounter} wrap='off' readOnly ref={lineNumberRef}>1.</textarea> */}

View file

@ -175,6 +175,7 @@ const Header = ({ changeTheme, theme }: DriftProps) => {
auto auto
type="abort" type="abort"
onClick={() => setExpanded(!expanded)} onClick={() => setExpanded(!expanded)}
aria-label="Menu"
> >
<Spacer height={5 / 6} width={0} /> <Spacer height={5 / 6} width={0} />
<MenuIcon /> <MenuIcon />

View file

@ -9,7 +9,7 @@ const fetcher = (url: string) => fetch(url, {
}).then(r => r.json()) }).then(r => r.json())
const MyPosts = () => { const MyPosts = () => {
const { data, error } = useSWR('/api/users/mine', fetcher) const { data, error } = useSWR('/server-api/users/mine', fetcher)
return <PostList posts={data} error={error} /> return <PostList posts={data} error={error} />
} }

View file

@ -94,35 +94,37 @@ const allowedFileExtensions = [
'webmanifest', 'webmanifest',
] ]
// TODO: this shouldn't need to know about docs function FileDropzone({ setDocs, docs }: { setDocs: React.Dispatch<React.SetStateAction<Document[]>>, docs: Document[] }) {
function FileDropzone({ setDocs, docs }: { setDocs: (docs: Document[]) => void, docs: Document[] }) {
const { palette } = useTheme() const { palette } = useTheme()
const onDrop = useCallback((acceptedFiles) => { const { setToast } = useToasts()
acceptedFiles.forEach((file: File) => { const onDrop = useCallback(async (acceptedFiles) => {
const reader = new FileReader() 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.onabort = () => setToast({ text: 'File reading was aborted', type: 'error' })
reader.onerror = () => console.log('file reading has failed') reader.onerror = () => setToast({ text: 'File reading failed', type: 'error' })
reader.onload = () => { reader.onload = () => {
const content = reader.result as string const content = reader.result as string
if (docs.length === 1 && docs[0].content === '') { resolve({
setDocs([{
title: file.name, title: file.name,
content, content,
id: generateUUID() 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) => { const validator = (file: File) => {
// TODO: make this configurable // TODO: make this configurable

View file

@ -31,7 +31,7 @@ const Post = () => {
const onSubmit = async (visibility: string) => { const onSubmit = async (visibility: string) => {
setSubmitting(true) setSubmitting(true)
const response = await fetch('/api/posts/create', { const response = await fetch('/server-api/posts/create', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',

View file

@ -16,7 +16,7 @@ const useSignedIn = ({ redirectIfNotAuthed = false }: { redirectIfNotAuthed?: bo
async function checkToken() { async function checkToken() {
const token = localStorage.getItem('drift-token') const token = localStorage.getItem('drift-token')
if (token) { if (token) {
const response = await fetch('/api/auth/verify-token', { const response = await fetch('/server-api/auth/verify-token', {
method: 'GET', method: 'GET',
headers: { headers: {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`

View file

@ -10,9 +10,13 @@ const nextConfig = {
async rewrites() { async rewrites() {
return [ return [
{ {
source: "/api/:path*", source: "/server-api/:path*",
destination: `${process.env.API_URL}/:path*`, destination: `${process.env.API_URL}/:path*`,
}, },
{
source: "/file/raw/:id",
destination: `/api/raw/:id`,
},
]; ];
}, },
}; };

View file

@ -12,6 +12,7 @@
"@fec/remark-a11y-emoji": "^3.1.0", "@fec/remark-a11y-emoji": "^3.1.0",
"@geist-ui/core": "^2.3.5", "@geist-ui/core": "^2.3.5",
"@geist-ui/icons": "^1.0.1", "@geist-ui/icons": "^1.0.1",
"client-zip": "^2.0.0",
"comlink": "^4.3.1", "comlink": "^4.3.1",
"dotenv": "^16.0.0", "dotenv": "^16.0.0",
"next": "12.1.0", "next": "12.1.0",

View file

@ -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

View file

@ -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 Skeleton from 'react-loading-skeleton';
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -19,7 +19,7 @@ const Post = ({ theme, changeTheme }: ThemeProps) => {
async function fetchPost() { async function fetchPost() {
setIsLoading(true); setIsLoading(true);
if (router.query.id) { 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", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -46,6 +46,23 @@ const Post = ({ theme, changeTheme }: ThemeProps) => {
fetchPost() fetchPost()
}, [router, router.query.id]) }, [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 ( return (
<Page width={"100%"}> <Page width={"100%"}>
<Head> <Head>
@ -62,10 +79,17 @@ const Post = ({ theme, changeTheme }: ThemeProps) => {
{!error && isLoading && <><Text h2><Skeleton width={400} /></Text> {!error && isLoading && <><Text h2><Skeleton width={400} /></Text>
<Document skeleton={true} /> <Document skeleton={true} />
</>} </>}
{!isLoading && post && <><Text h2>{post.title} <VisibilityBadge visibility={post.visibility} /></Text> {!isLoading && post && <>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text h2>{post.title} <VisibilityBadge visibility={post.visibility} /></Text>
<Button auto onClick={download}>
Download as Zip
</Button>
</div>
{post.files.map(({ id, content, title }: { id: any, content: string, title: string }) => ( {post.files.map(({ id, content, title }: { id: any, content: string, title: string }) => (
<Document <Document
key={id} key={id}
id={id}
content={content} content={content}
title={title} title={title}
editable={false} editable={false}

View file

@ -480,6 +480,11 @@ character-reference-invalid@^1.0.0:
resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz#083329cda0eae272ab3dbbf37e9a382c13af1560" resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz#083329cda0eae272ab3dbbf37e9a382c13af1560"
integrity sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg== integrity sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==
client-zip@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/client-zip/-/client-zip-2.0.0.tgz#c93676c92ddb40c858da83517c27297a53874f8d"
integrity sha512-JFd4zdhxk5F01NmNnBq3+iMgJkkh0ku9NsI1wZlUjZ52inPJX92vR5TlSkjxRhmHJBPI7YqanD71wDEiKhdWtw==
clsx@^1.0.4: clsx@^1.0.4:
version "1.1.1" version "1.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188" resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188"

View file

@ -2,7 +2,7 @@ import * as express from 'express';
import * as bodyParser from 'body-parser'; import * as bodyParser from 'body-parser';
import * as errorhandler from 'strong-error-handler'; import * as errorhandler from 'strong-error-handler';
import * as cors from 'cors'; import * as cors from 'cors';
import { posts, users, auth } from './routes'; import { posts, users, auth, files } from './routes';
export const app = express(); export const app = express();
@ -17,6 +17,7 @@ app.use(cors(corsOptions));
app.use("/auth", auth) app.use("/auth", auth)
app.use("/posts", posts) app.use("/posts", posts)
app.use("/users", users) app.use("/users", users)
app.use("/files", files)
app.use(errorhandler({ app.use(errorhandler({
debug: process.env.ENV !== 'production', debug: process.env.ENV !== 'production',

View file

@ -0,0 +1,29 @@
import { Router } from 'express'
// import { Movie } from '../models/Post'
import { File } from '../../lib/models/File'
export const files = Router()
files.get("/raw/:id", async (req, res, next) => {
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);
}
});

View file

@ -1,3 +1,4 @@
export { auth } from './auth'; export { auth } from './auth';
export { posts } from './posts'; export { posts } from './posts';
export { users } from './users'; export { users } from './users';
export { files } from './files';

View file

@ -69,7 +69,7 @@ posts.get("/:id", async (req: UserJwtRequest, res, next) => {
{ {
model: File, model: File,
as: "files", as: "files",
attributes: ["id", "title", "content", "sha"], attributes: ["id", "title", "content", "sha", "createdAt", "updatedAt"],
}, },
{ {
model: User, model: User,
@ -80,8 +80,11 @@ posts.get("/:id", async (req: UserJwtRequest, res, next) => {
}) })
if (post?.visibility === 'public' || post?.visibility === 'unlisted') { if (post?.visibility === 'public' || post?.visibility === 'unlisted') {
res.setHeader("Cache-Control", "public, max-age=86400");
res.json(post); res.json(post);
} else { } else {
// TODO: should this be `private, `?
res.setHeader("Cache-Control", "max-age=86400");
jwt(req, res, () => { jwt(req, res, () => {
res.json(post); res.json(post);
}); });