client/server: use config
dictionary on server, improve types on client
This commit is contained in:
parent
8da6d62cea
commit
dafc0c37f8
16 changed files with 116 additions and 57 deletions
|
@ -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/).)
|
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.
|
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
|
### 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).
|
`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/`.
|
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
|
### Environment Variables
|
||||||
|
|
||||||
You can change these to your liking.
|
You can change these to your liking.
|
||||||
|
|
|
@ -23,7 +23,7 @@ const Admin = () => {
|
||||||
if (posts) {
|
if (posts) {
|
||||||
// sum the sizes of each file per post
|
// sum the sizes of each file per post
|
||||||
const sizes = posts.reduce((acc, 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) }
|
return { ...acc, [post.id]: byteToMB(size) }
|
||||||
}, {})
|
}, {})
|
||||||
setPostSizes(sizes)
|
setPostSizes(sizes)
|
||||||
|
|
|
@ -30,7 +30,7 @@ const PostModal = ({ id }: {
|
||||||
<Modal width={'var(--main-content)'} {...bindings}>
|
<Modal width={'var(--main-content)'} {...bindings}>
|
||||||
<Modal.Title>{post.title}</Modal.Title>
|
<Modal.Title>{post.title}</Modal.Title>
|
||||||
<Modal.Subtitle>Click an item to expand</Modal.Subtitle>
|
<Modal.Subtitle>Click an item to expand</Modal.Subtitle>
|
||||||
{post.files.map((file) => (
|
{post.files?.map((file) => (
|
||||||
<div key={file.id} className={styles.postModal}>
|
<div key={file.id} className={styles.postModal}>
|
||||||
<Modal.Content>
|
<Modal.Content>
|
||||||
<details>
|
<details>
|
||||||
|
|
|
@ -39,7 +39,7 @@ const ListItem = ({ post, isOwner = true, deletePost }: { post: Post, isOwner?:
|
||||||
<CreatedAgoBadge createdAt={post.createdAt} />
|
<CreatedAgoBadge createdAt={post.createdAt} />
|
||||||
</span>
|
</span>
|
||||||
<span style={{ marginLeft: 'var(--gap)' }}>
|
<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>
|
||||||
<span style={{ marginLeft: 'var(--gap)' }}>
|
<span style={{ marginLeft: 'var(--gap)' }}>
|
||||||
<ExpirationBadge postExpirationDate={post.expiresAt} />
|
<ExpirationBadge postExpirationDate={post.expiresAt} />
|
||||||
|
@ -49,7 +49,7 @@ const ListItem = ({ post, isOwner = true, deletePost }: { post: Post, isOwner?:
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
<Divider h="1px" my={0} />
|
<Divider h="1px" my={0} />
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
{post.files.map((file: File) => {
|
{post.files?.map((file: File) => {
|
||||||
return <div key={file.id}>
|
return <div key={file.id}>
|
||||||
<Link color href={`${getPostPath(post.visibility, post.id)}#${file.title}`}>
|
<Link color href={`${getPostPath(post.visibility, post.id)}#${file.title}`}>
|
||||||
{file.title || 'Untitled file'}
|
{file.title || 'Untitled file'}
|
||||||
|
|
|
@ -6,9 +6,8 @@ import styles from './post-page.module.css'
|
||||||
import homeStyles from '@styles/Home.module.css'
|
import homeStyles from '@styles/Home.module.css'
|
||||||
|
|
||||||
import type { File, Post } from "@lib/types"
|
import type { File, Post } from "@lib/types"
|
||||||
import { Page, Button, Text, Badge, Tooltip, Spacer, ButtonDropdown, ButtonGroup, useMediaQuery } from "@geist-ui/core"
|
import { Page, Button, Text, ButtonGroup, useMediaQuery } from "@geist-ui/core"
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { timeAgo, timeUntil } from "@lib/time-ago"
|
|
||||||
import Archive from '@geist-ui/icons/archive'
|
import Archive from '@geist-ui/icons/archive'
|
||||||
import FileDropdown from "@components/file-dropdown"
|
import FileDropdown from "@components/file-dropdown"
|
||||||
import ScrollToTop from "@components/scroll-to-top"
|
import ScrollToTop from "@components/scroll-to-top"
|
||||||
|
@ -51,6 +50,7 @@ const PostPage = ({ post }: Props) => {
|
||||||
|
|
||||||
|
|
||||||
const download = async () => {
|
const download = async () => {
|
||||||
|
if (!post.files) return
|
||||||
const downloadZip = (await import("client-zip")).downloadZip
|
const downloadZip = (await import("client-zip")).downloadZip
|
||||||
const blob = await downloadZip(post.files.map((file: any) => {
|
const blob = await downloadZip(post.files.map((file: any) => {
|
||||||
return {
|
return {
|
||||||
|
@ -102,12 +102,12 @@ const PostPage = ({ post }: Props) => {
|
||||||
<Button auto onClick={download} icon={<Archive />}>
|
<Button auto onClick={download} icon={<Archive />}>
|
||||||
Download as ZIP archive
|
Download as ZIP archive
|
||||||
</Button>
|
</Button>
|
||||||
<FileDropdown files={post.files} />
|
<FileDropdown files={post.files || []} />
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{/* {post.files.length > 1 && <FileTree files={post.files} />} */}
|
{/* {post.files.length > 1 && <FileTree files={post.files} />} */}
|
||||||
{post.files.map(({ id, content, title }: File) => (
|
{post.files?.map(({ id, content, title }: File) => (
|
||||||
<DocumentComponent
|
<DocumentComponent
|
||||||
key={id}
|
key={id}
|
||||||
title={title}
|
title={title}
|
||||||
|
|
2
client/lib/types.d.ts
vendored
2
client/lib/types.d.ts
vendored
|
@ -21,7 +21,7 @@ export type Post = {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
visibility: PostVisibility
|
visibility: PostVisibility
|
||||||
files: Files
|
files?: Files
|
||||||
createdAt: string
|
createdAt: string
|
||||||
users?: User[]
|
users?: User[]
|
||||||
expiresAt: Date | string | null
|
expiresAt: Date | string | null
|
||||||
|
|
1
server/migrate.js
Normal file
1
server/migrate.js
Normal file
|
@ -0,0 +1 @@
|
||||||
|
require("./src/database").umzug.runAsCLI()
|
|
@ -8,8 +8,9 @@
|
||||||
"dev": "cross-env NODE_ENV=development nodemon index.ts",
|
"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": "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",
|
"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:up": "ts-node migrate up",
|
||||||
"migrate:undo": "sequelize-cli-ts db:migrate:undo",
|
"migrate:down": "ts-node migrate down",
|
||||||
|
"migrate": "ts-node migrate",
|
||||||
"lint": "prettier --config .prettierrc 'src/**/*.ts' 'index.ts' --write"
|
"lint": "prettier --config .prettierrc 'src/**/*.ts' 'index.ts' --write"
|
||||||
},
|
},
|
||||||
"author": "",
|
"author": "",
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { posts, users, auth, files, admin } from "@routes/index"
|
||||||
import { errors } from "celebrate"
|
import { errors } from "celebrate"
|
||||||
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 config from "@lib/config"
|
||||||
|
|
||||||
export const app = express()
|
export const app = express()
|
||||||
|
|
||||||
|
@ -35,7 +36,7 @@ app.use(errors())
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
errorhandler({
|
errorhandler({
|
||||||
debug: process.env.NODE_ENV !== "production",
|
debug: !config.is_production,
|
||||||
log: true
|
log: true
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import config from "@lib/config"
|
||||||
import databasePath from "@lib/get-database-path"
|
import databasePath from "@lib/get-database-path"
|
||||||
import { Sequelize } from "sequelize-typescript"
|
import { Sequelize } from "sequelize-typescript"
|
||||||
import { SequelizeStorage, Umzug } from "umzug"
|
import { SequelizeStorage, Umzug } from "umzug"
|
||||||
|
@ -5,23 +6,22 @@ import { SequelizeStorage, Umzug } from "umzug"
|
||||||
export const sequelize = new Sequelize({
|
export const sequelize = new Sequelize({
|
||||||
dialect: "sqlite",
|
dialect: "sqlite",
|
||||||
database: "drift",
|
database: "drift",
|
||||||
storage: process.env.MEMORY_DB === "true" ? ":memory:" : databasePath,
|
storage: config.memory_db ? ":memory:" : databasePath,
|
||||||
models: [__dirname + "/lib/models"],
|
models: [__dirname + "/lib/models"],
|
||||||
logging: true
|
logging: true
|
||||||
})
|
})
|
||||||
|
|
||||||
if (process.env.MEMORY_DB !== "true") {
|
if (config.memory_db) {
|
||||||
console.log(`Database path: ${databasePath}`)
|
console.log(`Database path: ${databasePath}`)
|
||||||
} else {
|
} else {
|
||||||
console.log("Using in-memory database")
|
console.log("Using in-memory database")
|
||||||
}
|
}
|
||||||
|
|
||||||
const umzug = new Umzug({
|
export const umzug = new Umzug({
|
||||||
migrations: {
|
migrations: {
|
||||||
glob:
|
glob: config.is_production
|
||||||
process.env.NODE_ENV === "production"
|
? __dirname + "/migrations/*.js"
|
||||||
? __dirname + "/migrations/*.js"
|
: __dirname + "/migrations/*.ts"
|
||||||
: __dirname + "/migrations/*.ts"
|
|
||||||
},
|
},
|
||||||
context: sequelize.getQueryInterface(),
|
context: sequelize.getQueryInterface(),
|
||||||
storage: new SequelizeStorage({ sequelize }),
|
storage: new SequelizeStorage({ sequelize }),
|
||||||
|
@ -29,16 +29,20 @@ const umzug = new Umzug({
|
||||||
})
|
})
|
||||||
|
|
||||||
export type Migration = typeof umzug._types.migration
|
export type Migration = typeof umzug._types.migration
|
||||||
;(async () => {
|
|
||||||
// Checks migrations and run them if they are not already applied. To keep
|
// If you're in a development environment, you can manually migrate with `yarn migrate:{up,down}` in the `server` folder
|
||||||
// track of the executed migrations, a table (and sequelize model) called SequelizeMeta
|
if (config.is_production) {
|
||||||
// will be automatically created (if it doesn't exist already) and parsed.
|
;(async () => {
|
||||||
console.log("Checking migrations...")
|
// Checks migrations and run them if they are not already applied. To keep
|
||||||
const migrations = await umzug.up()
|
// track of the executed migrations, a table (and sequelize model) called SequelizeMeta
|
||||||
if (migrations.length > 0) {
|
// will be automatically created (if it doesn't exist already) and parsed.
|
||||||
console.log("Migrations applied:")
|
console.log("Checking migrations...")
|
||||||
console.log(migrations)
|
const migrations = await umzug.up()
|
||||||
} else {
|
if (migrations.length > 0) {
|
||||||
console.log("No migrations applied.")
|
console.log("Migrations applied:")
|
||||||
}
|
console.log(migrations)
|
||||||
})()
|
} else {
|
||||||
|
console.log("No migrations applied.")
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,55 @@
|
||||||
export default {
|
type Config = {
|
||||||
port: process.env.PORT || 3000,
|
port: number
|
||||||
jwt_secret: process.env.JWT_SECRET || "myjwtsecret",
|
jwt_secret: string
|
||||||
drift_home: process.env.DRIFT_HOME || "~/.drift"
|
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()
|
||||||
|
|
|
@ -19,7 +19,7 @@ export default function authenticateToken(
|
||||||
const authHeader = req.headers["authorization"]
|
const authHeader = req.headers["authorization"]
|
||||||
const token = authHeader && authHeader.split(" ")[1]
|
const token = authHeader && authHeader.split(" ")[1]
|
||||||
if (token == null) return res.sendStatus(401)
|
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) => {
|
jwt.verify(token, config.jwt_secret, async (err: any, user: any) => {
|
||||||
if (err) return res.sendStatus(403)
|
if (err) return res.sendStatus(403)
|
||||||
|
|
|
@ -1,17 +1,13 @@
|
||||||
|
import config from "@lib/config"
|
||||||
import { NextFunction, Request, Response } from "express"
|
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(
|
export default function authenticateToken(
|
||||||
req: Request,
|
req: Request,
|
||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const requestKey = req.headers["x-secret-key"]
|
const requestKey = req.headers["x-secret-key"]
|
||||||
if (requestKey !== key) {
|
if (requestKey !== config.secret_key) {
|
||||||
return res.sendStatus(401)
|
return res.sendStatus(401)
|
||||||
}
|
}
|
||||||
next()
|
next()
|
||||||
|
|
|
@ -95,7 +95,15 @@ admin.get("/post/:id", async (req, res, next) => {
|
||||||
|
|
||||||
admin.delete("/post/:id", async (req, res, next) => {
|
admin.delete("/post/:id", async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const post = await Post.findByPk(req.params.id)
|
const post = await Post.findByPk(req.params.id, {
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: File,
|
||||||
|
as: "files"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
if (!post) {
|
if (!post) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
message: "Post not found"
|
message: "Post not found"
|
||||||
|
|
|
@ -8,10 +8,10 @@ import { celebrate, Joi } from "celebrate"
|
||||||
|
|
||||||
const NO_EMPTY_SPACE_REGEX = /^\S*$/
|
const NO_EMPTY_SPACE_REGEX = /^\S*$/
|
||||||
|
|
||||||
|
// we require a server password if the password is set and we're in production
|
||||||
export const requiresServerPassword =
|
export const requiresServerPassword =
|
||||||
(process.env.MEMORY_DB || process.env.NODE_ENV === "production") &&
|
config.registration_password.length > 0 && config.is_production
|
||||||
!!process.env.REGISTRATION_PASSWORD
|
if (requiresServerPassword) console.log(`Registration password enabled.`)
|
||||||
console.log(`Registration password required: ${requiresServerPassword}`)
|
|
||||||
|
|
||||||
export const auth = Router()
|
export const auth = Router()
|
||||||
|
|
||||||
|
@ -25,10 +25,7 @@ const validateAuthPayload = (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requiresServerPassword) {
|
if (requiresServerPassword) {
|
||||||
if (
|
if (!serverPassword || config.registration_password !== serverPassword) {
|
||||||
!serverPassword ||
|
|
||||||
process.env.REGISTRATION_PASSWORD !== serverPassword
|
|
||||||
) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Server password is incorrect. Please contact the server administrator."
|
"Server password is incorrect. Please contact the server administrator."
|
||||||
)
|
)
|
||||||
|
@ -68,10 +65,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:
|
role: config.enable_admin && count === 0 ? "admin" : "user"
|
||||||
!!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)
|
||||||
|
|
|
@ -38,7 +38,7 @@ posts.post(
|
||||||
userId: Joi.string().required(),
|
userId: Joi.string().required(),
|
||||||
password: Joi.string().optional(),
|
password: Joi.string().optional(),
|
||||||
// expiresAt, allow to be null
|
// expiresAt, allow to be null
|
||||||
expiresAt: Joi.date().optional().allow(null, '')
|
expiresAt: Joi.date().optional().allow(null, "")
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
async (req, res, next) => {
|
async (req, res, next) => {
|
||||||
|
|
Loading…
Reference in a new issue