diff --git a/client/components/admin/admin.module.css b/client/components/admin/admin.module.css index 57d378dc..9c4cb99d 100644 --- a/client/components/admin/admin.module.css +++ b/client/components/admin/admin.module.css @@ -12,3 +12,14 @@ color: var(--gray-dark); font-weight: bold; } + +.postModal details { + border-radius: var(--radius); + padding: var(--gap); + border-radius: var(--radius); +} + +.postModal summary { + cursor: pointer; + outline: none; +} diff --git a/client/components/admin/index.tsx b/client/components/admin/index.tsx index ffff89e4..28338a61 100644 --- a/client/components/admin/index.tsx +++ b/client/components/admin/index.tsx @@ -1,10 +1,12 @@ import { Text, Fieldset, Spacer, Link } from '@geist-ui/core' -import getPostPath from '@lib/get-post-path' import { Post, User } from '@lib/types' import Cookies from 'js-cookie' +import { useEffect, useState } from 'react' import useSWR from 'swr' import styles from './admin.module.css' -const fetcher = (url: string) => fetch(url, { +import PostModal from './post-modal-link' + +export const adminFetcher = (url: string) => fetch(url, { method: "GET", headers: { "Content-Type": "application/json", @@ -13,9 +15,21 @@ const fetcher = (url: string) => fetch(url, { }).then(res => res.json()) const Admin = () => { - const { data: posts, error } = useSWR('/server-api/admin/posts', fetcher) - const { data: users, error: errorUsers } = useSWR('/server-api/admin/users', fetcher) - console.log(posts, error) + const { data: posts, error: postsError } = useSWR('/server-api/admin/posts', adminFetcher) + const { data: users, error: usersError } = useSWR('/server-api/admin/users', adminFetcher) + const [postSizes, setPostSizes] = useState<{ [key: string]: number }>({}) + const byteToMB = (bytes: number) => Math.round(bytes / 1024 / 1024 * 100) / 100 + useEffect(() => { + if (posts) { + // sum the sizes of each file per post + const sizes = posts.reduce((acc, post) => { + const size = post.files.reduce((acc, file) => acc + file.html.length, 0) + return { ...acc, [post.id]: byteToMB(size) } + }, {}) + setPostSizes(sizes) + } + }, [posts]) + return (
Administration @@ -23,6 +37,7 @@ const Admin = () => { Users {users && {users.length} users} {!users && Loading...} + {usersError && An error occured} {users && @@ -50,6 +65,7 @@ const Admin = () => { Posts {posts && {posts.length} posts} {!posts && Loading...} + {postsError && An error occured} {posts &&
@@ -57,19 +73,24 @@ const Admin = () => { + - {posts?.map(post => ( + {posts?.map((post, i) => ( - + - + + ))}
Visibility Created AuthorSize
{post.title} {post.visibility} {new Date(post.createdAt).toLocaleDateString()} {new Date(post.createdAt).toLocaleTimeString()}{post.users ? post.users[0].username : ''}{post.users?.length ? post.users[0].username : Deleted}{postSizes[post.id] ? `${postSizes[post.id]} MB` : ''}
} + {Object.keys(postSizes).length &&
+ Total size: {Object.values(postSizes).reduce((prev, curr) => prev + curr)} MB +
}
diff --git a/client/components/admin/post-modal-link.tsx b/client/components/admin/post-modal-link.tsx new file mode 100644 index 00000000..e1d3a5b0 --- /dev/null +++ b/client/components/admin/post-modal-link.tsx @@ -0,0 +1,51 @@ +import { Link, Modal, useModal } from "@geist-ui/core"; +import { Post } from "@lib/types"; +import Cookies from "js-cookie"; +import useSWR from "swr"; +import { adminFetcher } from "."; +import styles from './admin.module.css' + +const PostModal = ({ id }: { + id: string, +}) => { + const { visible, setVisible, bindings } = useModal() + const { data: post, error } = useSWR(`/server-api/admin/post/${id}`, adminFetcher) + if (error) return failed to load + if (!post) return loading... + + const deletePost = async () => { + await fetch(`/server-api/admin/post/${id}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${Cookies.get("drift-token")}`, + } + }) + setVisible(false) + } + + return ( + <> + setVisible(true)}>{post.title} + + {post.title} + Click an item to expand + {post.files.map((file) => ( +
+ +
+ {file.title} +
+
+
+
+
+ ) + )} + Delete + setVisible(false)}>Close +
+ ) +} + +export default PostModal \ No newline at end of file diff --git a/client/components/header/header.tsx b/client/components/header/header.tsx index a0d35949..2a6ffa10 100644 --- a/client/components/header/header.tsx +++ b/client/components/header/header.tsx @@ -40,6 +40,7 @@ const Header = () => { const userData = useUserData(); const [pages, setPages] = useState([]) const { setTheme, theme } = useTheme() + useEffect(() => { setBodyHidden(expanded) }, [expanded, setBodyHidden]) diff --git a/client/components/new-post/drag-and-drop/index.tsx b/client/components/new-post/drag-and-drop/index.tsx index dfc0f465..6c65b4a4 100644 --- a/client/components/new-post/drag-and-drop/index.tsx +++ b/client/components/new-post/drag-and-drop/index.tsx @@ -33,12 +33,14 @@ function FileDropzone({ setDocs }: { setDocs: ((docs: Document[]) => void) }) { } const validator = (file: File) => { + const byteToMB = (bytes: number) => Math.round(bytes / 1024 / 1024 * 100) / 100 + // TODO: make this configurable const maxFileSize = 50000000; if (file.size > maxFileSize) { return { code: 'file-too-big', - message: 'File is too big. Maximum file size is ' + (maxFileSize).toFixed(2) + ' MB.', + message: 'File is too big. Maximum file size is ' + byteToMB(maxFileSize) + ' MB.', } } diff --git a/client/components/post-list/index.tsx b/client/components/post-list/index.tsx index ce0b1157..6918580b 100644 --- a/client/components/post-list/index.tsx +++ b/client/components/post-list/index.tsx @@ -117,7 +117,7 @@ const PostList = ({ morePosts, initialPosts, error }: Props) => { } - {posts?.length === 0 && !error && No posts found.Create one here.} + {posts?.length === 0 && !error && No posts found. Create one here.} { posts?.length > 0 &&
    diff --git a/docker-compose.yml b/docker-compose.yml index c2613cdc..deebbbd3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,12 +20,12 @@ services: context: ./client network: host args: - API_URL: http://server:3000 + API_URL: http://localhost:3000 SECRET_KEY: secret restart: unless-stopped user: 1000:1000 environment: - - API_URL=http://server:3000 + - API_URL=http://localhost:3000 - SECRET_KEY=secret ports: - "3001:3001" diff --git a/drift.sqlite b/drift.sqlite index a349ed9f..eb3a7807 100644 Binary files a/drift.sqlite and b/drift.sqlite differ diff --git a/server/src/routes/admin.ts b/server/src/routes/admin.ts index cf7831e3..c501b3b3 100644 --- a/server/src/routes/admin.ts +++ b/server/src/routes/admin.ts @@ -46,7 +46,7 @@ admin.get("/posts", async (req, res, next) => { { model: File, as: "files", - attributes: ["id", "title", "content", "sha", "createdAt", "updatedAt"] + attributes: ["id", "title", "createdAt", "html"] }, { model: User, @@ -61,3 +61,55 @@ admin.get("/posts", async (req, res, next) => { } }) +admin.get("/post/:id", async (req, res, next) => { + try { + const post = await Post.findByPk(req.params.id, { + attributes: { + exclude: ["content"], + include: ["id", "title", "visibility", "createdAt"] + }, + include: [ + { + model: File, + as: "files", + attributes: ["id", "title", "sha", "createdAt", "updatedAt", "html"] + }, + { + model: User, + as: "users", + attributes: ["id", "username"] + } + ] + }) + if (!post) { + return res.status(404).json({ + message: "Post not found" + }) + } + + res.json(post) + } catch (e) { + next(e) + } +}) + +admin.delete("/post/:id", async (req, res, next) => { + try { + const post = await Post.findByPk(req.params.id) + if (!post) { + return res.status(404).json({ + message: "Post not found" + }) + } + + if (post.files?.length) + await Promise.all(post.files.map((file) => file.destroy())) + await post.destroy({ force: true }) + res.json({ + message: "Post deleted" + }) + } catch (e) { + next(e) + } +}) + diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts index 0c3b8b81..5cc9c984 100644 --- a/server/src/routes/auth.ts +++ b/server/src/routes/auth.ts @@ -68,7 +68,7 @@ auth.post( const user = { username: username as string, password: await hash(req.body.password, salt), - role: (!process.env.MEMORY_DB && process.env.ENABLE_ADMIN && count === 0) ? "admin" : "user" + role: (!!process.env.MEMORY_DB && process.env.ENABLE_ADMIN && count === 0) ? "admin" : "user" } const created_user = await User.create(user) diff --git a/server/src/routes/posts.ts b/server/src/routes/posts.ts index 85062687..f7ece834 100644 --- a/server/src/routes/posts.ts +++ b/server/src/routes/posts.ts @@ -8,6 +8,7 @@ import { User } from "@lib/models/User" import secretKey from "@lib/middleware/secret-key" import markdown from "@lib/render-markdown" import { Op } from "sequelize" +import { PostAuthor } from "@lib/models/PostAuthor" export const posts = Router() @@ -168,7 +169,7 @@ posts.get( q: Joi.string().required() } }), - async (req, res, next) => { + async (req: UserJwtRequest, res, next) => { const { q } = req.query if (typeof q !== "string") { return res.status(400).json({ error: "Invalid query" }) @@ -181,6 +182,9 @@ posts.get( { title: { [Op.like]: `%${q}%` } }, { "$files.title$": { [Op.like]: `%${q}%` } }, { "$files.content$": { [Op.like]: `%${q}%` } } + ], + [Op.and]: [ + { "$users.id$": req.user?.id || "" }, ] }, include: [ @@ -188,9 +192,13 @@ posts.get( model: File, as: "files", attributes: ["id", "title"] + }, + { + model: User, + as: "users", } ], - attributes: ["id", "title", "visibility", "createdAt"], + attributes: ["id", "title", "visibility", "createdAt", "deletedAt"], order: [["createdAt", "DESC"]] }) @@ -273,19 +281,36 @@ posts.get( } ) -posts.delete("/:id", jwt, async (req, res, next) => { +posts.delete("/:id", jwt, async (req: UserJwtRequest, res, next) => { try { - const post = await Post.findByPk(req.params.id) + const post = await Post.findByPk(req.params.id, { + include: [ + { + model: User, + as: "users", + attributes: ["id"] + } + ] + }) if (!post) { return res.status(404).json({ error: "Post not found" }) } - jwt(req as UserJwtRequest, res, async () => { - if (post.files?.length) - await Promise.all(post.files.map((file) => file.destroy())) - await post.destroy() - res.json({ message: "Post deleted" }) + + if (req.user?.id !== post.users![0].id) { + return res.status(403).json({ error: "Forbidden" }) + } + if (post.files?.length) + await Promise.all(post.files.map((file) => file.destroy())) + + const postAuthor = await PostAuthor.findOne({ + where: { + postId: post.id + } }) + if (postAuthor) await postAuthor.destroy() + await post.destroy() + res.json({ message: "Post deleted" }) } catch (e) { next(e) }