client/server: search cleanup, admin work

This commit is contained in:
Max Leiter 2022-03-29 00:11:02 -07:00
parent 7505bb43fe
commit 6afc4c915e
No known key found for this signature in database
GPG key ID: A3512F2F2F17EBDA
11 changed files with 186 additions and 23 deletions

View file

@ -12,3 +12,14 @@
color: var(--gray-dark); color: var(--gray-dark);
font-weight: bold; font-weight: bold;
} }
.postModal details {
border-radius: var(--radius);
padding: var(--gap);
border-radius: var(--radius);
}
.postModal summary {
cursor: pointer;
outline: none;
}

View file

@ -1,10 +1,12 @@
import { Text, Fieldset, Spacer, Link } from '@geist-ui/core' import { Text, Fieldset, Spacer, Link } from '@geist-ui/core'
import getPostPath from '@lib/get-post-path'
import { Post, User } from '@lib/types' import { Post, User } from '@lib/types'
import Cookies from 'js-cookie' import Cookies from 'js-cookie'
import { useEffect, useState } from 'react'
import useSWR from 'swr' import useSWR from 'swr'
import styles from './admin.module.css' 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", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -13,9 +15,21 @@ const fetcher = (url: string) => fetch(url, {
}).then(res => res.json()) }).then(res => res.json())
const Admin = () => { const Admin = () => {
const { data: posts, error } = useSWR<Post[]>('/server-api/admin/posts', fetcher) const { data: posts, error: postsError } = useSWR<Post[]>('/server-api/admin/posts', adminFetcher)
const { data: users, error: errorUsers } = useSWR<User[]>('/server-api/admin/users', fetcher) const { data: users, error: usersError } = useSWR<User[]>('/server-api/admin/users', adminFetcher)
console.log(posts, error) 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 ( return (
<div className={styles.adminWrapper}> <div className={styles.adminWrapper}>
<Text h2>Administration</Text> <Text h2>Administration</Text>
@ -23,6 +37,7 @@ const Admin = () => {
<Fieldset.Title>Users</Fieldset.Title> <Fieldset.Title>Users</Fieldset.Title>
{users && <Fieldset.Subtitle>{users.length} users</Fieldset.Subtitle>} {users && <Fieldset.Subtitle>{users.length} users</Fieldset.Subtitle>}
{!users && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>} {!users && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
{usersError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>}
{users && <table> {users && <table>
<thead> <thead>
<tr> <tr>
@ -50,6 +65,7 @@ const Admin = () => {
<Fieldset.Title>Posts</Fieldset.Title> <Fieldset.Title>Posts</Fieldset.Title>
{posts && <Fieldset.Subtitle>{posts.length} posts</Fieldset.Subtitle>} {posts && <Fieldset.Subtitle>{posts.length} posts</Fieldset.Subtitle>}
{!posts && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>} {!posts && <Fieldset.Subtitle>Loading...</Fieldset.Subtitle>}
{postsError && <Fieldset.Subtitle>An error occured</Fieldset.Subtitle>}
{posts && <table> {posts && <table>
<thead> <thead>
<tr> <tr>
@ -57,19 +73,24 @@ const Admin = () => {
<th>Visibility</th> <th>Visibility</th>
<th>Created</th> <th>Created</th>
<th>Author</th> <th>Author</th>
<th>Size</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{posts?.map(post => ( {posts?.map((post, i) => (
<tr key={post.id}> <tr key={post.id}>
<td><Link color href={getPostPath(post.visibility, post.id)}>{post.title}</Link></td> <td><PostModal id={post.id} /></td>
<td>{post.visibility}</td> <td>{post.visibility}</td>
<td>{new Date(post.createdAt).toLocaleDateString()} {new Date(post.createdAt).toLocaleTimeString()}</td> <td>{new Date(post.createdAt).toLocaleDateString()} {new Date(post.createdAt).toLocaleTimeString()}</td>
<td>{post.users ? post.users[0].username : ''}</td> <td>{post.users?.length ? post.users[0].username : <i>Deleted</i>}</td>
<td>{postSizes[post.id] ? `${postSizes[post.id]} MB` : ''}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table>} </table>}
{Object.keys(postSizes).length && <div style={{ float: 'right' }}>
<Text>Total size: {Object.values(postSizes).reduce((prev, curr) => prev + curr)} MB</Text>
</div>}
</Fieldset> </Fieldset>
</div > </div >

View file

@ -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<Post>(`/server-api/admin/post/${id}`, adminFetcher)
if (error) return <Modal>failed to load</Modal>
if (!post) return <Modal>loading...</Modal>
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 (
<>
<Link href="#" color onClick={() => setVisible(true)}>{post.title}</Link>
<Modal width={'var(--main-content)'} {...bindings}>
<Modal.Title>{post.title}</Modal.Title>
<Modal.Subtitle>Click an item to expand</Modal.Subtitle>
{post.files.map((file) => (
<div key={file.id} className={styles.postModal}>
<Modal.Content>
<details>
<summary>{file.title}</summary>
<div dangerouslySetInnerHTML={{ __html: file.html }}>
</div>
</details>
</Modal.Content>
</div>
)
)}
<Modal.Action type="warning" onClick={deletePost}>Delete</Modal.Action>
<Modal.Action passive onClick={() => setVisible(false)}>Close</Modal.Action>
</Modal>
</>)
}
export default PostModal

View file

@ -40,6 +40,7 @@ const Header = () => {
const userData = useUserData(); const userData = useUserData();
const [pages, setPages] = useState<Tab[]>([]) const [pages, setPages] = useState<Tab[]>([])
const { setTheme, theme } = useTheme() const { setTheme, theme } = useTheme()
useEffect(() => { useEffect(() => {
setBodyHidden(expanded) setBodyHidden(expanded)
}, [expanded, setBodyHidden]) }, [expanded, setBodyHidden])

View file

@ -33,12 +33,14 @@ function FileDropzone({ setDocs }: { setDocs: ((docs: Document[]) => void) }) {
} }
const validator = (file: File) => { const validator = (file: File) => {
const byteToMB = (bytes: number) => Math.round(bytes / 1024 / 1024 * 100) / 100
// TODO: make this configurable // TODO: make this configurable
const maxFileSize = 50000000; const maxFileSize = 50000000;
if (file.size > maxFileSize) { if (file.size > maxFileSize) {
return { return {
code: 'file-too-big', 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.',
} }
} }

View file

@ -117,7 +117,7 @@ const PostList = ({ morePosts, initialPosts, error }: Props) => {
<ListItemSkeleton /> <ListItemSkeleton />
</li> </li>
</ul>} </ul>}
{posts?.length === 0 && !error && <Text type='secondary'>No posts found.Create one <NextLink passHref={true} href="/new"><Link color>here</Link></NextLink>.</Text>} {posts?.length === 0 && !error && <Text type='secondary'>No posts found. Create one <NextLink passHref={true} href="/new"><Link color>here</Link></NextLink>.</Text>}
{ {
posts?.length > 0 && <div> posts?.length > 0 && <div>
<ul> <ul>

View file

@ -20,12 +20,12 @@ services:
context: ./client context: ./client
network: host network: host
args: args:
API_URL: http://server:3000 API_URL: http://localhost:3000
SECRET_KEY: secret SECRET_KEY: secret
restart: unless-stopped restart: unless-stopped
user: 1000:1000 user: 1000:1000
environment: environment:
- API_URL=http://server:3000 - API_URL=http://localhost:3000
- SECRET_KEY=secret - SECRET_KEY=secret
ports: ports:
- "3001:3001" - "3001:3001"

Binary file not shown.

View file

@ -46,7 +46,7 @@ admin.get("/posts", async (req, res, next) => {
{ {
model: File, model: File,
as: "files", as: "files",
attributes: ["id", "title", "content", "sha", "createdAt", "updatedAt"] attributes: ["id", "title", "createdAt", "html"]
}, },
{ {
model: User, 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)
}
})

View file

@ -68,7 +68,7 @@ auth.post(
const user = { const user = {
username: username as string, username: username as string,
password: await hash(req.body.password, salt), 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) const created_user = await User.create(user)

View file

@ -8,6 +8,7 @@ import { User } from "@lib/models/User"
import secretKey from "@lib/middleware/secret-key" import secretKey from "@lib/middleware/secret-key"
import markdown from "@lib/render-markdown" import markdown from "@lib/render-markdown"
import { Op } from "sequelize" import { Op } from "sequelize"
import { PostAuthor } from "@lib/models/PostAuthor"
export const posts = Router() export const posts = Router()
@ -168,7 +169,7 @@ posts.get(
q: Joi.string().required() q: Joi.string().required()
} }
}), }),
async (req, res, next) => { async (req: UserJwtRequest, res, next) => {
const { q } = req.query const { q } = req.query
if (typeof q !== "string") { if (typeof q !== "string") {
return res.status(400).json({ error: "Invalid query" }) return res.status(400).json({ error: "Invalid query" })
@ -181,6 +182,9 @@ posts.get(
{ title: { [Op.like]: `%${q}%` } }, { title: { [Op.like]: `%${q}%` } },
{ "$files.title$": { [Op.like]: `%${q}%` } }, { "$files.title$": { [Op.like]: `%${q}%` } },
{ "$files.content$": { [Op.like]: `%${q}%` } } { "$files.content$": { [Op.like]: `%${q}%` } }
],
[Op.and]: [
{ "$users.id$": req.user?.id || "" },
] ]
}, },
include: [ include: [
@ -188,9 +192,13 @@ posts.get(
model: File, model: File,
as: "files", as: "files",
attributes: ["id", "title"] attributes: ["id", "title"]
},
{
model: User,
as: "users",
} }
], ],
attributes: ["id", "title", "visibility", "createdAt"], attributes: ["id", "title", "visibility", "createdAt", "deletedAt"],
order: [["createdAt", "DESC"]] 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 { 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) { if (!post) {
return res.status(404).json({ error: "Post not found" }) return res.status(404).json({ error: "Post not found" })
} }
jwt(req as UserJwtRequest, res, async () => {
if (post.files?.length) if (req.user?.id !== post.users![0].id) {
await Promise.all(post.files.map((file) => file.destroy())) return res.status(403).json({ error: "Forbidden" })
await post.destroy() }
res.json({ message: "Post deleted" }) 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) { } catch (e) {
next(e) next(e)
} }