client/server: use config dictionary on server, improve types on client

This commit is contained in:
Max Leiter 2022-04-01 16:26:42 -07:00
parent 8da6d62cea
commit dafc0c37f8
No known key found for this signature in database
GPG key ID: A3512F2F2F17EBDA
16 changed files with 116 additions and 57 deletions

View file

@ -13,12 +13,16 @@ If you want to contribute, need support, or want to stay updated, you can join t
In both `server` and `client`, run `yarn` (if you need yarn, you can download it [here](https://yarnpkg.com/).)
You can run `yarn dev` in either / both folders to start the server and client with file watching / live reloading.
To migrate the sqlite database in development, you can use `yarn migrate` to see a list of options.
### Production
`yarn build` in both `client/` and `server/` will produce production code for the client and server respectively. The client and server each also have Dockerfiles which you can use with a docker-compose (an example compose will be provided in the near future).
If you're deploying the front-end to something like Vercel, you'll need to set the root folder to `client/`.
In production the sqlite database will be automatically migrated to the latest version.
### Environment Variables
You can change these to your liking.

View file

@ -23,7 +23,7 @@ const Admin = () => {
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)
const size = post.files?.reduce((acc, file) => acc + file.html.length, 0) || 0
return { ...acc, [post.id]: byteToMB(size) }
}, {})
setPostSizes(sizes)

View file

