rm server code, add markdown rendering, html saving, visibility updating

This commit is contained in:
Max Leiter 2022-11-12 16:06:23 -08:00
parent 096cf41eee
commit c41cf7c5ef
76 changed files with 2853 additions and 11852 deletions

View file

@ -1,11 +1,10 @@
import Auth from "../components"
import Header from "@components/header"
import PageWrapper from "@components/page-wrapper"
export default function SignInPage() {
return (
<>
<Header />
<PageWrapper>
<Auth page="signin" />
</>
</PageWrapper>
)
}

View file

@ -1,6 +1,7 @@
import Auth from "../components"
import Header from "@components/header"
import { getRequiresPasscode } from "pages/api/auth/requires-passcode"
import PageWrapper from "@components/page-wrapper"
const getPasscode = async () => {
return await getRequiresPasscode()
@ -9,9 +10,8 @@ const getPasscode = async () => {
export default async function SignUpPage() {
const requiresPasscode = await getPasscode()
return (
<>
<Header />
<PageWrapper signedIn={false}>
<Auth page="signup" requiresServerPassword={requiresPasscode} />
</>
</PageWrapper>
)
}

View file

@ -1,5 +1,7 @@
import { memo, useEffect, useState } from "react"
import styles from "./preview.module.css"
import "@styles/markdown.css"
import "./marked.css"
type Props = {
height?: number | string
@ -8,8 +10,13 @@ type Props = {
title?: string
}
const MarkdownPreview = ({ height = 500, fileId, content, title }: Props) => {
const [preview, setPreview] = useState<string>(content || "")
const MarkdownPreview = ({
height = 500,
fileId,
content: initial = "",
title
}: Props) => {
const [content, setPreview] = useState<string>(initial)
const [isLoading, setIsLoading] = useState<boolean>(true)
useEffect(() => {
async function fetchPost() {
@ -47,16 +54,28 @@ const MarkdownPreview = ({ height = 500, fileId, content, title }: Props) => {
{isLoading ? (
<div>Loading...</div>
) : (
<article
className={styles.markdownPreview}
dangerouslySetInnerHTML={{ __html: preview }}
style={{
height
}}
/>
<StaticPreview content={content} height={height} />
)}
</>
)
}
export default memo(MarkdownPreview)
export default MarkdownPreview
export const StaticPreview = ({
content,
height = 500
}: {
content: string
height: string | number
}) => {
return (
<article
className={styles.markdownPreview}
dangerouslySetInnerHTML={{ __html: content }}
style={{
height
}}
/>
)
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
import NewPost from "../../components/new"
import { useRouter } from "next/navigation"
import Header from "@components/header"
import { notFound } from "next/navigation"
import { getPostById } from "@lib/server/prisma"
import PageWrapper from "@components/page-wrapper"
const NewFromExisting = async ({
params
@ -11,20 +11,17 @@ const NewFromExisting = async ({
}
}) => {
const { id } = params
const router = useRouter()
if (!id) {
router.push("/new")
return;
return notFound()
}
const post = await getPostById(id, true)
return (
<>
<Header signedIn />
<PageWrapper signedIn>
<NewPost initialPost={post} newPostParent={id} />
</>
</PageWrapper>
)
}

View file

@ -1,10 +1,12 @@
import Header from "@components/header"
import NewPost from "app/(posts)/new/components/new"
import "@styles/react-datepicker.css"
import PageWrapper from "@components/page-wrapper"
const New = () => <>
<Header signedIn />
<NewPost />
</>
const New = () => (
<PageWrapper signedIn>
<NewPost />
</PageWrapper>
)
export default New

View file

@ -142,13 +142,14 @@ const PostPage = ({ post: initialPost, isProtected, isAuthor }: Props) => {
</div>
)}
{/* {post.files.length > 1 && <FileTree files={post.files} />} */}
{post.files?.map(({ id, content, title }: File) => (
{post.files?.map(({ id, content, title, html }: File) => (
<DocumentComponent
key={id}
title={title}
initialTab={"preview"}
id={id}
content={content}
preview={html}
/>
))}
{isAuthor && (

View file

@ -15,15 +15,12 @@ const PasswordModalPage = ({ setPost, postId }: Props) => {
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(true)
const onSubmit = async (password: string) => {
const res = await fetch(
`/api/posts/authenticate?id=${postId}&password=${password}`,
{
method: "GET",
headers: {
"Content-Type": "application/json"
}
const res = await fetch(`/api/post/${postId}?password=${password}`, {
method: "GET",
headers: {
"Content-Type": "application/json"
}
)
})
if (!res.ok) {
setToast({

View file

@ -14,7 +14,7 @@ import {
Tooltip,
Tag
} from "@geist-ui/core/dist"
import HtmlPreview from "app/(posts)/components/preview"
import { StaticPreview } from "app/(posts)/components/preview"
import FadeIn from "@components/fade-in"
// import Link from "next/link"
@ -24,6 +24,7 @@ type Props = {
skeleton?: boolean
id: string
content: string
preview: string
}
const DownloadButton = ({ rawLink }: { rawLink?: string }) => {
@ -63,6 +64,7 @@ const DownloadButton = ({ rawLink }: { rawLink?: string }) => {
const Document = ({
content,
preview,
title,
initialTab = "edit",
skeleton,
@ -150,11 +152,11 @@ const Document = ({
</Tabs.Item>
<Tabs.Item label="Preview" value="preview">
<div style={{ marginTop: "var(--gap-half)" }}>
<HtmlPreview
<StaticPreview
height={height}
fileId={id}
content={content}
title={title}
// fileId={id}
content={preview}
// title={title}
/>
</div>
</Tabs.Item>

View file

@ -7,6 +7,7 @@ import { notFound } from "next/navigation"
import { getPostById } from "@lib/server/prisma"
import { getCurrentUser, getSession } from "@lib/server/session"
import Header from "@components/header"
import PageWrapper from "@components/page-wrapper"
export type PostProps = {
post: Post
@ -16,8 +17,8 @@ export type PostProps = {
const getPost = async (id: string) => {
const post = await getPostById(id, true)
const user = await getCurrentUser()
console.log("my post", post)
console.log("post is", post)
if (!post) {
return notFound()
}
@ -40,9 +41,7 @@ const getPost = async (id: string) => {
return notFound()
}
console.log("HERE", post.visibility, isAuthor)
if (post.visibility === "protected" && !isAuthor) {
console.log("HERE2")
return {
post,
isProtected: true,
@ -63,10 +62,9 @@ const PostView = async ({
}) => {
const { post, isProtected, isAuthor, signedIn } = await getPost(params.id)
return (
<>
<Header signedIn={signedIn} />
<PageWrapper signedIn={signedIn}>
<PostPage isAuthor={isAuthor} isProtected={isProtected} post={post} />
</>
</PageWrapper>
)
}

View file

@ -18,7 +18,7 @@ const VisibilityControl = ({ postId, visibility, setVisibility }: Props) => {
const sendRequest = useCallback(
async (visibility: string, password?: string) => {
const res = await fetch(`/server-api/posts/${postId}`, {
const res = await fetch(`/api/post/${postId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json"
@ -30,7 +30,6 @@ const VisibilityControl = ({ postId, visibility, setVisibility }: Props) => {
const json = await res.json()
setVisibility(json.visibility)
} else {
const json = await res.json()
setToast({
text: "An error occurred",
type: "error"

View file

@ -0,0 +1,7 @@
"use client";
import { MDXRemote, MDXRemoteProps } from "next-mdx-remote";
export default function MDXRemoteWrapper(props: MDXRemoteProps) {
return <MDXRemote {...props} />;
}

View file

@ -0,0 +1,30 @@
'use client';
import Page from "@geist-ui/core/dist/page"
import Header from "./header"
export default function PageWrapper({
children,
signedIn
}: {
children: React.ReactNode
signedIn?: boolean
}) {
return (
<>
<Page.Header>
<Header signedIn={signedIn} />
</Page.Header>
{children}
</>
)
}
export function LoadingPageWrapper() {
return (
<>
<Page.Header>
<Header signedIn={false} />
</Page.Header>
</>
)
}

View file

@ -2,8 +2,8 @@ import { redirect } from "next/navigation"
import { getPostsByUser } from "@lib/server/prisma"
import PostList from "@components/post-list"
import { getCurrentUser } from "@lib/server/session"
import Header from "@components/header"
import { authOptions } from "@lib/server/auth"
import PageWrapper from "@components/page-wrapper"
export default async function Mine() {
const userId = (await getCurrentUser())?.id
@ -16,9 +16,8 @@ export default async function Mine() {
const hasMore = false
return (
<>
<Header signedIn />
<PageWrapper signedIn>
<PostList morePosts={hasMore} initialPosts={posts} />
</>
</PageWrapper>
)
}

View file

@ -1,4 +1,5 @@
import Header from "@components/header"
import PageWrapper from "@components/page-wrapper"
import { getCurrentUser } from "@lib/server/session"
import { getWelcomeContent } from "pages/api/welcome"
import Home from "./components/home"
@ -13,9 +14,8 @@ export default async function Page() {
const authed = await getCurrentUser();
return (
<>
<Header signedIn={Boolean(authed)}/>
<Home rendered={rendered} introContent={content} introTitle={title} />
</>
<PageWrapper signedIn={Boolean(authed)}>
<Home rendered={rendered as string} introContent={content} introTitle={title} />
</PageWrapper>
)
}

View file

@ -5,17 +5,17 @@ import Profile from "app/settings/components/sections/profile"
import { authOptions } from "@lib/server/auth"
import { getCurrentUser } from "@lib/server/session"
import { redirect } from "next/navigation"
import PageWrapper from "@components/page-wrapper"
export default async function SettingsPage() {
const user = await getCurrentUser()
if (!user) {
redirect(authOptions.pages?.signIn || "/new")
return redirect(authOptions.pages?.signIn || "/new")
}
return (
<>
<Header signedIn />
<PageWrapper signedIn>
<div
style={{
display: "flex",
@ -32,6 +32,6 @@ export default async function SettingsPage() {
<Password />
</SettingsGroup>
</div>
</>
</PageWrapper>
)
}

View file

@ -1,5 +1,4 @@
@import "./syntax.css";
@import "./markdown.css";
@import "./inter.css";
:root {

View file

@ -1,11 +0,0 @@
import useSWR from "swr"
// https://2020.paco.me/blog/shared-hook-state-with-swr
const useSharedState = <T>(key: string, initial?: T) => {
const { data: state, mutate: setState } = useSWR(key, {
fallbackData: initial
})
return [state, setState] as const
}
export default useSharedState

View file

@ -1,33 +0,0 @@
import { TOKEN_COOKIE_NAME } from "@lib/constants"
import { getCookie, setCookie } from "cookies-next"
import { useEffect } from "react"
import useSharedState from "./use-shared-state"
const useSignedIn = () => {
const token = getCookie(TOKEN_COOKIE_NAME)
const [signedIn, setSignedIn] = useSharedState(
"signedIn",
typeof window === "undefined" ? false : !!token
)
const signin = (token: string) => {
setSignedIn(true)
// TODO: investigate SameSite / CORS cookie security
setCookie(TOKEN_COOKIE_NAME, token)
}
// useEffect(() => {
// if (token) {
// setSignedIn(true)
// } else {
// setSignedIn(false)
// }
// }, [setSignedIn, token])
console.log("signed in", signedIn)
return { signedIn, signin, token }
}
export default useSignedIn

View file

@ -1,7 +1,6 @@
import { marked } from "marked"
import Highlight, { defaultProps, Language } from "prism-react-renderer"
import Image from "next/image"
import Highlight, { defaultProps, Language } from "prism-react-renderer"
// // image sizes. DDoS Safe?
// const imageSizeLink = /^!?\[((?:\[[^\[\]]*\]|\\[\[\]]?|`[^`]*`|[^\[\]\\])*?)\]\(\s*(<(?:\\[<>]?|[^\s<>\\])*>|(?:\\[()]?|\([^\s\x00-\x1f()\\]*\)|[^\s\x00-\x1f()\\])*?(?:\s+=(?:[\w%]+)?x(?:[\w%]+)?)?)(?:\s+("(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)))?\s*\)/;
// //@ts-ignore
@ -31,25 +30,30 @@ const renderer = new marked.Renderer()
// )
// }
renderer.link = (href, _, text) => {
const isHrefLocal = href?.startsWith('/') || href?.startsWith('#')
if (isHrefLocal) {
return <a href={href || ''}>
{text}
</a>
}
// renderer.link = (href, _, text) => {
// const isHrefLocal = href?.startsWith("/") || href?.startsWith("#")
// if (isHrefLocal) {
// return <a href={href || ""}>{text}</a>
// }
// dirty hack
// if text contains elements, render as html
return <a href={href || ""} target="_blank" rel="noopener noreferrer" dangerouslySetInnerHTML={{ __html: convertHtmlEntities(text) }} ></a>
}
// // dirty hack
// // if text contains elements, render as html
// return (
// <a
// href={href || ""}
// target="_blank"
// rel="noopener noreferrer"
// dangerouslySetInnerHTML={{ __html: convertHtmlEntities(text) }}
// ></a>
// )
// }
// @ts-ignore
renderer.image = function (href, _, text) {
return <Image loading="lazy" src="${href}" alt="${text}" layout="fill" />
}
renderer.checkbox = () => ""
// renderer.checkbox = () => ""
// @ts-ignore
renderer.listitem = (text, task, checked) => {
if (task) {
@ -63,24 +67,24 @@ renderer.listitem = (text, task, checked) => {
</li>
)
}
return <li>{text}</li>
}
// //@ts-ignore
// renderer.code = (code: string, language: string) => {
// return (<pre>
// {/* {title && <code>{title} </code>} */}
// {/* {language && title && <code style={{}}> {language} </code>} */}
// <Code
// language={language}
// // title={title}
// code={code}
// // highlight={highlight}
// />
// </pre>
// )
// }
// @ts-ignore
renderer.code = (code: string, language: string) => {
const component =
<pre>
<Code
language={language}
// title={title}
code={code}
// highlight={highlight}
/>
)
</pre>
return component
}
marked.setOptions({
gfm: true,

View file

@ -1,7 +1,7 @@
import { PrismaAdapter } from "@next-auth/prisma-adapter"
import { NextAuthOptions } from "next-auth"
import GitHubProvider from "next-auth/providers/github"
import prisma from "@lib/server/prisma"
import { prisma } from "@lib/server/prisma"
import config from "@lib/config"
const providers: NextAuthOptions["providers"] = [

View file

@ -1,9 +1,9 @@
import markdown from "../render-markdown"
import type { File } from "@lib/server/prisma"
import markdown from "@wcj/markdown-to-html"
/**
* returns rendered HTML from a Drift file
*/
export function getHtmlFromFile({
export async function getHtmlFromFile({
content,
title
}: Pick<File, "content" | "title">) {
@ -39,6 +39,7 @@ ${content}
contentToRender = "\n" + content
}
const html = markdown(contentToRender)
const html = markdown(contentToRender, {
})
return html
}

View file

@ -2,7 +2,7 @@
import config from "@lib/config"
import { User } from "@prisma/client"
import prisma from "@lib/server/prisma"
import { prisma } from "@lib/server/prisma"
import * as jwt from "jsonwebtoken"
import next, { NextApiHandler, NextApiRequest, NextApiResponse } from "next"

View file

@ -5,8 +5,6 @@ declare global {
import config from "@lib/config"
import { Post, PrismaClient, File, User } from "@prisma/client"
const prisma = new PrismaClient()
// we want to update iff they exist the createdAt/updated/expired/deleted items
// the input could be an array, in which case we'd check each item in the array
// if it's an object, we'd check that object
@ -36,18 +34,16 @@ const updateDates = (input: any) => {
}
}
prisma.$use(async (params, next) => {
const result = await next(params)
return updateDates(result)
})
export default prisma
export const prisma =
global.prisma ||
new PrismaClient({
log: ["query"]
})
// https://next-auth.js.org/adapters/prisma
const client = globalThis.prisma || prisma
if (process.env.NODE_ENV !== "production") globalThis.prisma = client
if (process.env.NODE_ENV !== "production") global.prisma = prisma
export type { User, AuthTokens, File, Post } from "@prisma/client"
export type { User, File, Post } from "@prisma/client"
export type PostWithFiles = Post & {
files: File[]
@ -138,7 +134,6 @@ export const createUser = async (
throw new Error("Wrong registration password")
}
const isUserAdminByDefault =
config.enable_admin && (await prisma.user.count()) === 0
const userRole = isUserAdminByDefault ? "admin" : "user"
@ -150,7 +145,6 @@ export const createUser = async (
}
export const getPostById = async (postId: Post["id"], withFiles = false) => {
console.log("getPostById", postId)
const post = await prisma.post.findUnique({
where: {
id: postId

View file

@ -14,8 +14,12 @@
"dependencies": {
"@geist-ui/core": "^2.3.8",
"@geist-ui/icons": "1.0.2",
"@mdx-js/loader": "^2.1.5",
"@mdx-js/mdx": "^2.1.5",
"@mdx-js/react": "^2.1.5",
"@next-auth/prisma-adapter": "^1.0.5",
"@prisma/client": "^4.6.1",
"@wcj/markdown-to-html": "^2.1.2",
"bcrypt": "^5.1.0",
"client-zip": "2.2.1",
"clsx": "^1.2.1",
@ -26,6 +30,7 @@
"next": "13.0.3-canary.4",
"next-auth": "^4.16.4",
"next-joi": "^2.2.1",
"next-mdx-remote": "^4.2.0",
"next-themes": "npm:@wits/next-themes@0.2.7",
"prism-react-renderer": "^1.3.5",
"rc-table": "7.24.1",
@ -35,10 +40,19 @@
"react-dropzone": "14.2.3",
"react-hot-toast": "^2.4.0",
"react-loading-skeleton": "3.1.0",
"rehype-parse": "^8.0.4",
"rehype-remark": "^9.1.2",
"remark": "^14.0.2",
"remark-gfm": "^3.0.1",
"remark-html": "^15.0.1",
"remark-mdx": "^2.1.5",
"remark-rehype": "^10.1.0",
"remark-stringify": "^10.0.2",
"server-only": "^0.0.1",
"showdown": "^2.1.0",
"swr": "1.3.0",
"textarea-markdown-editor": "0.1.13",
"unified": "^10.1.2",
"zod": "^3.19.1"
},
"devDependencies": {
@ -54,6 +68,7 @@
"cross-env": "7.0.3",
"eslint": "8.27.0",
"eslint-config-next": "13.0.2",
"katex": "^0.16.3",
"next-unused": "0.0.6",
"prettier": "2.6.2",
"prisma": "^4.6.1",

View file

@ -1,7 +1,7 @@
import { withMethods } from "@lib/api-middleware/with-methods"
import { getHtmlFromFile } from "@lib/server/get-html-from-drift-file"
import { parseQueryParam } from "@lib/server/parse-query-param"
import prisma from "@lib/server/prisma"
import { prisma } from "@lib/server/prisma"
import { NextApiRequest, NextApiResponse } from "next"
export default withMethods(
@ -10,7 +10,7 @@ export default withMethods(
const query = req.query
const fileId = parseQueryParam(query.fileId)
const content = parseQueryParam(query.content)
const title = parseQueryParam(query.title)
const title = parseQueryParam(query.title) || "Untitled"
if (fileId && (content || title)) {
return res.status(400).json({ error: "Too many arguments" })
@ -33,7 +33,7 @@ export default withMethods(
return res.status(400).json({ error: "Missing arguments" })
}
const renderedHTML = getHtmlFromFile({
const renderedHTML = await getHtmlFromFile({
title,
content
})

View file

@ -1,5 +1,5 @@
import { NextApiRequest, NextApiResponse } from "next"
import prisma from "@lib/server/prisma"
import { prisma } from "@lib/server/prisma"
import { parseQueryParam } from "@lib/server/parse-query-param"
const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => {
@ -9,16 +9,14 @@ const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => {
}
})
console.log("file", file, "id", req.query.id)
if (!file) {
return res.status(404).end()
}
res.setHeader("Content-Type", "text/plain")
res.setHeader("Cache-Control", "public, max-age=4800")
console.log(file.html)
return res.status(200).write(file.html)
}
res.status(200).write(file.html)
res.end()
}
export default getRawFile

View file

@ -1,5 +1,5 @@
import { NextApiRequest, NextApiResponse } from "next"
import prisma from "lib/server/prisma"
import {prisma} from "lib/server/prisma"
import { parseQueryParam } from "@lib/server/parse-query-param"
const getRawFile = async (req: NextApiRequest, res: NextApiResponse) => {

View file

@ -0,0 +1,117 @@
import { withMethods } from "@lib/api-middleware/with-methods"
import { parseQueryParam } from "@lib/server/parse-query-param"
import { getPostById } from "@lib/server/prisma"
import type { NextApiRequest, NextApiResponse } from "next"
import { getSession } from "next-auth/react"
import { prisma } from "lib/server/prisma"
import * as crypto from "crypto"
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "GET") return handleGet(req, res)
else if (req.method === "PUT") return handlePut(req, res)
}
export default withMethods(["GET", "PUT"], handler)
async function handleGet(req: NextApiRequest, res: NextApiResponse<any>) {
const id = parseQueryParam(req.query.id)
const files = req.query.files ? parseQueryParam(req.query.files) : true
console.log("post id is", id)
if (!id) {
return res.status(400).json({ error: "Missing id" })
}
const post = await getPostById(id, Boolean(files))
if (!post) {
return res.status(404).json({ message: "Post not found" })
}
if (post.visibility === "public") {
res.setHeader("Cache-Control", "s-maxage=86400, stale-while-revalidate")
return res.json(post)
} else if (post.visibility === "unlisted") {
res.setHeader("Cache-Control", "s-maxage=1, stale-while-revalidate")
}
const session = await getSession({ req })
// the user can always go directly to their own post
if (session?.user.id === post.authorId) {
return res.json(post)
}
if (post.visibility === "protected") {
const password = parseQueryParam(req.query.password)
const hash = crypto
.createHash("sha256")
.update(password?.toString() || "")
.digest("hex")
.toString()
if (hash === post.password) {
return res.json(post)
} else {
return {
isProtected: true,
post: {
id: post.id,
visibility: post.visibility,
title: post.title
}
}
}
}
return res.status(404).json({ message: "Post not found" })
}
// PUT is for adjusting visibility and password
async function handlePut(req: NextApiRequest, res: NextApiResponse<any>) {
const { password, visibility } = req.body
const id = parseQueryParam(req.query.id)
if (!id) {
return res.status(400).json({ error: "Missing id" })
}
const post = await getPostById(id, false)
if (!post) {
return res.status(404).json({ message: "Post not found" })
}
const session = await getSession({ req })
const isAuthor = session?.user.id === post.authorId
if (!isAuthor) {
return res.status(403).json({ message: "Unauthorized" })
}
if (visibility === "protected" && !password) {
return res.status(400).json({ message: "Missing password" })
}
const hashedPassword = crypto
.createHash("sha256")
.update(password?.toString() || "")
.digest("hex")
.toString()
const updatedPost = await prisma.post.update({
where: {
id
},
data: {
visibility,
password: visibility === "protected" ? hashedPassword : null
}
})
res.json({
id: updatedPost.id,
visibility: updatedPost.visibility
})
}

View file

@ -3,7 +3,7 @@
import { withMethods } from "@lib/api-middleware/with-methods"
import { authOptions } from "@lib/server/auth"
import prisma, { getPostById } from "@lib/server/prisma"
import {prisma, getPostById } from "@lib/server/prisma"
import { NextApiRequest, NextApiResponse } from "next"
import { unstable_getServerSession } from "next-auth/next"
import { File } from "@lib/server/prisma"
@ -13,18 +13,18 @@ import { getSession } from "next-auth/react"
import { parseQueryParam } from "@lib/server/parse-query-param"
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method === "POST") {
return await handlePost(req, res)
} else {
return await handleGet(req, res)
}
return await handlePost(req, res)
}
export default withMethods(["POST", "GET"], handler)
export default withMethods(["POST"], handler)
async function handlePost(req: NextApiRequest, res: NextApiResponse<any>) {
try {
const session = await unstable_getServerSession(req, res, authOptions)
if (!session) {
console.log("no session")
return res.status(401).json({ error: "Unauthorized" })
}
const files = req.body.files as File[]
const fileTitles = files.map((file) => file.title)
@ -45,23 +45,6 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse<any>) {
.digest("hex")
}
const postFiles = files.map((file) => {
const html = getHtmlFromFile(file)
return {
title: file.title,
content: file.content,
sha: crypto
.createHash("sha256")
.update(file.content)
.digest("hex")
.toString(),
html: html,
userId: session?.user.id
// postId: post.id
}
}) as File[]
const post = await prisma.post.create({
data: {
title: req.body.title,
@ -69,62 +52,51 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse<any>) {
visibility: req.body.visibility,
password: hashedPassword,
expiresAt: req.body.expiresAt,
parentId: req.body.parentId,
// authorId: session?.user.id,
author: {
connect: {
id: session?.user.id
}
},
files: {
create: postFiles
}
// files: {
// connectOrCreate: postFiles.map((file) => ({
// where: {
// sha: file.sha
// },
// create: file
// }))
// }
}
})
await Promise.all(
files.map(async (file) => {
const html = (await getHtmlFromFile(file)) as string
return prisma.file.create({
data: {
title: file.title,
content: file.content,
sha: crypto
.createHash("sha256")
.update(file.content)
.digest("hex")
.toString(),
html: html,
userId: session.user.id,
post: {
connect: {
id: post.id
}
}
}
})
})
)
return res.json(post)
} catch (error) {
return res.status(500).json(error)
}
}
async function handleGet(req: NextApiRequest, res: NextApiResponse<any>) {
const id = parseQueryParam(req.query.id)
const files = req.query.files ? parseQueryParam(req.query.files) : true
if (!id) {
return res.status(400).json({ error: "Missing id" })
}
const post = await getPostById(id, Boolean(files))
if (!post) {
return res.status(404).json({ message: "Post not found" })
}
if (post.visibility === "public") {
res.setHeader("Cache-Control", "s-maxage=86400, stale-while-revalidate")
return res.json(post)
} else if (post.visibility === "unlisted") {
res.setHeader("Cache-Control", "s-maxage=1, stale-while-revalidate")
}
const session = await getSession({ req })
// the user can always go directly to their own post
if (session?.user.id === post.authorId) {
return res.json(post)
}
if (post.visibility === "protected") {
return {
isProtected: true,
post: {
id: post.id,
visibility: post.visibility,
title: post.title
}
}
}
return res.status(404).json({ message: "Post not found" })
}

View file

@ -5,7 +5,7 @@
// })
import { USER_COOKIE_NAME } from "@lib/constants"
import prisma, { getUserById } from "@lib/server/prisma"
import { getUserById } from "@lib/server/prisma"
import { getCookie } from "cookies-next"
import { NextApiRequest, NextApiResponse } from "next"
@ -37,17 +37,20 @@ export default async function handler(
message: "Unauthorized"
})
const userId = String(getCookie(USER_COOKIE_NAME, {
req, res
}))
const userId = String(
getCookie(USER_COOKIE_NAME, {
req,
res
})
)
if (!userId) {
return error()
}
if (!userId) {
return error()
}
try {
const user = await getUserById(userId);
const user = await getUserById(userId)
if (!user) {
return error()
}

View file

@ -1,29 +1,31 @@
// a nextjs api handerl
import config from "@lib/config"
import renderMarkdown from "@lib/render-markdown"
import { getHtmlFromMarkdown } from "@lib/remark-plugins"
import { getHtmlFromFile } from "@lib/server/get-html-from-drift-file"
import markdown from "@wcj/markdown-to-html"
import { NextApiRequest, NextApiResponse } from "next"
export const getWelcomeContent = async () => {
const introContent = config.welcome_content
const introTitle = config.welcome_title
return {
title: introTitle,
content: introContent,
rendered: renderMarkdown(introContent)
rendered: await getHtmlFromFile({
title: `intro.md`,
content: introContent
})
}
}
export default async function handler(
_: NextApiRequest,
res: NextApiResponse
) {
export default async function handler(_: NextApiRequest, res: NextApiResponse) {
const welcomeContent = await getWelcomeContent()
if (!welcomeContent) {
return res.status(500).json({ error: "Missing welcome content" })
}
console.log(welcomeContent.title)
return res.json(welcomeContent)
}

File diff suppressed because it is too large Load diff

View file

@ -1,2 +0,0 @@
node_modules/
__tests__/

View file

@ -1,4 +0,0 @@
JWT_SECRET=secret-jwt
MEMORY_DB=true
REGISTRATION_PASSWORD=password
SECRET_KEY=secret

4
server/.gitignore vendored
View file

@ -1,4 +0,0 @@
.env
node_modules/
dist/
drift.sqlite

View file

@ -1,7 +0,0 @@
{
"semi": false,
"trailingComma": "none",
"singleQuote": false,
"printWidth": 80,
"useTabs": true
}

View file

@ -1,8 +0,0 @@
const path = require("path")
module.exports = {
// config: path.resolve("config", "config.js"),
"models-path": path.resolve("src", "lib", "models"),
// "seeders-path": path.resolve("src", "seeders"),
"migrations-path": path.resolve("src", "migrations")
}

View file

@ -1,46 +0,0 @@
FROM node:17-alpine AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat git
WORKDIR /app
COPY package.json yarn.lock tsconfig.json tslint.json ./
RUN yarn install --frozen-lockfile
FROM node:17-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG NODE_ENV
ENV NODE_ENV=${NODE_ENV:-production}
RUN apk add --no-cache git
RUN yarn build:docker
FROM node:17-alpine AS runner
WORKDIR /app
ARG NODE_ENV
ENV NODE_ENV=${NODE_ENV:-production}
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 drift
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
USER drift
ENV PORT=3000
EXPOSE 3000
CMD ["node", "dist/index.js"]

View file

@ -1,11 +0,0 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
setupFiles: ["<rootDir>/test/setup-tests.ts"],
moduleNameMapper: {
"@lib/(.*)": "<rootDir>/src/lib/$1",
"@routes/(.*)": "<rootDir>/src/routes/$1"
},
testPathIgnorePatterns: ["<rootDir>/node_modules/", "<rootDir>/dist/"]
}

View file

@ -1,2 +0,0 @@
require("dotenv").config()
require("./src/database").umzug.runAsCLI()

View file

@ -1,65 +0,0 @@
{
"name": "sequelize-typescript-starter",
"version": "1.0.0",
"description": "",
"main": "index.ts",
"scripts": {
"start": "cross-env NODE_ENV=production node dist/index.js",
"dev": "cross-env NODE_ENV=development nodemon index.ts",
"build": "mkdir -p ./dist && cp .env ./dist/.env && tsc -p ./tsconfig.json && tsc-alias -p ./tsconfig.json && yarn post-build",
"build:docker": "mkdir -p ./dist && cp .env.test ./dist/.env && tsc -p ./tsconfig.json && tsc-alias -p ./tsconfig.json && yarn post-build",
"post-build": "cp package.json ./dist/package.json && cp yarn.lock ./dist/yarn.lock && cd dist && env NODE_ENV=production yarn install",
"migrate:up": "ts-node migrate up",
"migrate:down": "ts-node migrate down",
"migrate": "ts-node migrate",
"lint": "prettier --config .prettierrc 'src/**/*.ts' 'index.ts' --write",
"test": "jest --silent"
},
"author": "",
"license": "ISC",
"dependencies": {
"bcryptjs": "^2.4.3",
"body-parser": "^1.18.2",
"celebrate": "^15.0.1",
"cors": "^2.8.5",
"dotenv": "^16.0.0",
"express": "^4.16.2",
"express-jwt": "^6.1.1",
"jsonwebtoken": "^8.5.1",
"marked": "^4.0.12",
"nodemon": "^2.0.15",
"prism-react-renderer": "^1.3.1",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"reflect-metadata": "^0.1.10",
"sequelize": "^6.17.0",
"sequelize-typescript": "^2.1.3",
"sqlite3": "^5.1.2",
"strong-error-handler": "^4.0.0",
"umzug": "^3.1.0"
},
"devDependencies": {
"@types/bcryptjs": "2.4.2",
"@types/cors": "2.8.12",
"@types/express": "4.17.13",
"@types/express-jwt": "6.0.4",
"@types/jest": "27.5.0",
"@types/jsonwebtoken": "8.5.8",
"@types/marked": "4.0.3",
"@types/node": "17.0.21",
"@types/node-fetch": "2.6.1",
"@types/react-dom": "17.0.16",
"@types/supertest": "2.0.12",
"@types/validator": "^13.7.10",
"cross-env": "7.0.3",
"jest": "27.5.1",
"prettier": "2.6.2",
"supertest": "6.2.3",
"ts-jest": "27.1.4",
"ts-node": "10.7.0",
"tsc-alias": "1.6.7",
"tsconfig-paths": "3.14.1",
"tslint": "6.1.3",
"typescript": "4.6.4"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,43 +0,0 @@
import * as express from "express"
import * as bodyParser from "body-parser"
import * as errorhandler from "strong-error-handler"
import { posts, user, auth, files, admin, health } from "@routes/index"
import { errors } from "celebrate"
import secretKey from "@lib/middleware/secret-key"
import markdown from "@lib/render-markdown"
import config from "@lib/config"
export const app = express()
app.use(bodyParser.urlencoded({ extended: true }))
app.use(bodyParser.json({ limit: "5mb" }))
app.use("/auth", auth)
app.use("/posts", posts)
app.use("/user", user)
app.use("/files", files)
app.use("/admin", admin)
app.use("/health", health)
app.get("/welcome", secretKey, (req, res) => {
const introContent = config.welcome_content
const introTitle = config.welcome_title
if (!introContent || !introTitle) {
return res.status(500).json({ error: "Missing welcome content" })
}
return res.json({
title: introTitle,
content: introContent,
rendered: markdown(introContent)
})
})
app.use(errors())
app.use(
errorhandler({
debug: !config.is_production,
log: true
})
)

View file

@ -1,48 +0,0 @@
import config from "@lib/config"
import databasePath from "@lib/get-database-path"
import { Sequelize } from "sequelize-typescript"
import { SequelizeStorage, Umzug } from "umzug"
export const sequelize = new Sequelize({
dialect: "sqlite",
database: "drift",
storage: config.memory_db ? ":memory:" : databasePath,
models: [__dirname + "/lib/models"],
logging: console.log
})
if (config.memory_db) {
console.log("Using in-memory database")
} else {
console.log(`Database path: ${databasePath}`)
}
export const umzug = new Umzug({
migrations: {
glob: config.is_production
? __dirname + "/migrations/*.js"
: __dirname + "/migrations/*.ts"
},
context: sequelize.getQueryInterface(),
storage: new SequelizeStorage({ sequelize }),
logger: console
})
export type Migration = typeof umzug._types.migration
// If you're in a development environment, you can manually migrate with `yarn migrate:{up,down}` in the `server` folder
if (config.is_production) {
;(async () => {
// Checks migrations and run them if they are not already applied. To keep
// track of the executed migrations, a table (and sequelize model) called SequelizeMeta
// will be automatically created (if it doesn't exist already) and parsed.
console.log("Checking migrations...")
const migrations = await umzug.up()
if (migrations.length > 0) {
console.log("Migrations applied:")
console.log(migrations)
} else {
console.log("No migrations applied.")
}
})()
}

View file

@ -1,86 +0,0 @@
type Config = {
port: number
jwt_secret: string
drift_home: string
is_production: boolean
memory_db: boolean
enable_admin: boolean
secret_key: string
registration_password: string
welcome_content: string | undefined
welcome_title: string | undefined
}
type EnvironmentValue = string | undefined
type Environment = { [key: string]: EnvironmentValue }
export const config = (env: Environment): Config => {
const stringToBoolean = (str: EnvironmentValue): boolean => {
if (str === "true") {
return true
} else if (str === "false") {
return false
} else if (str) {
throw new Error(`Invalid boolean value: ${str}`)
} else {
return false
}
}
const throwIfUndefined = (str: EnvironmentValue, name: string): string => {
if (str === undefined) {
throw new Error(`Missing environment variable: ${name}`)
}
return str
}
const defaultIfUndefined = (
str: EnvironmentValue,
defaultValue: string
): string => {
if (str === undefined) {
return defaultValue
}
return str
}
const validNodeEnvs = (str: EnvironmentValue) => {
const valid = ["development", "production", "test"]
if (str && !valid.includes(str)) {
throw new Error(`Invalid NODE_ENV set: ${str}`)
} else if (!str) {
console.warn("No NODE_ENV specified, defaulting to development")
} else {
console.log(`Using NODE_ENV: ${str}`)
}
}
const is_production = env.NODE_ENV === "production"
const developmentDefault = (
str: EnvironmentValue,
name: string,
defaultValue: string
): string => {
if (is_production) return throwIfUndefined(str, name)
return defaultIfUndefined(str, defaultValue)
}
validNodeEnvs(env.NODE_ENV)
const config: Config = {
port: env.PORT ? parseInt(env.PORT) : 3000,
jwt_secret: env.JWT_SECRET || "myjwtsecret",
drift_home: env.DRIFT_HOME || "~/.drift",
is_production,
memory_db: stringToBoolean(env.MEMORY_DB),
enable_admin: stringToBoolean(env.ENABLE_ADMIN),
secret_key: developmentDefault(env.SECRET_KEY, "SECRET_KEY", "secret"),
registration_password: env.REGISTRATION_PASSWORD ?? "",
welcome_content: env.WELCOME_CONTENT,
welcome_title: env.WELCOME_TITLE
}
return config
}
export default config(process.env)

View file

@ -1,16 +0,0 @@
// https://github.com/thelounge/thelounge/blob/0fb6dae8a68627cd7747ea6164ebe93390fe90f2/src/helper.js#L224
import * as os from "os"
import * as path from "path"
import config from "./config"
// Expand ~ into the current user home dir.
// This does *not* support `~other_user/tmp` => `/home/other_user/tmp`.
function getDatabasePath() {
const fileName = "drift.sqlite"
const databasePath = `${config.drift_home}/${fileName}`
const home = os.homedir().replace("$", "$$$$")
return path.resolve(databasePath.replace(/^~($|\/|\\)/, home + "$1"))
}
export default getDatabasePath()

View file

@ -1,40 +0,0 @@
import markdown from "./render-markdown"
import { File } from "@lib/models/File"
/**
* returns rendered HTML from a Drift file
*/
function getHtmlFromFile({ content, title }: Pick<File, "content" | "title">) {
const renderAsMarkdown = [
"markdown",
"md",
"mdown",
"mkdn",
"mkd",
"mdwn",
"mdtxt",
"mdtext",
"text",
""
]
const fileType = () => {
const pathParts = title.split(".")
const language = pathParts.length > 1 ? pathParts[pathParts.length - 1] : ""
return language
}
const type = fileType()
let contentToRender: string = content || ""
if (!renderAsMarkdown.includes(type)) {
contentToRender = `~~~${type}
${content}
~~~`
} else {
contentToRender = "\n" + content
}
const html = markdown(contentToRender)
return html
}
export default getHtmlFromFile

View file

@ -1,50 +0,0 @@
// import * as request from 'supertest'
// import { app } from '../../../app'
import { NextFunction, Response } from "express"
import isAdmin from "@lib/middleware/is-admin"
import { UserJwtRequest } from "@lib/middleware/jwt"
describe("is-admin middlware", () => {
let mockRequest: Partial<UserJwtRequest>
let mockResponse: Partial<Response>
let nextFunction: NextFunction = jest.fn()
beforeEach(() => {
mockRequest = {}
mockResponse = {
sendStatus: jest.fn()
}
})
it("should return 401 if no authorization header", async () => {
const res = mockResponse as Response
isAdmin(mockRequest as UserJwtRequest, res, nextFunction)
expect(res.sendStatus).toHaveBeenCalledWith(401)
})
it("should return 401 if no token is supplied", async () => {
const req = mockRequest as UserJwtRequest
req.headers = {
authorization: "Bearer"
}
isAdmin(req, mockResponse as Response, nextFunction)
expect(mockResponse.sendStatus).toBeCalledWith(401)
})
it("should return 404 if config.enable_admin is false", async () => {
jest.mock("../../config", () => ({
enable_admin: false
}))
const req = mockRequest as UserJwtRequest
req.headers = {
authorization: "Bearer 123"
}
isAdmin(req, mockResponse as Response, nextFunction)
expect(mockResponse.sendStatus).toBeCalledWith(404)
})
// TODO: 403 if !isAdmin
// Verify it calls next() if admin
// Requires mocking config.enable_admin
})

View file

@ -1,48 +0,0 @@
import jwt, { UserJwtRequest } from "@lib/middleware/jwt"
import { NextFunction, Response } from "express"
describe("jwt middlware", () => {
let mockRequest: Partial<UserJwtRequest>
let mockResponse: Partial<Response>
let nextFunction: NextFunction = jest.fn()
beforeEach(() => {
mockRequest = {}
mockResponse = {
sendStatus: jest.fn().mockReturnThis()
}
})
it("should return 401 if no authorization header", () => {
const res = mockResponse as Response
jwt(mockRequest as UserJwtRequest, res, nextFunction)
expect(res.sendStatus).toHaveBeenCalledWith(401)
})
it("should return 401 if no token is supplied", () => {
const req = mockRequest as UserJwtRequest
req.headers = {
authorization: "Bearer"
}
jwt(req, mockResponse as Response, nextFunction)
expect(mockResponse.sendStatus).toBeCalledWith(401)
})
// it("should return 401 if token is deleted", async () => {
// try {
// const tokenString = "123"
// const req = mockRequest as UserJwtRequest
// req.headers = {
// authorization: `Bearer ${tokenString}`
// }
// jwt(req, mockResponse as Response, nextFunction)
// expect(mockResponse.sendStatus).toBeCalledWith(401)
// expect(mockResponse.json).toBeCalledWith({
// message: "Token is no longer valid"
// })
// } catch (e) {
// console.log(e)
// }
// })
})

View file

@ -1,46 +0,0 @@
// import * as request from 'supertest'
// import { app } from '../../../app'
import { NextFunction, Response } from "express"
import { UserJwtRequest } from "@lib/middleware/jwt"
import secretKey from "@lib/middleware/secret-key"
import config from "@lib/config"
describe("secret-key middlware", () => {
let mockRequest: Partial<UserJwtRequest>
let mockResponse: Partial<Response>
let nextFunction: NextFunction = jest.fn()
beforeEach(() => {
mockRequest = {}
mockResponse = {
sendStatus: jest.fn()
}
})
it("should return 401 if no x-secret-key header", async () => {
const res = mockResponse as Response
secretKey(mockRequest as UserJwtRequest, res, nextFunction)
expect(res.sendStatus).toHaveBeenCalledWith(401)
})
it("should return 401 if x-secret-key does not match server", async () => {
const defaultSecretKey = config.secret_key
const req = mockRequest as UserJwtRequest
req.headers = {
authorization: "Bearer",
"x-secret-key": defaultSecretKey + "1"
}
secretKey(req, mockResponse as Response, nextFunction)
expect(mockResponse.sendStatus).toBeCalledWith(401)
})
it("should call next() if x-secret-key matches server", async () => {
const req = mockRequest as UserJwtRequest
req.headers = {
authorization: "Bearer",
"x-secret-key": config.secret_key
}
secretKey(req, mockResponse as Response, nextFunction)
expect(nextFunction).toBeCalled()
})
})

View file

@ -1,43 +0,0 @@
import { NextFunction, Request, Response } from "express"
import * as jwt from "jsonwebtoken"
import config from "../config"
import { User as UserModel } from "../models/User"
export interface User {
id: string
}
export interface UserJwtRequest extends Request {
user?: User
}
export default function isAdmin(
req: UserJwtRequest,
res: Response,
next: NextFunction
) {
if (!req.headers?.authorization) {
return res.sendStatus(401)
}
const authHeader = req.headers["authorization"]
const token = authHeader && authHeader.split(" ")[1]
if (!token) return res.sendStatus(401)
if (!config.enable_admin) return res.sendStatus(404)
jwt.verify(token, config.jwt_secret, async (err: any, user: any) => {
if (err) return res.sendStatus(403)
const userObj = await UserModel.findByPk(user.id, {
attributes: {
exclude: ["password"]
}
})
if (!userObj || userObj.role !== "admin") {
return res.sendStatus(403)
}
req.user = userObj
next()
})
}

View file

@ -1,50 +0,0 @@
import { AuthToken } from "@lib/models/AuthToken"
import { NextFunction, Request, Response } from "express"
import * as jwt from "jsonwebtoken"
import config from "../config"
import { User as UserModel } from "../models/User"
export interface User {
id: string
}
export interface UserJwtRequest extends Request {
user?: User
}
export default async function authenticateToken(
req: UserJwtRequest,
res: Response,
next: NextFunction
) {
const authHeader = req.headers ? req.headers["authorization"] : undefined
const token = authHeader && authHeader.split(" ")[1]
if (token == null) return res.sendStatus(401)
const authToken = await AuthToken.findOne({ where: { token: token } })
if (authToken == null) {
return res.sendStatus(401)
}
if (authToken.deletedAt) {
return res.sendStatus(401).json({
message: "Token is no longer valid"
})
}
jwt.verify(token, config.jwt_secret, async (err: any, user: any) => {
if (err) return res.sendStatus(403)
const userObj = await UserModel.findByPk(user.id, {
attributes: {
exclude: ["password"]
}
})
if (!userObj) {
return res.sendStatus(403)
}
req.user = user
next()
})
}

View file

@ -1,18 +0,0 @@
import config from "@lib/config"
import { NextFunction, Request, Response } from "express"
export default function authenticateToken(
req: Request,
res: Response,
next: NextFunction
) {
if (!(req.headers && req.headers["x-secret-key"])) {
return res.sendStatus(401)
}
const requestKey = req.headers["x-secret-key"]
if (requestKey !== config.secret_key) {
return res.sendStatus(401)
}
next()
}

View file

@ -1,52 +0,0 @@
import {
Model,
Column,
Table,
IsUUID,
PrimaryKey,
DataType,
CreatedAt,
UpdatedAt,
DeletedAt,
Unique,
BelongsTo,
ForeignKey
} from "sequelize-typescript"
import { User } from "./User"
@Table
export class AuthToken extends Model {
@IsUUID(4)
@PrimaryKey
@Unique
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4
})
id!: string
@Column
token!: string
@BelongsTo(() => User, "userId")
user!: User
@ForeignKey(() => User)
@Column
userId!: number
@Column
expiredReason?: string
@CreatedAt
@Column
createdAt!: Date
@UpdatedAt
@Column
updatedAt!: Date
@DeletedAt
@Column
deletedAt?: Date
}

View file

@ -1,73 +0,0 @@
import {
BelongsTo,
Column,
CreatedAt,
DataType,
ForeignKey,
IsUUID,
Model,
PrimaryKey,
Scopes,
Table,
Unique
} from "sequelize-typescript"
import { Post } from "./Post"
import { User } from "./User"
@Scopes(() => ({
full: {
include: [
{
model: User,
through: { attributes: [] }
},
{
model: Post,
through: { attributes: [] }
}
]
}
}))
@Table({
tableName: "files"
})
export class File extends Model {
@IsUUID(4)
@PrimaryKey
@Unique
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4
})
id!: string
@Column
title!: string
@Column
content!: string
@Column
sha!: string
@Column
html!: string
@BelongsTo(() => User, "userId")
user!: User
@BelongsTo(() => Post, "postId")
post!: Post
@ForeignKey(() => User)
@Column
userId!: number
@ForeignKey(() => Post)
@Column
postId!: number
@CreatedAt
@Column
createdAt!: Date
}

View file

@ -1,91 +0,0 @@
import {
BelongsToMany,
Column,
CreatedAt,
DataType,
HasMany,
HasOne,
IsUUID,
Model,
PrimaryKey,
Scopes,
Table,
Unique,
UpdatedAt
} from "sequelize-typescript"
import { PostAuthor } from "./PostAuthor"
import { User } from "./User"
import { File } from "./File"
@Scopes(() => ({
user: {
include: [
{
model: User,
through: { attributes: [] }
}
]
},
full: {
include: [
{
model: User,
through: { attributes: [] }
},
{
model: File,
through: { attributes: [] }
}
]
}
}))
@Table({
tableName: "posts"
})
export class Post extends Model {
@IsUUID(4)
@PrimaryKey
@Unique
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4
})
id!: string
@Column
title!: string
@Column
description?: string
@BelongsToMany(() => User, () => PostAuthor)
users?: User[]
@HasMany(() => File, { constraints: false })
files?: File[]
@CreatedAt
@Column
createdAt!: Date
@Column
visibility!: string
@Column
password?: string
@UpdatedAt
@Column
updatedAt!: Date
@Column
deletedAt?: Date
@Column
expiresAt?: Date
@HasOne(() => Post, { foreignKey: "parentId", constraints: false })
parent?: Post
// TODO: deletedBy
}

View file

@ -1,34 +0,0 @@
import {
Model,
Column,
Table,
ForeignKey,
IsUUID,
PrimaryKey,
DataType,
Unique
} from "sequelize-typescript"
import { Post } from "./Post"
import { User } from "./User"
@Table({
tableName: "post_authors"
})
export class PostAuthor extends Model {
@IsUUID(4)
@PrimaryKey
@Unique
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4
})
id!: string
@ForeignKey(() => Post)
@Column
postId!: number
@ForeignKey(() => User)
@Column
userId!: number
}

View file

@ -1,73 +0,0 @@
import {
Model,
Column,
Table,
BelongsToMany,
Scopes,
CreatedAt,
UpdatedAt,
IsUUID,
PrimaryKey,
DataType,
Unique
} from "sequelize-typescript"
import { Post } from "./Post"
import { PostAuthor } from "./PostAuthor"
@Scopes(() => ({
posts: {
include: [
{
model: Post,
through: { attributes: [] }
}
]
},
withoutPassword: {
attributes: {
exclude: ["password"]
}
}
}))
@Table({
tableName: "users"
})
export class User extends Model {
@IsUUID(4)
@PrimaryKey
@Unique
@Column({
type: DataType.UUID,
defaultValue: DataType.UUIDV4
})
id!: string
@Column
username!: string
@Column
password!: string
@BelongsToMany(() => Post, () => PostAuthor)
posts?: Post[]
@CreatedAt
@Column
createdAt!: Date
@UpdatedAt
@Column
updatedAt!: Date
@Column
role!: string
@Column
email?: string
@Column
displayName?: string
@Column
bio?: string
}

View file

@ -1,152 +0,0 @@
import { marked } from 'marked'
import Highlight, { defaultProps, Language, } from 'prism-react-renderer'
import { renderToStaticMarkup } from 'react-dom/server'
// // image sizes. DDoS Safe?
// const imageSizeLink = /^!?\[((?:\[[^\[\]]*\]|\\[\[\]]?|`[^`]*`|[^\[\]\\])*?)\]\(\s*(<(?:\\[<>]?|[^\s<>\\])*>|(?:\\[()]?|\([^\s\x00-\x1f()\\]*\)|[^\s\x00-\x1f()\\])*?(?:\s+=(?:[\w%]+)?x(?:[\w%]+)?)?)(?:\s+("(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)))?\s*\)/;
// //@ts-ignore
// Lexer.rules.inline.normal.link = imageSizeLink;
// //@ts-ignore
// Lexer.rules.inline.gfm.link = imageSizeLink;
// //@ts-ignore
// Lexer.rules.inline.breaks.link = imageSizeLink;
//@ts-ignore
delete defaultProps.theme
// import linkStyles from '../components/link/link.module.css'
const renderer = new marked.Renderer()
renderer.heading = (text, level, _, slugger) => {
const id = slugger.slug(text)
const Component = `h${level}`
return renderToStaticMarkup(
//@ts-ignore
<Component>
<a href={`#${id}`} id={id} style={{ color: "inherit" }} dangerouslySetInnerHTML={{ __html: (text) }} >
</a>
</Component>
)
}
// renderer.link = (href, _, text) => {
// const isHrefLocal = href?.startsWith('/') || href?.startsWith('#')
// if (isHrefLocal) {
// return renderToStaticMarkup(
// <a href={href || ''}>
// {text}
// </a>
// )
// }
// // dirty hack
// // if text contains elements, render as html
// return <a href={href || ""} target="_blank" rel="noopener noreferrer" dangerouslySetInnerHTML={{ __html: convertHtmlEntities(text) }} ></a>
// }
renderer.image = function (href, _, text) {
return `<Image loading="lazy" src="${href}" alt="${text}" layout="fill" />`
}
renderer.checkbox = () => ''
renderer.listitem = (text, task, checked) => {
if (task) {
return `<li class="reset"><span class="check">&#8203;<input type="checkbox" disabled ${checked ? 'checked' : ''
} /></span><span>${text}</span></li>`
}
return `<li>${text}</li>`
}
renderer.code = (code: string, language: string) => {
return renderToStaticMarkup(
<pre>
{/* {title && <code>{title} </code>} */}
{/* {language && title && <code style={{}}> {language} </code>} */}
<Code
language={language}
// title={title}
code={code}
// highlight={highlight}
/>
</pre>
)
}
marked.setOptions({
gfm: true,
breaks: true,
headerIds: true,
renderer,
})
const markdown = (markdown: string) => marked(markdown)
export default markdown
const Code = ({ code, language, highlight, title, ...props }: {
code: string,
language: string,
highlight?: string,
title?: string,
}) => {
if (!language)
return (
<>
<code {...props} dangerouslySetInnerHTML={{ __html: code }} />
</>
)
const highlightedLines = highlight
//@ts-ignore
? highlight.split(',').reduce((lines, h) => {
if (h.includes('-')) {
// Expand ranges like 3-5 into [3,4,5]
const [start, end] = h.split('-').map(Number)
const x = Array(end - start + 1)
.fill(undefined)
.map((_, i) => i + start)
return [...lines, ...x]
}
return [...lines, Number(h)]
}, [])
: ''
// https://mdxjs.com/guides/syntax-harkedighlighting#all-together
return (
<>
<Highlight {...defaultProps} code={code.trim()} language={language as Language} >
{({ className, style, tokens, getLineProps, getTokenProps }) => (
<code className={className} style={{ ...style }}>
{
tokens.map((line, i) => (
<div
key={i}
{...getLineProps({ line, key: i })}
style={
//@ts-ignore
highlightedLines.includes((i + 1).toString())
? {
background: 'var(--highlight)',
margin: '0 -1rem',
padding: '0 1rem',
}
: undefined
}
>
{
line.map((token, key) => (
<span key={key} {...getTokenProps({ token, key })} />
))
}
</div>
))}
</code>
)}
</Highlight>
</>
)
}

View file

@ -1,31 +0,0 @@
"use strict"
import { DataTypes } from "sequelize"
import type { Migration } from "../database"
export const up: Migration = async ({ context: queryInterface }) =>
queryInterface.createTable("users", {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
unique: true
},
username: {
type: DataTypes.STRING
},
password: {
type: DataTypes.STRING
},
createdAt: {
type: DataTypes.DATE
},
updatedAt: {
type: DataTypes.DATE
},
deletedAt: {
type: DataTypes.DATE
}
})
export const down: Migration = async ({ context: queryInterface }) =>
queryInterface.dropTable("users")

View file

@ -1,12 +0,0 @@
"use strict"
import { DataTypes } from "sequelize"
import type { Migration } from "../database"
export const up: Migration = async ({ context: queryInterface }) =>
queryInterface.addColumn("users", "role", {
type: DataTypes.STRING,
defaultValue: "user"
})
export const down: Migration = async ({ context: queryInterface }) =>
queryInterface.removeColumn("users", "role")

View file

@ -1,34 +0,0 @@
"use strict"
import { DataTypes } from "sequelize"
import type { Migration } from "../database"
export const up: Migration = async ({ context: queryInterface }) =>
queryInterface.createTable("posts", {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
unique: true
},
title: {
type: DataTypes.STRING
},
visibility: {
type: DataTypes.STRING
},
password: {
type: DataTypes.STRING
},
createdAt: {
type: DataTypes.DATE
},
updatedAt: {
type: DataTypes.DATE
},
deletedAt: {
type: DataTypes.DATE
}
})
export const down: Migration = async ({ context: queryInterface }) =>
await queryInterface.dropTable("posts")

View file

@ -1,58 +0,0 @@
"use strict"
import { DataTypes } from "sequelize"
import type { Migration } from "../database"
export const up: Migration = async ({ context: queryInterface }) =>
queryInterface.createTable("files", {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
allowNull: false,
unique: true
},
title: {
type: DataTypes.STRING
},
content: {
type: DataTypes.STRING
},
sha: {
type: DataTypes.STRING
},
html: {
type: DataTypes.STRING
},
createdAt: {
type: DataTypes.DATE
},
updatedAt: {
type: DataTypes.DATE
},
deletedAt: {
type: DataTypes.DATE
},
userId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: "users",
key: "id"
},
onDelete: "SET NULL",
onUpdate: "CASCADE"
},
postId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: "posts",
key: "id"
},
onDelete: "SET NULL",
onUpdate: "CASCADE"
}
})
export const down: Migration = async ({ context: queryInterface }) =>
await queryInterface.dropTable("files")

View file

@ -1,41 +0,0 @@
"use strict"
import { DataTypes } from "sequelize"
import type { Migration } from "../database"
export const up: Migration = async ({ context: queryInterface }) =>
queryInterface.createTable("post_authors", {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true
},
createdAt: {
type: DataTypes.DATE
},
updatedAt: {
type: DataTypes.DATE
},
postId: {
type: DataTypes.UUID,
primaryKey: true,
references: {
model: "posts",
key: "id"
},
onDelete: "CASCADE",
onUpdate: "CASCADE"
},
userId: {
type: DataTypes.UUID,
primaryKey: true,
references: {
model: "users",
key: "id"
},
onDelete: "CASCADE",
onUpdate: "CASCADE"
}
})
export const down: Migration = async ({ context: queryInterface }) =>
await queryInterface.dropTable("post_authors")

View file

@ -1,12 +0,0 @@
"use strict"
import { DataTypes } from "sequelize"
import type { Migration } from "../database"
export const up: Migration = async ({ context: queryInterface }) =>
queryInterface.addColumn("posts", "expiresAt", {
type: DataTypes.DATE,
allowNull: true
})
export const down: Migration = async ({ context: queryInterface }) =>
await queryInterface.removeColumn("posts", "expiresAt")

View file

@ -1,12 +0,0 @@
"use strict"
import { DataTypes } from "sequelize"
import type { Migration } from "../database"
export const up: Migration = async ({ context: queryInterface }) =>
queryInterface.addColumn("posts", "parentId", {
type: DataTypes.STRING,
allowNull: true
})
export const down: Migration = async ({ context: queryInterface }) =>
await queryInterface.removeColumn("posts", "parentId")

View file

@ -1,43 +0,0 @@
"use strict"
import { DataTypes } from "sequelize"
import type { Migration } from "../database"
export const up: Migration = async ({ context: queryInterface }) =>
queryInterface.createTable("AuthTokens", {
id: {
type: DataTypes.UUID,
defaultValue: DataTypes.UUIDV4,
primaryKey: true,
unique: true
},
token: {
type: DataTypes.STRING
},
expiredReason: {
type: DataTypes.STRING,
allowNull: true
},
createdAt: {
type: DataTypes.DATE
},
updatedAt: {
type: DataTypes.DATE
},
deletedAt: {
type: DataTypes.DATE,
allowNull: true
},
userId: {
type: DataTypes.UUID,
allowNull: false,
references: {
model: "users",
key: "id"
},
onUpdate: "CASCADE",
onDelete: "CASCADE"
}
})
export const down: Migration = async ({ context: queryInterface }) =>
queryInterface.dropTable("AuthTokens")

View file

@ -1,12 +0,0 @@
"use strict"
import { DataTypes } from "sequelize"
import type { Migration } from "../database"
export const up: Migration = async ({ context: queryInterface }) =>
queryInterface.addColumn("posts", "description", {
type: DataTypes.STRING,
allowNull: true
})
export const down: Migration = async ({ context: queryInterface }) =>
await queryInterface.removeColumn("posts", "description")

View file

@ -1,26 +0,0 @@
"use strict"
import { DataTypes } from "sequelize"
import type { Migration } from "../database"
export const up: Migration = async ({ context: queryInterface }) =>
Promise.all([
queryInterface.addColumn("users", "email", {
type: DataTypes.STRING,
allowNull: true
}),
queryInterface.addColumn("users", "displayName", {
type: DataTypes.STRING,
allowNull: true
}),
queryInterface.addColumn("users", "bio", {
type: DataTypes.STRING,
allowNull: true
})
])
export const down: Migration = async ({ context: queryInterface }) =>
Promise.all([
queryInterface.removeColumn("users", "email"),
queryInterface.removeColumn("users", "displayName"),
queryInterface.removeColumn("users", "bio")
])

View file

@ -1,10 +0,0 @@
import { createServer } from "http"
import { app } from "./app"
import config from "./lib/config"
import "./database"
;(async () => {
// await sequelize.sync()
createServer(app).listen(config.port, () =>
console.info(`Server running on port ${config.port}`)
)
})()

View file

@ -1,29 +0,0 @@
{
"compileOnSave": false,
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"moduleResolution": "node",
"jsx": "react-jsx",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"noUnusedLocals": true,
"sourceMap": true,
"declaration": false,
"pretty": true,
"strictNullChecks": true,
"skipLibCheck": true,
"strictPropertyInitialization": true,
"outDir": "dist",
"baseUrl": ".",
"paths": {
"@routes/*": ["./src/routes/*"],
"@lib/*": ["./src/lib/*"]
}
},
"ts-node": {
"require": ["tsconfig-paths/register"]
},
"include": ["index.ts", "src/**/*.ts"],
"exclude": ["node_modules"]
}

View file

@ -1,51 +0,0 @@
{
"extends": "tslint:latest",
"rules": {
"arrow-parens": false,
"class-name": false,
"no-empty": false,
"unified-signatures": false,
"no-trailing-whitespace": false,
"no-submodule-imports": false,
"object-literal-sort-keys": false,
"interface-name": false,
"no-consecutive-blank-lines": false,
"no-object-literal-type-assertion": false,
"no-unused-expression": false,
"trailing-comma": false,
"ordered-imports": false,
"no-unused-imports": [true, {
"ignoreExports": true,
"ignoreDeclarations": true,
"ignoreTypeReferences": true
}],
"max-line-length": [
true,
140
],
"member-access": false,
"no-string-literal": false,
"curly": false,
"only-arrow-functions": false,
"typedef": [
true
],
"no-var-requires": false,
"quotemark": [
"single"
],
"triple-equals": true,
"member-ordering": [
true,
"public-before-private",
"variables-before-functions"
],
"variable-name": [
true,
"ban-keywords",
"check-format",
"allow-leading-underscore",
"allow-pascal-case"
]
}
}

File diff suppressed because it is too large Load diff