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

pull/24/head
Max Leiter 3 years ago committed by GitHub
parent 606e38e192
commit f9e9c6fe06
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -1,13 +1,13 @@
# <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.
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

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

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

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

@ -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} />
))} ))}

@ -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> */}

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

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

@ -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)
})
}))
if (docs.length === 1) {
if (docs[0].content === '') {
setDocs(newDocs)
return
} }
reader.readAsText(file) }
})
}, [docs, setDocs]) setDocs((oldDocs) => [...oldDocs, ...newDocs])
}, [setDocs, setToast, docs])
const validator = (file: File) => { const validator = (file: File) => {
// TODO: make this configurable // TODO: make this configurable

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

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

@ -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`,
},
]; ];
}, },
}; };

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

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

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

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

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

@ -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);
}
});

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

@ -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);
}); });

Loading…
Cancel
Save