@ -30,7 +30,7 @@ const PostModal = ({ id }: {
<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) => (
{post.files?.map((file) => (
<div key={file.id} className={styles.postModal}>
<Modal.Content>
<details>

View file

@ -39,7 +39,7 @@ const ListItem = ({ post, isOwner = true, deletePost }: { post: Post, isOwner?:
<CreatedAgoBadge createdAt={post.createdAt} />
</span>
<span style={{ marginLeft: 'var(--gap)' }}>
<Badge type="secondary">{post.files.length === 1 ? "1 file" : `${post.files.length} files`}</Badge>
<Badge type="secondary">{post.files?.length === 1 ? "1 file" : `${post.files?.length || 0} files`}</Badge>
</span>
<span style={{ marginLeft: 'var(--gap)' }}>
<ExpirationBadge postExpirationDate={post.expiresAt} />
@ -49,7 +49,7 @@ const ListItem = ({ post, isOwner = true, deletePost }: { post: Post, isOwner?:
</Card.Body>
<Divider h="1px" my={0} />
<Card.Content>
{post.files.map((file: File) => {
{post.files?.map((file: File) => {
return <div key={file.id}>
<Link color href={`${getPostPath(post.visibility, post.id)}#${file.title}`}>
{file.title || 'Untitled file'}

View file

@ -6,9 +6,8 @@ import styles from './post-page.module.css'
import homeStyles from '@styles/Home.module.css'
import type { File, Post } from "@lib/types"
import { Page, Button, Text, Badge, Tooltip, Spacer, ButtonDropdown, ButtonGroup, useMediaQuery } from "@geist-ui/core"
import { useCallback, useEffect, useMemo, useState } from "react"
import { timeAgo, timeUntil } from "@lib/time-ago"
import { Page, Button, Text, ButtonGroup, useMediaQuery } from "@geist-ui/core"
import { useEffect, useState } from "react"
import Archive from '@geist-ui/icons/archive'
import FileDropdown from "@components/file-dropdown"
import ScrollToTop from "@components/scroll-to-top"
@ -51,6 +50,7 @@ const PostPage = ({ post }: Props) => {
const download = async () => {
if (!post.files) return
const downloadZip = (await import("client-zip")).downloadZip
const blob = await downloadZip(post.files.map((file: any) => {
return {
@ -102,12 +102,12 @@ const PostPage = ({ post }: Props) => {
<Button auto onClick={download} icon={<Archive />}>
Download as ZIP archive
</Button>
<FileDropdown files={post.files} />
<FileDropdown files={post.files || []} />
</ButtonGroup>
</span>
</div>
{/* {post.files.length > 1 && <FileTree files={post.files} />} */}
{post.files.map(({ id, content, title }: File) => (
{post.files?.map(({ id, content, title }: File) => (
<DocumentComponent
key={id}
title={title}

View file

@ -21,7 +21,7 @@ export type Post = {
title: string
description: string
visibility: PostVisibility
files: Files
files?: Files
createdAt: string
users?: User[]
expiresAt: Date | string | null

1
server/migrate.js Normal file
View file

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

View file

@ -8,8 +8,9 @@
"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",
"post-build": "cp package.json ./dist/package.json && cp yarn.lock ./dist/yarn.lock && cd dist && env NODE_ENV=production yarn install",
"migrate": "sequelize-cli-ts db:migrate",
"migrate:undo": "sequelize-cli-ts db:migrate:undo",
"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"
},
"author": "",

View file

@ -5,6 +5,7 @@ import { posts, users, auth, files, admin } 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()
@ -35,7 +36,7 @@ app.use(errors())
app.use(
errorhandler({
debug: process.env.NODE_ENV !== "production",
debug: !config.is_production,
log: true
})
)

View file

@ -1,3 +1,4 @@
import config from "@lib/config"
import databasePath from "@lib/get-database-path"
import { Sequelize } from "sequelize-typescript"
import { SequelizeStorage, Umzug } from "umzug"
@ -5,23 +6,22 @@ import { SequelizeStorage, Umzug } from "umzug"
export const sequelize = new Sequelize({
dialect: "sqlite",
database: "drift",
storage: process.env.MEMORY_DB === "true" ? ":memory:" : databasePath,
storage: config.memory_db ? ":memory:" : databasePath,
models: [__dirname + "/lib/models"],
logging: true
})
if (process.env.MEMORY_DB !== "true") {
if (config.memory_db) {
console.log(`Database path: ${databasePath}`)
} else {
console.log("Using in-memory database")
}
const umzug = new Umzug({
export const umzug = new Umzug({
migrations: {
glob:
process.env.NODE_ENV === "production"
? __dirname + "/migrations/*.js"
: __dirname + "/migrations/*.ts"
glob: config.is_production
? __dirname + "/migrations/*.js"
: __dirname + "/migrations/*.ts"
},
context: sequelize.getQueryInterface(),
storage: new SequelizeStorage({ sequelize }),
@ -29,16 +29,20 @@ const umzug = new Umzug({
})
export type Migration = typeof umzug._types.migration
;(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.")
}
})()
// 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,5 +1,55 @@
export default {
port: process.env.PORT || 3000,
jwt_secret: process.env.JWT_SECRET || "myjwtsecret",
drift_home: process.env.DRIFT_HOME || "~/.drift"
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
}
const config = (): Config => {
const stringToBoolean = (str: string | undefined): boolean => {
if (str === "true") {
return true
} else if (str === "false") {
return false
} else {
throw new Error(`Invalid boolean value: ${str}`)
}
}
const throwIfUndefined = (str: string | undefined): string => {
if (str === undefined) {
throw new Error(`Missing environment variable: ${str}`)
}
return str
}
const validNodeEnvs = (str: string | undefined) => {
const valid = ["development", "production"]
if (str && !valid.includes(str)) {
throw new Error(`Invalid environment variable: ${str}`)
} else if (!str) {
console.warn("No NODE_ENV specified, defaulting to development")
} else {
console.log(`Using NODE_ENV: ${str}`)
}
}
validNodeEnvs(process.env.NODE_ENV)
const config: Config = {
port: process.env.PORT ? parseInt(process.env.PORT) : 3000,
jwt_secret: process.env.JWT_SECRET || "myjwtsecret",
drift_home: process.env.DRIFT_HOME || "~/.drift",
is_production: process.env.NODE_ENV === "production",
memory_db: stringToBoolean(process.env.MEMORY_DB),
enable_admin: stringToBoolean(process.env.ENABLE_ADMIN),
secret_key: throwIfUndefined(process.env.SECRET_KEY),
registration_password: process.env.REGISTRATION_PASSWORD || ""
}
return config
}
export default config()

View file

@ -19,7 +19,7 @@ export default function authenticateToken(
const authHeader = req.headers["authorization"]
const token = authHeader && authHeader.split(" ")[1]
if (token == null) return res.sendStatus(401)
if (!process.env.ENABLE_ADMIN) return res.sendStatus(404)
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)

View file

@ -1,17 +1,13 @@
import config from "@lib/config"
import { NextFunction, Request, Response } from "express"
const key = process.env.SECRET_KEY
if (!key) {
throw new Error("SECRET_KEY is not set.")
}
export default function authenticateToken(
req: Request,
res: Response,
next: NextFunction
) {
const requestKey = req.headers["x-secret-key"]
if (requestKey !== key) {
if (requestKey !== config.secret_key) {
return res.sendStatus(401)
}
next()

View file

@ -95,7 +95,15 @@ admin.get("/post/:id", async (req, res, next) => {
admin.delete("/post/:id", async (req, res, next) => {
try {
const post = await Post.findByPk(req.params.id)
const post = await Post.findByPk(req.params.id, {
include: [
{
model: File,
as: "files"
}
]
})
if (!post) {
return res.status(404).json({
message: "Post not found"

View file

@ -8,10 +8,10 @@ import { celebrate, Joi } from "celebrate"
const NO_EMPTY_SPACE_REGEX = /^\S*$/
// we require a server password if the password is set and we're in production
export const requiresServerPassword =
(process.env.MEMORY_DB || process.env.NODE_ENV === "production") &&
!!process.env.REGISTRATION_PASSWORD
console.log(`Registration password required: ${requiresServerPassword}`)
config.registration_password.length > 0 && config.is_production
if (requiresServerPassword) console.log(`Registration password enabled.`)
export const auth = Router()
@ -25,10 +25,7 @@ const validateAuthPayload = (
}
if (requiresServerPassword) {
if (
!serverPassword ||
process.env.REGISTRATION_PASSWORD !== serverPassword
) {
if (!serverPassword || config.registration_password !== serverPassword) {
throw new Error(
"Server password is incorrect. Please contact the server administrator."
)
@ -68,10 +65,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: config.enable_admin && count === 0 ? "admin" : "user"
}
const created_user = await User.create(user)

View file

@ -38,7 +38,7 @@ posts.post(
userId: Joi.string().required(),
password: Joi.string().optional(),
// expiresAt, allow to be null
expiresAt: Joi.date().optional().allow(null, '')
expiresAt: Joi.date().optional().allow(null, "")
}
}),
async (req, res, next) => {