├── .env.example ├── .github └── workflows │ ├── ci.yml │ └── docs-cd.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc.js ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── compose.yml ├── docs ├── _sidebar.md ├── bootstrap.md ├── concepts.md ├── controllers.md ├── decorators.md ├── getting-started.md ├── index.md ├── misc.md ├── queues.md ├── routes.md └── styles.css ├── package.json ├── pnpm-lock.yaml ├── prisma └── schema.prisma ├── readme.md ├── src ├── app.ts ├── boot.ts ├── configs │ └── index.ts ├── controllers │ ├── auth.ts │ ├── health.ts │ ├── home.ts │ ├── options.ts │ └── post.ts ├── docs.ts ├── jobs │ ├── email-queue-handler.ts │ └── test-queue-handler.ts ├── lib │ ├── constants.ts │ ├── crypto.ts │ ├── db.ts │ ├── email.ts │ ├── queue.ts │ ├── redis.ts │ └── router.ts ├── middlewares │ ├── auth.ts │ └── errors.ts ├── public │ └── styles.min.css ├── server.ts ├── styles │ └── tailwind.css ├── types.d.ts ├── views │ ├── auth │ │ ├── login.njk │ │ └── register.njk │ ├── dashboard.njk │ ├── index.njk │ ├── layouts │ │ └── base.njk │ └── posts │ │ ├── delete.njk │ │ ├── new.njk │ │ ├── public-view.njk │ │ └── show.njk └── worker.ts ├── tailwind.config.js ├── tests ├── basic.spec.ts └── setup.ts ├── tsconfig.json └── vitest.config.ts /.env.example: -------------------------------------------------------------------------------- 1 | PORT=3001 2 | DATABASE_URL="file:./dev.db" 3 | REDIS_PASSWORD="examplepassword" 4 | JWT_SECRET="examplejwtsecret" 5 | SESSION_SECRET="examplesessionsecret" -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | pull_request: 7 | branches: ['main'] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | container: node:10.18-jessie 13 | services: 14 | postgres: 15 | image: postgres:13-alpine 16 | 17 | env: 18 | POSTGRES_PASSWORD: postgres 19 | POSTGRES_DB: thestack 20 | # Set health checks to wait until postgres has started 21 | options: >- 22 | --health-cmd pg_isready --health-interval 10s --health-timeout 5s 23 | --health-retries 5 24 | strategy: 25 | matrix: 26 | node-version: [16.x] 27 | env: 28 | # these could also come from env variables of the actions if you wish for them to 29 | DATABASE_URL: 'postgresql://postgres:postgres@postgres:5432/tillwhen?schema=public' 30 | SESSION_SECRET: 'examplesessionsecret' 31 | steps: 32 | - uses: actions/checkout@v3 33 | - name: Install Node.js 34 | uses: actions/setup-node@v3 35 | with: 36 | node-version: ${{ matrix.node-version }} 37 | 38 | - uses: pnpm/action-setup@v2 39 | with: 40 | version: 8.3.1 41 | 42 | - run: pnpm install --frozen-lockfile 43 | # TODO: Enable after you have defined migrations in your codebase using prisma 44 | # - run: pnpx prisma migrate deploy 45 | - run: pnpm test:ci 46 | -------------------------------------------------------------------------------- /.github/workflows/docs-cd.yml: -------------------------------------------------------------------------------- 1 | # This workflow will run tests using node and then publish a package to GitHub Packages when a release is created 2 | # For more information see: https://docs.github.com/en/actions/publishing-packages/publishing-nodejs-packages 3 | 4 | name: Docs CD 5 | 6 | on: 7 | push: 8 | branches: [main] 9 | pull_request: 10 | branches: [main] 11 | 12 | jobs: 13 | # Build job 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Get Mudkip 19 | run: | 20 | curl -o mudkip.tgz -L https://github.com/barelyhuman/mudkip/releases/latest/download/linux-amd64.tgz 21 | tar -xvzf mudkip.tgz 22 | install linux-amd64/mudkip /usr/local/bin 23 | - name: Build 24 | run: | 25 | mudkip --baseurl="/thestack/" -o='docs_dist' --stylesheet='docs/styles.css' 26 | - uses: actions/upload-pages-artifact@v1 27 | with: 28 | path: './docs_dist' 29 | 30 | # Deploy job 31 | deploy: 32 | # Add a dependency to the build job 33 | needs: build 34 | permissions: 35 | pages: write # to deploy to Pages 36 | id-token: write # to verify the deployment originates from an appropriate source 37 | environment: 38 | name: github-pages 39 | url: ${{ steps.deployment.outputs.page_url }} 40 | runs-on: ubuntu-latest 41 | steps: 42 | - name: Deploy to GitHub Pages 43 | id: deployment 44 | uses: actions/deploy-pages@v2 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | 4 | 5 | prisma/*.db 6 | 7 | .env* 8 | !.env.example 9 | 10 | docs_dist 11 | .DS_Store 12 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | // FORCE prettier to load plugins 2 | module.exports = { 3 | ...require('@barelyhuman/prettier-config'), 4 | htmlWhitespaceSensitivity: 'css', 5 | plugins: ['prettier-plugin-twig-nunjucks-melody'], 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ronnidc.nunjucks", 4 | "esbenp.prettier-vscode", 5 | "bradlc.vscode-tailwindcss" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[jinja-html]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.detectIndentation": false, 5 | "editor.tabSize": 2, 6 | "editor.formatOnSave": true 7 | }, 8 | "prettier.documentSelectors": ["**/*.njk"], 9 | "emmet.includeLanguages": { 10 | "jinja-html": "html", 11 | "nunjucks": "html" 12 | }, 13 | "editor.quickSuggestions": { 14 | "strings": true 15 | }, 16 | "tailwindCSS.includeLanguages": { 17 | "plaintext": "njk", 18 | "njk": "html" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023-Present reaper 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | services: 3 | redis: 4 | restart: always 5 | command: 6 | 'redis-server --save 20 1 --loglevel warning --requirepass $REDIS_PASSWORD' 7 | image: redis 8 | env_file: 9 | - .env 10 | ports: 11 | - '6379:6379' 12 | expose: 13 | - 6379 14 | volumes: 15 | - redis:/data 16 | 17 | # Enable to debug queues 18 | # bullboard: 19 | # image: deadly0/bull-board 20 | # restart: always 21 | # ports: 22 | # - 3000:3000 23 | # environment: 24 | # REDIS_HOST: redis 25 | # REDIS_PORT: 6379 26 | # REDIS_PASSWORD: 'examplepassword' 27 | # REDIS_USE_TLS: 'false' 28 | # BULL_VERSION: 'bull' 29 | # depends_on: 30 | # - redis 31 | 32 | volumes: 33 | redis: 34 | driver: local 35 | -------------------------------------------------------------------------------- /docs/_sidebar.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | [About](%baseurl%)\ 4 | [Getting Started](%baseurl%getting-started)\ 5 | [Concepts](%baseurl%concepts) 6 | 7 |
8 | 9 | --- 10 | -------------------------------------------------------------------------------- /docs/bootstrap.md: -------------------------------------------------------------------------------- 1 | # Bootstrap 2 | 3 | Booting the app actually goes through the `boot.ts` file first. This is where 4 | you will be adding the [Controllers](%baseurl%controllers) and 5 | [Job Queues](%baseurl%queues) imports to make sure the app loads them up. 6 | 7 | ## Caveats 8 | 9 | Due to the nature of testing modularity, the `boot` is loaded in the `app.ts` 10 | file and the `app.ts` imports are what you use for testing. 11 | 12 | This shouldn't be a show stopper but if looked closely does feel like an 13 | after-thought. This can be easily solved by making the boot.ts file a more 14 | complicated booting module but that would lead to too much abstraction for 15 | people to understand. 16 | -------------------------------------------------------------------------------- /docs/concepts.md: -------------------------------------------------------------------------------- 1 | # Concepts 2 | 3 | While I assume that the overall structure is pretty starightforward there's 4 | definitely things that someone might not understand. 5 | 6 | So here's a list of things that the codebase does and it's respective 7 | explanation. 8 | 9 | - [Bootstrap](%baseurl%bootstrap) 10 | - [Controllers](%baseurl%controllers) 11 | - [Routes and Middleware](%baseurl%routes) 12 | - [Decorators](%baseurl%decorators) 13 | - [Queues](%baseurl%queues) 14 | - [Misc](%baseurl%misc) 15 | -------------------------------------------------------------------------------- /docs/controllers.md: -------------------------------------------------------------------------------- 1 | ## Controllers 2 | 3 | The concept of controllers is very similar to what you already assume them to 4 | be. These provide functionality to the routes that are being defined for your 5 | API. 6 | 7 | In `TheStack` , a controller is simply a class that's being exported from the 8 | `src/controllers/` directory. 9 | 10 | ```ts 11 | // ./src/controllers/health.ts 12 | 13 | export default class Health { 14 | ping() { 15 | // functionality 16 | } 17 | } 18 | ``` 19 | 20 | As you can see, we haven't defined any functionality but they are just classes 21 | with methods. 22 | 23 | We aren't doing anything OOP related specifically over here but the only reason 24 | for using Classes here is to be able to use [Decorators](%baseurl%decorators). 25 | 26 | With the help of these decorators, we can now bind these controller **methods** 27 | to be able to receive network request context. 28 | 29 | ```ts 30 | // ./src/controllers/health.ts 31 | 32 | // import the get decorator from the router 33 | import { get } from '@/lib/router' 34 | 35 | // import the types for Request and Response 36 | import type { Request, Response } from 'express' 37 | 38 | export default class Health { 39 | // assign the decorator to the `ping` method for the route `GET /api/ping` 40 | @get('/api/ping') 41 | ping(req: Request, res: Response) { 42 | return res.send({ pong: true }) 43 | } 44 | } 45 | ``` 46 | 47 | Once you've added these, the controller's method `ping` is now called when the 48 | http route `/api/ping` is requested with the method `GET` 49 | 50 | You can read more about [routes and middleware](%baseurl%routes) 51 | -------------------------------------------------------------------------------- /docs/decorators.md: -------------------------------------------------------------------------------- 1 | # Decorators 2 | 3 | You can learn about the basics of decorators from the 4 | [typescript docs](https://www.typescriptlang.org/docs/handbook/decorators.html). 5 | 6 | This particular section explains how decorators are being used to help with 7 | [Controller](%baseurl%controllers) and [Route](%baseurl%routes) binding. 8 | 9 | ## Route Decorators 10 | 11 | The route decorators are located in `src/lib/router` file and define basic HTTP 12 | Method handlers that tie to the express router. There's no magic in this section 13 | it's a very simple binding for ES6 class methods. 14 | 15 | The following show the entire API for the route decorators. 16 | 17 | `get(path:string, middleware: MiddlewareFuncs[])` 18 | `post(path:string, middleware: MiddlewareFuncs[])` 19 | `del(path:string, middleware: MiddlewareFuncs[])` 20 | 21 | ```js 22 | class ClassDefinition { 23 | @get('api/path', [middleware]) 24 | method(req, res) { 25 | // request handling 26 | } 27 | } 28 | ``` 29 | 30 | The `req`,`res` objects that you get a just plain ExpressJS types and there's a 31 | few extentions to those types defined in `src/types.d.ts` 32 | 33 | ## Job/Queue Decorators 34 | 35 | `TheStack` comes with a Queue/Scheduler already as a part of the codebase and so 36 | there's helpers for you to be able to define methods as queue handlers. 37 | 38 | > **Note**: While you could just define these decorators in the Controllers, it 39 | > becomes hard to manage both network request handlers and queue handlers in the 40 | > same controller and so it's recommended to add job handlers in the 41 | > `src/jobs/.ts` files 42 | 43 | You can find the queue `listen` decorator in the `./src/lib/queue` file and it 44 | does the same thing as the route decorators but instead binds the method to a 45 | bull processing job. 46 | 47 | With regards to the type of the job and the input the method gets, do checkout 48 | [`bull` docs](https://optimalbits.github.io/bull/). 49 | 50 | Here's a quick example of how to use this 51 | 52 | - Define the queue to create in the `boot.ts` file 53 | - Here we are taking name from the config object 54 | 55 | ```ts 56 | // src/boot.ts 57 | import { config } from '@/configs' 58 | 59 | createProducer(config.queue.email.name) 60 | ``` 61 | 62 | - Now you can `pushToQueue` using your controllers or use the `pushToQueue` 63 | export from `./src/lib/queue` in the same manner anywhere else in the 64 | codebase. 65 | 66 | ```ts 67 | // src/controllers/auth.ts 68 | export class Auth { 69 | @post('/auth/register') 70 | register(req: Request, res: Response) { 71 | const { email } = req.body 72 | 73 | req.pushToQueue(config.queue.email.name, { 74 | email: email, 75 | // The type is used to classify if a sub-type handler is to be used 76 | type: config.queue.email.types.loginEmail, 77 | }) 78 | } 79 | } 80 | ``` 81 | 82 | Finally, we define the handler in the `./src/jobs/email-queue-handler.ts` file 83 | and tie a method with the `@listen` decorator and pass it the queue name and 84 | subtype(optional) that it needs to listen for. 85 | 86 | Here the `job` parameter received by the method is the original job object 87 | passed by Bull with no modifications 88 | 89 | ```ts 90 | // ./src/jobs/email-queue-handler.ts 91 | import { config } from '@/configs' 92 | import { sendEmail } from '@/lib/email' 93 | import { listen } from '@/lib/queue' 94 | 95 | export class EmailQueueHandler { 96 | @listen( 97 | // type 98 | config.queue.email.name, 99 | // subtype 100 | config.queue.email.types.loginEmail 101 | ) 102 | async handleEmail(job: any) { 103 | const toEmail = job.data.email 104 | await sendEmail({ 105 | toEmail, 106 | subject: 'hello human', 107 | html: '

Please approve log-in request

', 108 | }) 109 | } 110 | } 111 | ``` 112 | 113 | If you wish to read more about [Queues](%baseurl%queues) 114 | -------------------------------------------------------------------------------- /docs/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Using the Template 4 | 5 | You've got a few options 6 | 7 | - Clone the repository, delete the `.git` folder and then run `git init` to 8 | start fresh. 9 | - Click the `Use Template` button (make sure you are logged in) on GitHub, if 10 | you plan to use GitHub for your work. 11 | - Use a git based scaffolding tool 12 | 13 | ```sh 14 | npx degit barelyhuman/thestack 15 | ``` 16 | 17 | > **Note**: Replace `` with the folder/project-name you'd 18 | > like to setup 19 | 20 | - Create the basic `.env` file, you can modify values as needed but the 21 | `.example` comes with dummy values that should help you get started either 22 | ways 23 | 24 | ```sh 25 | $ cp .env.example .env 26 | ``` 27 | 28 | ## Package Management 29 | 30 | You are free to use any package manager but the template comes with `pnpm` as 31 | the preferred package manager. If you do not wish to continue with that then you 32 | can switch to a different one using the following steps. 33 | 34 | 1. Delete the `pnpm-lock.yaml` file 35 | 2. Delete `"packageManager": "pnpm@8.3.1"` from `package.json`. 36 | 3. Run the `install` command of your preferred package manager 37 | 4. Make sure you add the `lock` file generated by your selected package manager. 38 | 39 | ```sh 40 | npm install 41 | # or 42 | yarn install 43 | # or whatever else exists at this point 44 | ``` 45 | 46 | ## Starting the Server 47 | 48 | **Prerequisites** 49 | 50 | - Docker and Docker compose installed (if using the app with redis) 51 | - If you do not plan to use redis then you should also remove the ratelimiter 52 | code in `app.ts` 53 | 54 | If you decided to keep the redis and rate limiter implementation and want to use 55 | a local redis instance then run the following before running the `Dev` or `Prod` 56 | modes 57 | 58 | ```sh 59 | $ docker compose up # run in foreground 60 | $ docker compose up -d # run the service in the background 61 | ``` 62 | 63 | The scripts to start the server in both dev and prod mode are already in place. 64 | 65 | **Dev Mode** (Will watch for changes and restart) 66 | 67 | ```sh 68 | npm run dev 69 | # or 70 | yarn dev 71 | # or 72 | pnpm run dev 73 | ``` 74 | 75 | **Run/Prod Mode** 76 | 77 | ```sh 78 | npm run start 79 | # or 80 | yarn start 81 | # or 82 | pnpm run start 83 | ``` 84 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # The Stack 2 | 3 | > Get out of the hype train 4 | 5 | This is a tiny attempt at providing a existing set of decisions to help you get 6 | started on the work you actually wish to do. 7 | 8 | A pretty big part of Ruby on Rails is it's generator CLI and the initial 9 | boilerplate. Which comes with it's own set of magic and DSL. I'm not a fan of 10 | the magic since it stops me from understanding stuff but that's not true for 11 | every developer so we keep the magic but also keep the secrets behind the magic 12 | in the codebase. 13 | 14 | This ends up giving you the ability to quickly define routes in controllers 15 | using `decorators` and for the curious one's the implementation of the 16 | decorators is a part of the codebase. 17 | 18 | Here's a small snippet of the `controllers/options.ts` file to explain what I 19 | mean 20 | 21 | ```ts 22 | import { OPTIONS } from '@/lib/constants' 23 | import { get } from '@/lib/router' 24 | import { Response } from 'express' 25 | 26 | export class Options { 27 | @get('/options') 28 | getOptions(_: Request, res: Response) { 29 | return res.send(Object.values(OPTIONS)) 30 | } 31 | } 32 | ``` 33 | 34 | If you like this approach then [let's move forward](%baseurl%getting-started) 35 | -------------------------------------------------------------------------------- /docs/misc.md: -------------------------------------------------------------------------------- 1 | # Misc 2 | 3 |

Example

4 | 5 | The codebase does come with a microblogging example already implemented for you. 6 | Please take note that this is just an example implementation and not a full 7 | featured implementation with all edge cases covered **yet** (as of v0.1.0) and 8 | we will update it to be a more solid base for you to start with but that would 9 | also involve making the codebase specifically the controllers a lot more 10 | complicated for beginners. 11 | 12 | Either way, here's the things that the Example covers right now. 13 | 14 |

Authentication

15 | 16 | - `middlewares/auth` and `controllers/auth` are responsible for most of the 17 | functionality around the authentication implementation. The implementation is 18 | session based token store which can be improved by splittling the cookie 19 | into 2. 20 | - one for the session and one for auth, right now it's in one to show how the 21 | session can be used in the tiny application. 22 | - There's also the `notLoggedIn` middleware which redirects authenticated users 23 | to their relvant pages when visiting pages like `login` and `register` when 24 | you are already logged in 25 | 26 |

Views

27 | 28 | - The views are implemented with nunjucks due to the low learning curve 29 | - `views/*.njk` files are responsible for what you see on different routes, each 30 | of these are rendered using[controllers](%baseurl%controllers) and are easily 31 | searchable for looking for the url path in your IDE's find all utility. 32 | - The `views/layouts/base.njk` also implements a header that conditionally shows 33 | navigation based on the login state of the user 34 | 35 |

Flash Messages

36 | 37 | Flash messages are the de-facto way to pass through alerts through various 38 | redirects and these are also a part of the example and is responsible for all 39 | the alerts that you see on the top after you either update the post or delete 40 | it. 41 | 42 | > **Note**: These can be replaced with various other server session state 43 | > messages if you are server rendering views. 44 | 45 |

CSS / Styling

46 | 47 | TailwindCSS comes setup for you, and in most places you'll see it being used for 48 | layout more than styling. Most of the classes for tailwind are created as 49 | shortcuts using the `@apply` css decorator in the `styles/tailwind.css` file and 50 | then the shortcuts are used in necessary places. This is done since the Views 51 | approach cannot do components (unless you are using Adonis Edge) and so copying 52 | the same button everywhere makes no sense. It's still easy to override these 53 | shortcut based styles wherever you need them but having a quick class for the 54 | base still helps. 55 | 56 |

Validation

57 | 58 | The Example comes with `Yup` as the payload validator for the controllers, this 59 | is just based on my own personal opinion and habit of using Yup everywhere. 60 | 61 | > **Note**: You are not limited to using Yup and can switch this to 62 | > [zod](https://zod.dev) or anything else that you like. 63 | -------------------------------------------------------------------------------- /docs/queues.md: -------------------------------------------------------------------------------- 1 | # Queues 2 | 3 | > **TBD** 4 | 5 | While this doc is incomplete, you can still get the basic idea from the **Job 6 | Decorators** section in [Decorators](%baseurl%decorators) 7 | -------------------------------------------------------------------------------- /docs/routes.md: -------------------------------------------------------------------------------- 1 | # Routes 2 | 3 | The basic way of exposing your functionality is by exposing them over REST API's 4 | and this is common when building a web app. 5 | 6 | There may be cases where you are building micro services and would need to build 7 | using an onion architecture / hexagonal arcitechture or whatever the new name 8 | for Bottom Up dependency trees is. 9 | 10 | > **Note**: `TheStack` is not for the latter case, and you are better of just 11 | > writing the functionalities as a isolated package and going forward with that 12 | > approach instead of trying to achieve something that requires a good 13 | > inheritence system like Java 14 | 15 | Defining routes, is pretty simple, the current version of `TheStack` comes with 16 | the following HTTP methods 17 | 18 | > **Methods**: `get`,`post`,`del` 19 | 20 | I know there's `put` , `patch`, `update`, etc, but these are easy to add and 21 | we'll go through how to do that. 22 | 23 | ### Basic Routes 24 | 25 | - Simple Get route 26 | 27 | ```ts 28 | import { get } from '@/lib/router' 29 | import type { Request, Response } from 'express' 30 | 31 | export default class Health { 32 | @get('/api/ping') 33 | ping(req: Request, res: Response) { 34 | return res.send({ pong: true }) 35 | } 36 | } 37 | ``` 38 | 39 | - Dynamic Parameter 40 | 41 | ```ts 42 | import { get } from '@/lib/router' 43 | import type { Request, Response } from 'express' 44 | 45 | export default class UserController { 46 | @get('/api/user/:id') 47 | fetchUserById(req: Request, res: Response) { 48 | return res.send({ id: req.params.id }) 49 | } 50 | } 51 | ``` 52 | 53 | - Routes with Middleware 54 | 55 | ```ts 56 | import { get } from '@/lib/router' 57 | import { auth } from '@/middlewares/auth' 58 | import type { Request, Response } from 'express' 59 | 60 | export default class UserController { 61 | @post('/api/user/:id', [auth]) // you can add as many as you want here. 62 | createUser(req: Request, res: Response) { 63 | return res.send({ id: req.params.id }) 64 | } 65 | } 66 | ``` 67 | 68 | # Middleware 69 | 70 | The middleware definitions are still the same as any expressjs middleware, so 71 | the documentation for that can be found on the 72 | [expressjs docs](https://expressjs.com/en/guide/writing-middleware.html) 73 | -------------------------------------------------------------------------------- /docs/styles.css: -------------------------------------------------------------------------------- 1 | @import url('https://rsms.me/raster/raster2.css?v=20'); 2 | @import url('https://unpkg.com/highlightjs@9.16.2/styles/grayscale.css'); 3 | 4 | :root { 5 | --font-mono: Menlo, Consolas, Monaco, Liberation Mono, Lucida Console, 6 | monospace; 7 | --base: #fff; 8 | --text: #343a40; 9 | --subtle: #495057; 10 | --foreground-color: var(--text); 11 | } 12 | 13 | pre { 14 | padding: 10px; 15 | font-family: var(--font-mono); 16 | } 17 | 18 | .hljs { 19 | white-space: pre-wrap; 20 | } 21 | 22 | #search-container { 23 | display: flex; 24 | flex-direction: column; 25 | justify-content: center; 26 | align-items: flex-end; 27 | position: relative; 28 | } 29 | 30 | #search-container > input { 31 | border: 1px solid var(--subtle); 32 | width: 250px; 33 | padding: 10px; 34 | border-radius: 4px; 35 | } 36 | 37 | #search-container > #search-results { 38 | border: 1px solid #f1f3f5; 39 | width: 250px; 40 | background: white; 41 | box-shadow: 0px 4px 8px rgba(0, 0, 0, 0); 42 | border-radius: 4px; 43 | } 44 | 45 | #search-container > #search-results > .mudkip-search-item { 46 | padding: 10px; 47 | } 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tillwhen-backend", 3 | "version": "0.1.0", 4 | "main": "index.js", 5 | "packageManager": "pnpm@8.3.1", 6 | "scripts": { 7 | "dev": "concurrently 'nr dev:server' 'nr dev:css'", 8 | "dev:server": "npx nodemon -e ts,njk,css -r tsconfig-paths/register src/server.ts", 9 | "dev:worker": "npx tsnd --exit-child -r tsconfig-paths/register src/worker.ts", 10 | "dev:css": "npx tailwindcss -i ./src/styles/tailwind.css -o ./src/public/styles.min.css --watch", 11 | "start": "npx ts-node --transpileOnly -r tsconfig-paths/register src/server.ts", 12 | "css": "npx tailwindcss -i ./src/styles/tailwind.css -o ./src/public/styles.min.css --minify", 13 | "test:types": "tsc", 14 | "fix": "npx prettier --write .", 15 | "test": "cross-env NODE_ENV=TEST vitest ./tests", 16 | "test:ci": "cross-env NODE_ENV=TEST vitest run ./tests", 17 | "db:migrate": "npx prisma migrate deploy", 18 | "db:reset": "npx prisma migrate reset", 19 | "db:push": "npx prisma db push", 20 | "db:make:migration": "npx prisma migrate dev", 21 | "prepare": "husky install" 22 | }, 23 | "license": "MIT", 24 | "dependencies": { 25 | "@prisma/client": "4.15.0", 26 | "body-parser": "^1.20.2", 27 | "bull": "^4.10.4", 28 | "compression": "^1.7.4", 29 | "connect-redis": "^7.1.0", 30 | "cookie-parser": "^1.4.6", 31 | "cors": "^2.8.5", 32 | "csrf": "^3.1.0", 33 | "date-fns": "^2.30.0", 34 | "dotenv": "^16.3.1", 35 | "express": "^4.18.2", 36 | "express-flash": "^0.0.2", 37 | "express-jsdoc-swagger": "^1.8.0", 38 | "express-limiter": "^1.6.1", 39 | "express-session": "^1.17.3", 40 | "helmet": "^7.0.0", 41 | "ioredis": "^5.3.2", 42 | "jsonwebtoken": "^9.0.0", 43 | "morgan": "^1.10.0", 44 | "ms": "^2.1.3", 45 | "nodemailer": "^6.9.3", 46 | "nunjucks": "^3.2.4", 47 | "serve-static": "^1.15.0", 48 | "yup": "^1.2.0" 49 | }, 50 | "devDependencies": { 51 | "@antfu/ni": "^0.21.4", 52 | "@barelyhuman/prettier-config": "^1.1.0", 53 | "@tailwindcss/forms": "^0.5.3", 54 | "@types/connect-redis": "^0.0.20", 55 | "@types/express": "^4.17.17", 56 | "@types/express-flash": "^0.0.2", 57 | "@types/express-session": "^1.17.7", 58 | "@types/node": "^20.3.1", 59 | "axios": "^1.4.0", 60 | "concurrently": "^8.2.0", 61 | "cross-env": "^7.0.3", 62 | "husky": ">=7", 63 | "ioredis-mock": "^8.7.0", 64 | "lint-staged": ">=10", 65 | "nodemon": "^2.0.22", 66 | "prettier": "^2.8.8", 67 | "prettier-plugin-twig-nunjucks-melody": "^0.1.0", 68 | "pretty-quick": "^3.1.3", 69 | "prisma": "^4.15.0", 70 | "tailwindcss": "^3.3.2", 71 | "test-listen": "^1.1.0", 72 | "ts-node": "^10.9.1", 73 | "ts-node-dev": "^2.0.0", 74 | "tsconfig-paths": "^4.2.0", 75 | "typescript": "^5.1.6", 76 | "vite": "^4.3.9", 77 | "vitest": "^0.32.2" 78 | }, 79 | "lint-staged": { 80 | "*.{ts,tsx,js,css,md}": "prettier --write" 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "sqlite" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model Team { 14 | id Int @id @default(autoincrement()) 15 | isActive Boolean @default(true) 16 | createdAt DateTime @default(now()) 17 | updatedAt DateTime @updatedAt 18 | 19 | name String 20 | TeamMember TeamMember[] 21 | } 22 | 23 | model User { 24 | id Int @id @default(autoincrement()) 25 | createdAt DateTime @default(now()) 26 | updatedAt DateTime @updatedAt 27 | email String 28 | name String? 29 | password String 30 | salt String 31 | 32 | TeamMember TeamMember[] 33 | Tokens Tokens[] 34 | Post Post[] 35 | } 36 | 37 | model TeamMember { 38 | id Int @id @default(autoincrement()) 39 | isActive Boolean @default(true) 40 | createdAt DateTime @default(now()) 41 | updatedAt DateTime @updatedAt 42 | 43 | // Values / enum for `role` are in lib/constants 44 | role Int 45 | 46 | invitedEmail String? 47 | user User? @relation(fields: [userId], references: [id]) 48 | userId Int? 49 | team Team @relation(fields: [teamId], references: [id]) 50 | teamId Int 51 | } 52 | 53 | model Post { 54 | id Int @id @default(autoincrement()) 55 | isActive Boolean @default(true) 56 | createdAt DateTime @default(now()) 57 | updatedAt DateTime @updatedAt 58 | 59 | published Boolean 60 | content String 61 | user User @relation(fields: [userId], references: [id]) 62 | userId Int 63 | } 64 | 65 | model Profile { 66 | id Int @id @default(autoincrement()) 67 | isActive Boolean @default(true) 68 | createdAt DateTime @default(now()) 69 | updatedAt DateTime @updatedAt 70 | 71 | // settings row 72 | } 73 | 74 | model Tokens { 75 | id Int @id @default(autoincrement()) 76 | isActive Boolean @default(true) 77 | createdAt DateTime @default(now()) 78 | updatedAt DateTime @updatedAt 79 | 80 | name String 81 | authToken String? 82 | 83 | user User @relation(fields: [userId], references: [id]) 84 | userId Int 85 | } 86 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # thestack 2 | 3 | I spent the time making the decisions so you don't have to. 4 | 5 | ## Documentation 6 | 7 | You can read the [docs](/docs/) folder or the 8 | [web version](https://barelyhuman.github.io/thestack/) 9 | 10 | ## What comes bundled 11 | 12 | - Simpler Express based server 13 | - Decorators + Classes for Routing 14 | 15 | ```ts 16 | class Auth { 17 | @post('/auth/signup') 18 | async signup(req, res) { 19 | // handler 20 | } 21 | } 22 | ``` 23 | 24 | - Typed (Mostly...) 25 | - Basic Security (Helmet, Rate Limiting, CORS) 26 | - API testing (Vitest + Mock Server) 27 | - ORM (prisma) 28 | - Migrations (prisma) 29 | - Queue (bull + redis) 30 | - Decorators for Queue Handling based on Worker execution 31 | - View Engine ([Nunjucks](https://mozilla.github.io/nunjucks/)) 32 | 33 | ```ts 34 | class User { 35 | @post('/user/invite') 36 | sendInvite(req, res) { 37 | const email = req.body.email 38 | req.pushToQueue('email', { 39 | email, 40 | type: 'invite', 41 | }) 42 | } 43 | } 44 | // jobs/email.queue.ts 45 | class EmailQueueHandler { 46 | @listen('email', 'invite') 47 | handleInviteJob(job) { 48 | // Called from the worker 49 | // process the job 50 | } 51 | } 52 | ``` 53 | 54 | - Compose (Self host services for dev / k8s) 55 | - Fully Docker compatible 56 | - Testing Mocks (Redis) 57 | - Swagger Doc generation using JSDoc - 58 | [Documentation](https://brikev.github.io/express-jsdoc-swagger-docs/#/) 59 | 60 | ```js 61 | /** 62 | * 63 | * GET /api/ping 64 | * @summary quickly check if the server is running or not 65 | * @returns 200 - success - application/json 66 | */ 67 | @get('/api/ping') 68 | ping(req: Request, res: Response) { 69 | req.pushToQueue('test', { 70 | type: '', 71 | }) 72 | return res.send({ 73 | success: true, 74 | now: new Date().toISOString(), 75 | }) 76 | } 77 | ``` 78 | 79 | ## Why? 80 | 81 | Various reasons...., 82 | [this might be relevant](https://reaper.is/writing/20230516-ignoring-backend-productivity) 83 | 84 | ## License 85 | 86 | [MIT](/LICENSE) 87 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { config as loadEnv } from 'dotenv' 2 | 3 | loadEnv() 4 | 5 | import '@/boot' 6 | import { pushToQueue } from '@/lib/queue' 7 | import { client, client as redisClient } from '@/lib/redis' 8 | import { router } from '@/lib/router' 9 | import bodyParser from 'body-parser' 10 | import compression from 'compression' 11 | import cors from 'cors' 12 | import express, { NextFunction, Request, Response } from 'express' 13 | import expressLimiter from 'express-limiter' 14 | import helmet from 'helmet' 15 | import morgan from 'morgan' 16 | import path from 'path' 17 | import serveStatic from 'serve-static' 18 | import cookieParser from 'cookie-parser' 19 | import { initDocs } from './docs' 20 | import session from 'express-session' 21 | import Tokens from 'csrf' 22 | import RedisStore from 'connect-redis' 23 | import { config } from './configs' 24 | import nunjucks from 'nunjucks' 25 | import { errorMiddleware } from './middlewares/errors' 26 | import flash from 'express-flash' 27 | 28 | const csrf = new Tokens() 29 | export const app = express() 30 | 31 | export const initApp = ({ db }) => { 32 | // TODO: configure proper cors here 33 | app.use(cors()) 34 | 35 | // Common defaults 36 | app.set('views', path.join(__dirname, 'views')) 37 | app.disable('x-powered-by') 38 | app.use(morgan('tiny')) 39 | app.use( 40 | helmet({ 41 | contentSecurityPolicy: { 42 | directives: { 43 | 'script-src': ["'self'", 'unpkg.com', 'fonts.bunny.net'], 44 | }, 45 | }, 46 | }) 47 | ) 48 | app.use(bodyParser.urlencoded({ extended: true })) 49 | app.use(bodyParser.json()) 50 | app.use(cookieParser()) 51 | app.use( 52 | compression({ 53 | filter: (req, res) => { 54 | return Boolean(req.headers['x-no-compression']) 55 | }, 56 | }) 57 | ) 58 | 59 | // Flash Messages for Errors and Info on Server Rendered Templates 60 | app.use(flash()) 61 | 62 | // Configure templates 63 | nunjucks.configure(path.join(__dirname, 'views'), { 64 | autoescape: true, 65 | express: app, 66 | }) 67 | 68 | // Add in helpers for REST API Errors 69 | app.use(errorMiddleware) 70 | 71 | let redisStore = new RedisStore({ 72 | client, 73 | // TODO: change this to your custom prefix 74 | prefix: 'thestack:', 75 | }) 76 | 77 | app.use( 78 | session({ 79 | store: redisStore, 80 | resave: false, // required: force lightweight session keep alive (touch) 81 | saveUninitialized: false, // recommended: only save session when data exists 82 | secret: config.security.session, 83 | cookie: { 84 | // Requires HTTPS to work properly, better off left disabled when working 85 | // in local , and can be enabled in production where HTTPS redirection is 86 | // mandatory 87 | secure: config.isProduction, 88 | }, 89 | }) 90 | ) 91 | 92 | // Basic CSRF Handling 93 | app.use((req, res, next) => { 94 | const token = req.cookies['csrf-token'] 95 | 96 | if (req.method === 'GET') { 97 | const secret = csrf.secretSync() 98 | const secretToken = csrf.create(secret) 99 | // @ts-expect-error cannot deep interface with session 100 | req.session.csrfSecret = secret 101 | 102 | res.cookie('csrf-token', secretToken) 103 | res.locals.csrfToken = req.cookies['csrf-token'] 104 | } else { 105 | // @ts-expect-error cannot deep interface with session 106 | if (!csrf.verify(req.session.csrfSecret, token)) { 107 | return res.status(403).send('Invalid CSRF token') 108 | } 109 | } 110 | 111 | next() 112 | }) 113 | 114 | app.use('/public', serveStatic(path.join(__dirname, './public'))) 115 | 116 | // Basic Rate limiter 117 | // TODO: change in the lookup and 118 | // expiry / total combination according to your usecase 119 | const limiter = expressLimiter(router, redisClient) 120 | app.use( 121 | limiter({ 122 | path: '*', 123 | method: 'all', 124 | lookup: ['headers.x-forwarded-for', 'connection.remoteAddress'], 125 | // 1000 request / 5 mins 126 | total: 1000, 127 | expire: 1000 * 60 * 5, 128 | }) 129 | ) 130 | 131 | initDocs(app) 132 | 133 | app.use(extender(db)) 134 | app.use(router) 135 | 136 | return app 137 | } 138 | 139 | const extender = db => (req: Request, res: Response, n: NextFunction) => { 140 | req.db = db 141 | req.pushToQueue = pushToQueue 142 | n() 143 | } 144 | -------------------------------------------------------------------------------- /src/boot.ts: -------------------------------------------------------------------------------- 1 | import { config } from '@/configs' 2 | import { createProducer } from '@/lib/queue' 3 | 4 | // example queues 5 | // Inline name 6 | createProducer('test') 7 | // name from config 8 | createProducer(config.queue.email.name) 9 | 10 | // get the controllers 11 | import '@/controllers/home' 12 | import '@/controllers/options' 13 | import '@/controllers/health' 14 | import '@/controllers/auth' 15 | import '@/controllers/post' 16 | -------------------------------------------------------------------------------- /src/configs/index.ts: -------------------------------------------------------------------------------- 1 | export const config = { 2 | isProduction: process.env.NODE_ENV === 'production', 3 | app: { 4 | operationsEmail: 'ahoy@barelyhuman.dev', 5 | }, 6 | security: { 7 | jwt: process.env.JWT_SECRET, 8 | session: process.env.SESSION_SECRET, 9 | }, 10 | redis: { 11 | host: process.env.REDIS_HOST || '127.0.0.1', 12 | password: process.env.REDIS_PASSWORD || 'examplepassword', 13 | port: Number(process.env.REDIS_PORT) || 6379, 14 | }, 15 | email: { 16 | host: process.env.SMTP_HOST, 17 | port: Number(process.env.SMTP_PORT), 18 | secure: Boolean(process.env.SMTP_SECURE) || false, 19 | username: process.env.SMTP_USERNAME, 20 | password: process.env.SMTP_PASSWORD, 21 | }, 22 | queue: { 23 | email: { 24 | name: 'email', 25 | types: { 26 | loginEmail: 'email:login', 27 | }, 28 | }, 29 | }, 30 | } 31 | -------------------------------------------------------------------------------- /src/controllers/auth.ts: -------------------------------------------------------------------------------- 1 | import { config } from '@/configs' 2 | import { hashPassword, verifyPassword } from '@/lib/crypto' 3 | import { get, post } from '@/lib/router' 4 | import { notLoggedIn, optionalLoggedIn } from '@/middlewares/auth' 5 | import { randomBytes } from 'crypto' 6 | import type { Request, Response } from 'express' 7 | import * as Yup from 'yup' 8 | 9 | const CreateUserSchema = Yup.object().shape({ 10 | email: Yup.string().email().required(), 11 | password: Yup.string().required(), 12 | confirmPassword: Yup.string() 13 | .required() 14 | .oneOf([Yup.ref('password'), null], 'Passwords must match'), 15 | }) 16 | 17 | const LoginUserSchema = Yup.object().shape({ 18 | email: Yup.string().email().required(), 19 | password: Yup.string().required(), 20 | }) 21 | 22 | export default class Auth { 23 | @get('/logout', [optionalLoggedIn]) 24 | logout(req: Request, res) { 25 | // @ts-expect-error token doesn't exist on session 26 | delete req.session.token 27 | return res.redirect(302, '/login') 28 | } 29 | 30 | @get('/login', [notLoggedIn]) 31 | loginView(req, res) { 32 | return res.render('auth/login.njk') 33 | } 34 | 35 | @post('/login') 36 | async attemptLogin(req: Request, res: Response) { 37 | try { 38 | const payload = await LoginUserSchema.validate(req.body) 39 | 40 | const userDetails = await req.db.user.findFirst({ 41 | where: { 42 | email: payload.email, 43 | }, 44 | }) 45 | 46 | if (!userDetails) { 47 | req.flash('error', 'Invalid credentials, please try again') 48 | return res.redirect(302, '/login') 49 | } 50 | 51 | const isPasswordValid = verifyPassword( 52 | payload.password, 53 | userDetails.password, 54 | userDetails.salt 55 | ) 56 | 57 | if (!isPasswordValid) { 58 | req.flash('error', 'Invalid credentials, please try again') 59 | return res.redirect(302, '/login') 60 | } 61 | 62 | const authToken = randomBytes(32).toString('base64url') 63 | 64 | await req.db.tokens.create({ 65 | data: { 66 | // TODO: get from request source 67 | name: 'Browser Token', 68 | userId: userDetails.id, 69 | authToken: authToken, 70 | }, 71 | }) 72 | 73 | // @ts-ignore 74 | req.session.token = authToken 75 | 76 | return res.redirect(302, '/posts') 77 | } catch (err) { 78 | console.error(err) 79 | 80 | if (err instanceof Yup.ValidationError) { 81 | res.badParameters(err) 82 | return 83 | } 84 | 85 | return res.serverError(err) 86 | } 87 | } 88 | 89 | @get('/register', [notLoggedIn]) 90 | registerView(req: Request, res: Response) { 91 | return res.render('auth/register.njk') 92 | } 93 | 94 | @post('/register', [notLoggedIn]) 95 | async register(req: Request, res: Response) { 96 | try { 97 | const payload = await CreateUserSchema.validate(req.body) 98 | 99 | const { pass, salt } = hashPassword(payload.password) 100 | 101 | const insertedUser = await req.db.user.create({ 102 | data: { 103 | email: payload.email, 104 | name: payload.email, 105 | salt, 106 | password: pass, 107 | }, 108 | }) 109 | 110 | req.pushToQueue(config.queue.email.name, { 111 | email: payload.email, 112 | type: config.queue.email.types.loginEmail, 113 | }) 114 | 115 | req.flash('info', 'Registered successfully, please log in') 116 | return res.redirect('/login') 117 | } catch (err) { 118 | if (err instanceof Yup.ValidationError) { 119 | res.badParameters(err) 120 | return 121 | } 122 | 123 | return res.serverError(err) 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/controllers/health.ts: -------------------------------------------------------------------------------- 1 | import { get } from '@/lib/router' 2 | import type { Response, Request } from 'express' 3 | 4 | export default class Health { 5 | /** 6 | * 7 | * GET /api/ping 8 | * @summary quickly check if the server is running or not 9 | * @returns 200 - success - application/json 10 | */ 11 | @get('/api/ping') 12 | ping(req: Request, res: Response) { 13 | req.pushToQueue('test', { 14 | type: '', 15 | }) 16 | return res.send({ 17 | success: true, 18 | now: new Date().toISOString(), 19 | }) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/controllers/home.ts: -------------------------------------------------------------------------------- 1 | import { get } from '@/lib/router' 2 | import { auth, optionalLoggedIn } from '@/middlewares/auth' 3 | import { Request } from 'express' 4 | 5 | export class HomeController { 6 | @get('/', [optionalLoggedIn]) 7 | async index(req: Request, res) { 8 | const limit = req.query.limit ? +req.query.limit : 10 9 | const page = req.query.page ? +req.query.page : 0 10 | const posts = await req.db.post.findMany({ 11 | where: { 12 | published: true, 13 | }, 14 | orderBy: { 15 | createdAt: 'desc', 16 | }, 17 | include: { 18 | user: { 19 | select: { 20 | email: true, 21 | }, 22 | }, 23 | }, 24 | skip: page * limit, 25 | take: limit, 26 | }) 27 | 28 | const totalPosts = await req.db.post.count({ 29 | where: { 30 | published: true, 31 | }, 32 | }) 33 | 34 | const hasNext = page + 1 < Math.floor(totalPosts / limit) 35 | const hasPrev = page > 0 36 | 37 | return res.render('index.njk', { 38 | posts, 39 | page: page, 40 | limit: limit, 41 | hasNext, 42 | hasPrev, 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/controllers/options.ts: -------------------------------------------------------------------------------- 1 | import { OPTIONS } from '@/lib/constants' 2 | import { get } from '@/lib/router' 3 | import { Response } from 'express' 4 | 5 | export class Options { 6 | @get('/options') 7 | getOptions(_: Request, res: Response) { 8 | return res.send(Object.values(OPTIONS)) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/controllers/post.ts: -------------------------------------------------------------------------------- 1 | import { del, get, post } from '@/lib/router' 2 | import { auth, optionalLoggedIn } from '@/middlewares/auth' 3 | import type { Request, Response } from 'express' 4 | 5 | const isPostOwner = async (req: Request, res: Response, next) => { 6 | const postId = Number(req.params.id) 7 | const postDetails = await req.db.post.findUnique({ 8 | where: { 9 | id: postId, 10 | }, 11 | }) 12 | 13 | if (!req.currentUser?.id) { 14 | req.flash( 15 | 'error', 16 | "Sorry, you don't have enough permissions to perform this action" 17 | ) 18 | return res.redirect(302, '/posts') 19 | } 20 | 21 | if (postDetails.userId !== req.currentUser.id) { 22 | req.flash( 23 | 'error', 24 | "Sorry, you don't have enough permissions to perform this action" 25 | ) 26 | return res.redirect(302, '/posts') 27 | } 28 | 29 | return next() 30 | } 31 | 32 | export default class Post { 33 | @get('/posts', [auth]) 34 | async dashboard(req, res) { 35 | const posts = await req.db.post.findMany({ 36 | where: { 37 | userId: req.currentUser.id, 38 | }, 39 | }) 40 | return res.render('dashboard.njk', { 41 | posts, 42 | }) 43 | } 44 | 45 | @get('/posts/new', [auth]) 46 | createPostView(req: Request, res: Response) { 47 | return res.render('posts/new.njk') 48 | } 49 | 50 | @post('/posts', [auth]) 51 | async create(req: Request, res: Response) { 52 | const user = req.currentUser 53 | const payload = req.body 54 | 55 | await req.db.post.create({ 56 | data: { 57 | content: payload.content, 58 | published: payload.published === 'on' || false, 59 | userId: user.id, 60 | }, 61 | }) 62 | 63 | return res.redirect(302, '/posts') 64 | } 65 | 66 | @get('/posts/:id', [auth, isPostOwner]) 67 | async show(req: Request, res: Response) { 68 | const postDetails = await req.db.post.findUnique({ 69 | where: { 70 | id: Number(req.params.id), 71 | }, 72 | }) 73 | 74 | return res.render('posts/show.njk', { 75 | post: postDetails, 76 | }) 77 | } 78 | 79 | @post('/posts/:id', [auth, isPostOwner]) 80 | async update(req: Request, res: Response) { 81 | await req.db.post.update({ 82 | data: { 83 | content: req.body.content, 84 | published: req.body.published === 'on' || false, 85 | }, 86 | where: { 87 | id: Number(req.params.id), 88 | }, 89 | }) 90 | 91 | req.flash('info', 'Updated Post') 92 | return res.redirect(302, `/posts/${req.params.id}`) 93 | } 94 | 95 | @get('/posts/:id/view', [optionalLoggedIn]) 96 | async viewPublicPost(req: Request, res: Response) { 97 | const postDetails = await req.db.post.findFirst({ 98 | where: { 99 | id: Number(req.params.id), 100 | }, 101 | include: { 102 | user: { 103 | select: { 104 | email: true, 105 | }, 106 | }, 107 | }, 108 | }) 109 | 110 | return res.render('posts/public-view.njk', { 111 | post: postDetails, 112 | }) 113 | } 114 | 115 | @get('/posts/:id/delete', [auth, isPostOwner]) 116 | async deleteConfirm(req: Request, res: Response) { 117 | const postDetails = await req.db.post.findFirst({ 118 | where: { 119 | id: Number(req.params.id), 120 | }, 121 | }) 122 | 123 | return res.render('posts/delete.njk', { 124 | post: postDetails, 125 | }) 126 | } 127 | 128 | @del('/posts/:id/delete', [auth, isPostOwner]) 129 | @post('/posts/:id/delete', [auth, isPostOwner]) 130 | async delete(req: Request, res: Response) { 131 | await req.db.post.deleteMany({ 132 | where: { 133 | id: Number(req.params.id), 134 | userId: req.currentUser.id, 135 | }, 136 | }) 137 | req.flash('info', 'Deleted Post') 138 | return res.redirect(302, '/posts') 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/docs.ts: -------------------------------------------------------------------------------- 1 | import expressJSDocSwagger from 'express-jsdoc-swagger' 2 | import path from 'path' 3 | 4 | export function initDocs(app) { 5 | const options = { 6 | info: { 7 | title: 'TheStack API', 8 | version: '1.0.0', 9 | description: '', 10 | }, 11 | servers: [ 12 | { 13 | url: '/', 14 | description: 'API server', 15 | }, 16 | ], 17 | security: { 18 | Auth: { 19 | type: 'apiKey', 20 | scheme: 'string', 21 | in: 'header', 22 | name: 'Authorization', 23 | }, 24 | }, 25 | baseDir: path.join(__dirname, './'), 26 | // Glob pattern to find your jsdoc files (multiple patterns can be added in an array) 27 | filesPattern: './controllers/**/*.ts', 28 | // URL where SwaggerUI will be rendered 29 | swaggerUIPath: '/api/api-docs', 30 | // Expose OpenAPI UI 31 | exposeSwaggerUI: true, 32 | // Expose Open API JSON Docs documentation in `apiDocsPath` path. 33 | exposeApiDocs: false, 34 | // Set non-required fields as nullable by default 35 | notRequiredAsNullable: false, 36 | // You can customize your UI options. 37 | // you can extend swagger-ui-express config. You can checkout an example of this 38 | // in the `example/configuration/swaggerOptions.js` 39 | swaggerUiOptions: {}, 40 | // multiple option in case you want more that one instance 41 | multiple: true, 42 | } 43 | 44 | expressJSDocSwagger(app)(options) 45 | } 46 | -------------------------------------------------------------------------------- /src/jobs/email-queue-handler.ts: -------------------------------------------------------------------------------- 1 | import { config } from '@/configs' 2 | import { sendEmail } from '@/lib/email' 3 | import { listen } from '@/lib/queue' 4 | 5 | export class EmailQueueHandler { 6 | @listen(config.queue.email.name, config.queue.email.types.loginEmail) 7 | async handleEmail(job: any) { 8 | const toEmail = job.data.email 9 | await sendEmail({ 10 | toEmail, 11 | subject: 'hello human', 12 | html: '

Please approve log-in request

', 13 | }) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/jobs/test-queue-handler.ts: -------------------------------------------------------------------------------- 1 | import { listen } from '@/lib/queue' 2 | 3 | export class TestQueueHandler { 4 | @listen('test', '') 5 | async(job: any) { 6 | console.log({ 7 | d: job.data, 8 | }) 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const OPTIONS = { 2 | GLOBAL_ROLES: { 3 | owner: { 4 | sequence: 0, 5 | label: 'Owner', 6 | value: 0, 7 | }, 8 | member: { 9 | sequence: 1, 10 | label: 'Member', 11 | value: 1, 12 | }, 13 | guest: { 14 | sequence: 2, 15 | label: 'Guest', 16 | value: 2, 17 | }, 18 | }, 19 | 20 | PROJECT_ROLES: { 21 | owner: { 22 | sequence: 0, 23 | label: 'Owner', 24 | value: 0, 25 | }, 26 | member: { 27 | sequence: 1, 28 | label: 'Member', 29 | value: 1, 30 | }, 31 | guest: { 32 | sequence: 2, 33 | label: 'Guest', 34 | value: 2, 35 | }, 36 | }, 37 | } 38 | -------------------------------------------------------------------------------- /src/lib/crypto.ts: -------------------------------------------------------------------------------- 1 | import { randomBytes, pbkdf2Sync } from 'crypto' 2 | 3 | const iterations = 1000 4 | const hashLength = 64 5 | const algo = 'sha512' 6 | 7 | export function hashPassword(password) { 8 | const salt = randomBytes(16).toString('hex') 9 | const hashedPass = pbkdf2Sync(password, salt, iterations, hashLength, algo) 10 | return { 11 | pass: hashedPass.toString('hex'), 12 | salt, 13 | } 14 | } 15 | 16 | export function verifyPassword(password, hashedPass, salt) { 17 | const hashBuffer = pbkdf2Sync(password, salt, iterations, hashLength, algo) 18 | const reHashedPassword = hashBuffer.toString('hex') 19 | return reHashedPassword === hashedPass 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client' 2 | 3 | export const db = new PrismaClient() 4 | 5 | export function exclude, Key extends keyof T>( 6 | model: T, 7 | keys: Key[] 8 | ): Omit { 9 | return Object.fromEntries( 10 | Object.entries(model).filter(([key]) => !keys.includes(key as Key)) 11 | ) as Omit 12 | } 13 | -------------------------------------------------------------------------------- /src/lib/email.ts: -------------------------------------------------------------------------------- 1 | type Options = { 2 | toEmail: string 3 | subject: string 4 | } 5 | 6 | type OptionsWithHTML = Options & { 7 | html: string 8 | } 9 | 10 | type OptionsWithContent = Options & { 11 | content: string 12 | } 13 | 14 | export function sendEmail(options: OptionsWithHTML | OptionsWithContent) { 15 | // add in the sdk for your service 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/queue.ts: -------------------------------------------------------------------------------- 1 | import { config } from '@/configs' 2 | import Bull from 'bull' 3 | 4 | export function createProducer(name: string) { 5 | const bull = new Bull(name, { 6 | redis: config.redis, 7 | }) 8 | return bull 9 | } 10 | 11 | export function pushToQueue(queueName: string, data: any) { 12 | const bull = new Bull(queueName, { 13 | redis: config.redis, 14 | }) 15 | bull.add(data) 16 | } 17 | 18 | export function listen(queueName: string, queueType: string) { 19 | return (target: any, pk: string) => { 20 | const bull = new Bull(queueName, { 21 | redis: config.redis, 22 | }) 23 | bull.process(async (job: any) => { 24 | if (job.data.type !== queueType) return 25 | return await target[pk](job) 26 | }) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/lib/redis.ts: -------------------------------------------------------------------------------- 1 | import { Redis } from 'ioredis' 2 | import { config } from '@/configs' 3 | 4 | export const client = new Redis(config.redis) 5 | -------------------------------------------------------------------------------- /src/lib/router.ts: -------------------------------------------------------------------------------- 1 | import { Router, Request, Response, NextFunction } from 'express' 2 | 3 | export const router = Router() 4 | 5 | type MiddlewareFunc = (req: Request, res: Response, n: NextFunction) => void 6 | 7 | type RouterOptions = { 8 | path: string 9 | method: 'get' | 'post' | 'delete' 10 | middleware?: MiddlewareFunc[] 11 | } 12 | 13 | export const createMethodDecorator = (options: RouterOptions) => { 14 | return (target: any, pk: string) => { 15 | const middleware = options.middleware || [] 16 | router[options.method](options.path, ...middleware, target[pk]) 17 | } 18 | } 19 | 20 | export const get = (path: string, middleware?: MiddlewareFunc[]) => { 21 | return createMethodDecorator({ 22 | method: 'get', 23 | path, 24 | middleware, 25 | }) 26 | } 27 | 28 | export const post = (path: string, middleware?: MiddlewareFunc[]) => { 29 | return createMethodDecorator({ 30 | method: 'post', 31 | path, 32 | middleware, 33 | }) 34 | } 35 | 36 | export const del = (path: string, middleware?: MiddlewareFunc[]) => { 37 | return createMethodDecorator({ 38 | method: 'delete', 39 | path, 40 | middleware, 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /src/middlewares/auth.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express' 2 | import { db } from '@/lib/db' 3 | 4 | declare module 'express' { 5 | interface Request { 6 | currentUser?: { 7 | id: number 8 | } 9 | } 10 | } 11 | 12 | export async function notLoggedIn(req: Request, res: Response, next) { 13 | // @ts-expect-error session is a dynamic object 14 | let token = req.headers.authorization || req.session.token 15 | if (token) { 16 | return res.redirect(302, '/') 17 | } 18 | next() 19 | } 20 | 21 | export async function optionalLoggedIn(req: Request, res: Response, next) { 22 | // @ts-expect-error session is a dynamic object 23 | let token = req.headers.authorization || req.session.token 24 | if (!token) { 25 | return next() 26 | } 27 | 28 | const user = await getTokenUser(token, req.db) 29 | if (!user) { 30 | return next() 31 | } 32 | 33 | req.currentUser = user 34 | res.locals.authenticated = true 35 | 36 | next() 37 | } 38 | 39 | export async function auth(req: Request, res: Response, next) { 40 | // @ts-expect-error session is a dynamic object 41 | let token = req.headers.authorization || req.session.token 42 | if (!token) { 43 | req.flash( 44 | 'error', 45 | "Sorry, you don't have enough permissions to perform this action" 46 | ) 47 | return res.redirect(302, '/login') 48 | } 49 | 50 | const user = await getTokenUser(token, req.db) 51 | 52 | if (!user) { 53 | req.flash( 54 | 'error', 55 | "Sorry, you don't have enough permissions to perform this action" 56 | ) 57 | return res.redirect(302, '/login') 58 | } 59 | 60 | req.currentUser = user 61 | res.locals.authenticated = true 62 | 63 | next() 64 | } 65 | 66 | async function getTokenUser(token, dbConnector: typeof db) { 67 | const tokenDetails = await dbConnector.tokens.findFirst({ 68 | where: { 69 | authToken: token, 70 | }, 71 | include: { 72 | user: true, 73 | }, 74 | }) 75 | 76 | if (!tokenDetails) { 77 | return null 78 | } 79 | 80 | return { 81 | id: tokenDetails.user.id, 82 | } 83 | 84 | return null 85 | } 86 | -------------------------------------------------------------------------------- /src/middlewares/errors.ts: -------------------------------------------------------------------------------- 1 | import { Response } from 'express' 2 | 3 | export function errorMiddleware(_, res, next) { 4 | _injectErrorHelpers(res) 5 | next() 6 | } 7 | 8 | function _injectErrorHelpers(res: Response) { 9 | res.badParameters = function (err) { 10 | res.status(400).send({ 11 | error: 'Bad Parameters', 12 | __stack: err, 13 | }) 14 | } 15 | 16 | res.serverError = function (err) { 17 | res.status(500).send({ 18 | error: 'Oops! Something went wrong...', 19 | __stack: err, 20 | }) 21 | } 22 | 23 | res.unauthorized = function () { 24 | res.status(401).send({ 25 | error: "Sorry, you don't have enough permissions to perform this action", 26 | }) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/public/styles.min.css: -------------------------------------------------------------------------------- 1 | /* 2 | ! tailwindcss v3.3.2 | MIT License | https://tailwindcss.com 3 | */ 4 | 5 | /* 6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) 7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) 8 | */ 9 | 10 | *, 11 | ::before, 12 | ::after { 13 | box-sizing: border-box; 14 | /* 1 */ 15 | border-width: 0; 16 | /* 2 */ 17 | border-style: solid; 18 | /* 2 */ 19 | border-color: #e5e7eb; 20 | /* 2 */ 21 | } 22 | 23 | ::before, 24 | ::after { 25 | --tw-content: ''; 26 | } 27 | 28 | /* 29 | 1. Use a consistent sensible line-height in all browsers. 30 | 2. Prevent adjustments of font size after orientation changes in iOS. 31 | 3. Use a more readable tab size. 32 | 4. Use the user's configured `sans` font-family by default. 33 | 5. Use the user's configured `sans` font-feature-settings by default. 34 | 6. Use the user's configured `sans` font-variation-settings by default. 35 | */ 36 | 37 | html { 38 | line-height: 1.5; 39 | /* 1 */ 40 | -webkit-text-size-adjust: 100%; 41 | /* 2 */ 42 | -moz-tab-size: 4; 43 | /* 3 */ 44 | -o-tab-size: 4; 45 | tab-size: 4; 46 | /* 3 */ 47 | font-family: Inter; 48 | /* 4 */ 49 | font-feature-settings: normal; 50 | /* 5 */ 51 | font-variation-settings: normal; 52 | /* 6 */ 53 | } 54 | 55 | /* 56 | 1. Remove the margin in all browsers. 57 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. 58 | */ 59 | 60 | body { 61 | margin: 0; 62 | /* 1 */ 63 | line-height: inherit; 64 | /* 2 */ 65 | } 66 | 67 | /* 68 | 1. Add the correct height in Firefox. 69 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) 70 | 3. Ensure horizontal rules are visible by default. 71 | */ 72 | 73 | hr { 74 | height: 0; 75 | /* 1 */ 76 | color: inherit; 77 | /* 2 */ 78 | border-top-width: 1px; 79 | /* 3 */ 80 | } 81 | 82 | /* 83 | Add the correct text decoration in Chrome, Edge, and Safari. 84 | */ 85 | 86 | abbr:where([title]) { 87 | -webkit-text-decoration: underline dotted; 88 | text-decoration: underline dotted; 89 | } 90 | 91 | /* 92 | Remove the default font size and weight for headings. 93 | */ 94 | 95 | h1, 96 | h2, 97 | h3, 98 | h4, 99 | h5, 100 | h6 { 101 | font-size: inherit; 102 | font-weight: inherit; 103 | } 104 | 105 | /* 106 | Reset links to optimize for opt-in styling instead of opt-out. 107 | */ 108 | 109 | a { 110 | color: inherit; 111 | text-decoration: inherit; 112 | } 113 | 114 | /* 115 | Add the correct font weight in Edge and Safari. 116 | */ 117 | 118 | b, 119 | strong { 120 | font-weight: bolder; 121 | } 122 | 123 | /* 124 | 1. Use the user's configured `mono` font family by default. 125 | 2. Correct the odd `em` font sizing in all browsers. 126 | */ 127 | 128 | code, 129 | kbd, 130 | samp, 131 | pre { 132 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 133 | 'Liberation Mono', 'Courier New', monospace; 134 | /* 1 */ 135 | font-size: 1em; 136 | /* 2 */ 137 | } 138 | 139 | /* 140 | Add the correct font size in all browsers. 141 | */ 142 | 143 | small { 144 | font-size: 80%; 145 | } 146 | 147 | /* 148 | Prevent `sub` and `sup` elements from affecting the line height in all browsers. 149 | */ 150 | 151 | sub, 152 | sup { 153 | font-size: 75%; 154 | line-height: 0; 155 | position: relative; 156 | vertical-align: baseline; 157 | } 158 | 159 | sub { 160 | bottom: -0.25em; 161 | } 162 | 163 | sup { 164 | top: -0.5em; 165 | } 166 | 167 | /* 168 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) 169 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) 170 | 3. Remove gaps between table borders by default. 171 | */ 172 | 173 | table { 174 | text-indent: 0; 175 | /* 1 */ 176 | border-color: inherit; 177 | /* 2 */ 178 | border-collapse: collapse; 179 | /* 3 */ 180 | } 181 | 182 | /* 183 | 1. Change the font styles in all browsers. 184 | 2. Remove the margin in Firefox and Safari. 185 | 3. Remove default padding in all browsers. 186 | */ 187 | 188 | button, 189 | input, 190 | optgroup, 191 | select, 192 | textarea { 193 | font-family: inherit; 194 | /* 1 */ 195 | font-size: 100%; 196 | /* 1 */ 197 | font-weight: inherit; 198 | /* 1 */ 199 | line-height: inherit; 200 | /* 1 */ 201 | color: inherit; 202 | /* 1 */ 203 | margin: 0; 204 | /* 2 */ 205 | padding: 0; 206 | /* 3 */ 207 | } 208 | 209 | /* 210 | Remove the inheritance of text transform in Edge and Firefox. 211 | */ 212 | 213 | button, 214 | select { 215 | text-transform: none; 216 | } 217 | 218 | /* 219 | 1. Correct the inability to style clickable types in iOS and Safari. 220 | 2. Remove default button styles. 221 | */ 222 | 223 | button, 224 | [type='button'], 225 | [type='reset'], 226 | [type='submit'] { 227 | -webkit-appearance: button; 228 | /* 1 */ 229 | background-color: transparent; 230 | /* 2 */ 231 | background-image: none; 232 | /* 2 */ 233 | } 234 | 235 | /* 236 | Use the modern Firefox focus style for all focusable elements. 237 | */ 238 | 239 | :-moz-focusring { 240 | outline: auto; 241 | } 242 | 243 | /* 244 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) 245 | */ 246 | 247 | :-moz-ui-invalid { 248 | box-shadow: none; 249 | } 250 | 251 | /* 252 | Add the correct vertical alignment in Chrome and Firefox. 253 | */ 254 | 255 | progress { 256 | vertical-align: baseline; 257 | } 258 | 259 | /* 260 | Correct the cursor style of increment and decrement buttons in Safari. 261 | */ 262 | 263 | ::-webkit-inner-spin-button, 264 | ::-webkit-outer-spin-button { 265 | height: auto; 266 | } 267 | 268 | /* 269 | 1. Correct the odd appearance in Chrome and Safari. 270 | 2. Correct the outline style in Safari. 271 | */ 272 | 273 | [type='search'] { 274 | -webkit-appearance: textfield; 275 | /* 1 */ 276 | outline-offset: -2px; 277 | /* 2 */ 278 | } 279 | 280 | /* 281 | Remove the inner padding in Chrome and Safari on macOS. 282 | */ 283 | 284 | ::-webkit-search-decoration { 285 | -webkit-appearance: none; 286 | } 287 | 288 | /* 289 | 1. Correct the inability to style clickable types in iOS and Safari. 290 | 2. Change font properties to `inherit` in Safari. 291 | */ 292 | 293 | ::-webkit-file-upload-button { 294 | -webkit-appearance: button; 295 | /* 1 */ 296 | font: inherit; 297 | /* 2 */ 298 | } 299 | 300 | /* 301 | Add the correct display in Chrome and Safari. 302 | */ 303 | 304 | summary { 305 | display: list-item; 306 | } 307 | 308 | /* 309 | Removes the default spacing and border for appropriate elements. 310 | */ 311 | 312 | blockquote, 313 | dl, 314 | dd, 315 | h1, 316 | h2, 317 | h3, 318 | h4, 319 | h5, 320 | h6, 321 | hr, 322 | figure, 323 | p, 324 | pre { 325 | margin: 0; 326 | } 327 | 328 | fieldset { 329 | margin: 0; 330 | padding: 0; 331 | } 332 | 333 | legend { 334 | padding: 0; 335 | } 336 | 337 | ol, 338 | ul, 339 | menu { 340 | list-style: none; 341 | margin: 0; 342 | padding: 0; 343 | } 344 | 345 | /* 346 | Prevent resizing textareas horizontally by default. 347 | */ 348 | 349 | textarea { 350 | resize: vertical; 351 | } 352 | 353 | /* 354 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) 355 | 2. Set the default placeholder color to the user's configured gray 400 color. 356 | */ 357 | 358 | input::-moz-placeholder, 359 | textarea::-moz-placeholder { 360 | opacity: 1; 361 | /* 1 */ 362 | color: #9ca3af; 363 | /* 2 */ 364 | } 365 | 366 | input::placeholder, 367 | textarea::placeholder { 368 | opacity: 1; 369 | /* 1 */ 370 | color: #9ca3af; 371 | /* 2 */ 372 | } 373 | 374 | /* 375 | Set the default cursor for buttons. 376 | */ 377 | 378 | button, 379 | [role='button'] { 380 | cursor: pointer; 381 | } 382 | 383 | /* 384 | Make sure disabled buttons don't get the pointer cursor. 385 | */ 386 | 387 | :disabled { 388 | cursor: default; 389 | } 390 | 391 | /* 392 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) 393 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) 394 | This can trigger a poorly considered lint error in some tools but is included by design. 395 | */ 396 | 397 | img, 398 | svg, 399 | video, 400 | canvas, 401 | audio, 402 | iframe, 403 | embed, 404 | object { 405 | display: block; 406 | /* 1 */ 407 | vertical-align: middle; 408 | /* 2 */ 409 | } 410 | 411 | /* 412 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) 413 | */ 414 | 415 | img, 416 | video { 417 | max-width: 100%; 418 | height: auto; 419 | } 420 | 421 | /* Make elements with the HTML hidden attribute stay hidden by default */ 422 | 423 | [hidden] { 424 | display: none; 425 | } 426 | 427 | [type='text'], 428 | [type='email'], 429 | [type='url'], 430 | [type='password'], 431 | [type='number'], 432 | [type='date'], 433 | [type='datetime-local'], 434 | [type='month'], 435 | [type='search'], 436 | [type='tel'], 437 | [type='time'], 438 | [type='week'], 439 | [multiple], 440 | textarea, 441 | select { 442 | -webkit-appearance: none; 443 | -moz-appearance: none; 444 | appearance: none; 445 | background-color: #fff; 446 | border-color: #6b7280; 447 | border-width: 1px; 448 | border-radius: 0px; 449 | padding-top: 0.5rem; 450 | padding-right: 0.75rem; 451 | padding-bottom: 0.5rem; 452 | padding-left: 0.75rem; 453 | font-size: 1rem; 454 | line-height: 1.5rem; 455 | --tw-shadow: 0 0 #0000; 456 | } 457 | 458 | [type='text']:focus, 459 | [type='email']:focus, 460 | [type='url']:focus, 461 | [type='password']:focus, 462 | [type='number']:focus, 463 | [type='date']:focus, 464 | [type='datetime-local']:focus, 465 | [type='month']:focus, 466 | [type='search']:focus, 467 | [type='tel']:focus, 468 | [type='time']:focus, 469 | [type='week']:focus, 470 | [multiple]:focus, 471 | textarea:focus, 472 | select:focus { 473 | outline: 2px solid transparent; 474 | outline-offset: 2px; 475 | --tw-ring-inset: var(--tw-empty, /*!*/ /*!*/); 476 | --tw-ring-offset-width: 0px; 477 | --tw-ring-offset-color: #fff; 478 | --tw-ring-color: #2563eb; 479 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 480 | var(--tw-ring-offset-width) var(--tw-ring-offset-color); 481 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 482 | calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); 483 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 484 | var(--tw-shadow); 485 | border-color: #2563eb; 486 | } 487 | 488 | input::-moz-placeholder, 489 | textarea::-moz-placeholder { 490 | color: #6b7280; 491 | opacity: 1; 492 | } 493 | 494 | input::placeholder, 495 | textarea::placeholder { 496 | color: #6b7280; 497 | opacity: 1; 498 | } 499 | 500 | ::-webkit-datetime-edit-fields-wrapper { 501 | padding: 0; 502 | } 503 | 504 | ::-webkit-date-and-time-value { 505 | min-height: 1.5em; 506 | } 507 | 508 | ::-webkit-datetime-edit, 509 | ::-webkit-datetime-edit-year-field, 510 | ::-webkit-datetime-edit-month-field, 511 | ::-webkit-datetime-edit-day-field, 512 | ::-webkit-datetime-edit-hour-field, 513 | ::-webkit-datetime-edit-minute-field, 514 | ::-webkit-datetime-edit-second-field, 515 | ::-webkit-datetime-edit-millisecond-field, 516 | ::-webkit-datetime-edit-meridiem-field { 517 | padding-top: 0; 518 | padding-bottom: 0; 519 | } 520 | 521 | select { 522 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e"); 523 | background-position: right 0.5rem center; 524 | background-repeat: no-repeat; 525 | background-size: 1.5em 1.5em; 526 | padding-right: 2.5rem; 527 | -webkit-print-color-adjust: exact; 528 | print-color-adjust: exact; 529 | } 530 | 531 | [multiple] { 532 | background-image: initial; 533 | background-position: initial; 534 | background-repeat: unset; 535 | background-size: initial; 536 | padding-right: 0.75rem; 537 | -webkit-print-color-adjust: unset; 538 | print-color-adjust: unset; 539 | } 540 | 541 | [type='checkbox'], 542 | [type='radio'] { 543 | -webkit-appearance: none; 544 | -moz-appearance: none; 545 | appearance: none; 546 | padding: 0; 547 | -webkit-print-color-adjust: exact; 548 | print-color-adjust: exact; 549 | display: inline-block; 550 | vertical-align: middle; 551 | background-origin: border-box; 552 | -webkit-user-select: none; 553 | -moz-user-select: none; 554 | user-select: none; 555 | flex-shrink: 0; 556 | height: 1rem; 557 | width: 1rem; 558 | color: #2563eb; 559 | background-color: #fff; 560 | border-color: #6b7280; 561 | border-width: 1px; 562 | --tw-shadow: 0 0 #0000; 563 | } 564 | 565 | [type='checkbox'] { 566 | border-radius: 0px; 567 | } 568 | 569 | [type='radio'] { 570 | border-radius: 100%; 571 | } 572 | 573 | [type='checkbox']:focus, 574 | [type='radio']:focus { 575 | outline: 2px solid transparent; 576 | outline-offset: 2px; 577 | --tw-ring-inset: var(--tw-empty, /*!*/ /*!*/); 578 | --tw-ring-offset-width: 2px; 579 | --tw-ring-offset-color: #fff; 580 | --tw-ring-color: #2563eb; 581 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 582 | var(--tw-ring-offset-width) var(--tw-ring-offset-color); 583 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 584 | calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); 585 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 586 | var(--tw-shadow); 587 | } 588 | 589 | [type='checkbox']:checked, 590 | [type='radio']:checked { 591 | border-color: transparent; 592 | background-color: currentColor; 593 | background-size: 100% 100%; 594 | background-position: center; 595 | background-repeat: no-repeat; 596 | } 597 | 598 | [type='checkbox']:checked { 599 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); 600 | } 601 | 602 | [type='radio']:checked { 603 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e"); 604 | } 605 | 606 | [type='checkbox']:checked:hover, 607 | [type='checkbox']:checked:focus, 608 | [type='radio']:checked:hover, 609 | [type='radio']:checked:focus { 610 | border-color: transparent; 611 | background-color: currentColor; 612 | } 613 | 614 | [type='checkbox']:indeterminate { 615 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e"); 616 | border-color: transparent; 617 | background-color: currentColor; 618 | background-size: 100% 100%; 619 | background-position: center; 620 | background-repeat: no-repeat; 621 | } 622 | 623 | [type='checkbox']:indeterminate:hover, 624 | [type='checkbox']:indeterminate:focus { 625 | border-color: transparent; 626 | background-color: currentColor; 627 | } 628 | 629 | [type='file'] { 630 | background: unset; 631 | border-color: inherit; 632 | border-width: 0; 633 | border-radius: 0; 634 | padding: 0; 635 | font-size: unset; 636 | line-height: inherit; 637 | } 638 | 639 | [type='file']:focus { 640 | outline: 1px solid ButtonText; 641 | outline: 1px auto -webkit-focus-ring-color; 642 | } 643 | 644 | *, 645 | ::before, 646 | ::after { 647 | --tw-border-spacing-x: 0; 648 | --tw-border-spacing-y: 0; 649 | --tw-translate-x: 0; 650 | --tw-translate-y: 0; 651 | --tw-rotate: 0; 652 | --tw-skew-x: 0; 653 | --tw-skew-y: 0; 654 | --tw-scale-x: 1; 655 | --tw-scale-y: 1; 656 | --tw-pan-x: ; 657 | --tw-pan-y: ; 658 | --tw-pinch-zoom: ; 659 | --tw-scroll-snap-strictness: proximity; 660 | --tw-gradient-from-position: ; 661 | --tw-gradient-via-position: ; 662 | --tw-gradient-to-position: ; 663 | --tw-ordinal: ; 664 | --tw-slashed-zero: ; 665 | --tw-numeric-figure: ; 666 | --tw-numeric-spacing: ; 667 | --tw-numeric-fraction: ; 668 | --tw-ring-inset: ; 669 | --tw-ring-offset-width: 0px; 670 | --tw-ring-offset-color: #fff; 671 | --tw-ring-color: rgb(59 130 246 / 0.5); 672 | --tw-ring-offset-shadow: 0 0 #0000; 673 | --tw-ring-shadow: 0 0 #0000; 674 | --tw-shadow: 0 0 #0000; 675 | --tw-shadow-colored: 0 0 #0000; 676 | --tw-blur: ; 677 | --tw-brightness: ; 678 | --tw-contrast: ; 679 | --tw-grayscale: ; 680 | --tw-hue-rotate: ; 681 | --tw-invert: ; 682 | --tw-saturate: ; 683 | --tw-sepia: ; 684 | --tw-drop-shadow: ; 685 | --tw-backdrop-blur: ; 686 | --tw-backdrop-brightness: ; 687 | --tw-backdrop-contrast: ; 688 | --tw-backdrop-grayscale: ; 689 | --tw-backdrop-hue-rotate: ; 690 | --tw-backdrop-invert: ; 691 | --tw-backdrop-opacity: ; 692 | --tw-backdrop-saturate: ; 693 | --tw-backdrop-sepia: ; 694 | } 695 | 696 | ::backdrop { 697 | --tw-border-spacing-x: 0; 698 | --tw-border-spacing-y: 0; 699 | --tw-translate-x: 0; 700 | --tw-translate-y: 0; 701 | --tw-rotate: 0; 702 | --tw-skew-x: 0; 703 | --tw-skew-y: 0; 704 | --tw-scale-x: 1; 705 | --tw-scale-y: 1; 706 | --tw-pan-x: ; 707 | --tw-pan-y: ; 708 | --tw-pinch-zoom: ; 709 | --tw-scroll-snap-strictness: proximity; 710 | --tw-gradient-from-position: ; 711 | --tw-gradient-via-position: ; 712 | --tw-gradient-to-position: ; 713 | --tw-ordinal: ; 714 | --tw-slashed-zero: ; 715 | --tw-numeric-figure: ; 716 | --tw-numeric-spacing: ; 717 | --tw-numeric-fraction: ; 718 | --tw-ring-inset: ; 719 | --tw-ring-offset-width: 0px; 720 | --tw-ring-offset-color: #fff; 721 | --tw-ring-color: rgb(59 130 246 / 0.5); 722 | --tw-ring-offset-shadow: 0 0 #0000; 723 | --tw-ring-shadow: 0 0 #0000; 724 | --tw-shadow: 0 0 #0000; 725 | --tw-shadow-colored: 0 0 #0000; 726 | --tw-blur: ; 727 | --tw-brightness: ; 728 | --tw-contrast: ; 729 | --tw-grayscale: ; 730 | --tw-hue-rotate: ; 731 | --tw-invert: ; 732 | --tw-saturate: ; 733 | --tw-sepia: ; 734 | --tw-drop-shadow: ; 735 | --tw-backdrop-blur: ; 736 | --tw-backdrop-brightness: ; 737 | --tw-backdrop-contrast: ; 738 | --tw-backdrop-grayscale: ; 739 | --tw-backdrop-hue-rotate: ; 740 | --tw-backdrop-invert: ; 741 | --tw-backdrop-opacity: ; 742 | --tw-backdrop-saturate: ; 743 | --tw-backdrop-sepia: ; 744 | } 745 | 746 | .sr-only { 747 | position: absolute; 748 | width: 1px; 749 | height: 1px; 750 | padding: 0; 751 | margin: -1px; 752 | overflow: hidden; 753 | clip: rect(0, 0, 0, 0); 754 | white-space: nowrap; 755 | border-width: 0; 756 | } 757 | 758 | .mx-auto { 759 | margin-left: auto; 760 | margin-right: auto; 761 | } 762 | 763 | .my-2 { 764 | margin-top: 0.5rem; 765 | margin-bottom: 0.5rem; 766 | } 767 | 768 | .mb-4 { 769 | margin-bottom: 1rem; 770 | } 771 | 772 | .ml-auto { 773 | margin-left: auto; 774 | } 775 | 776 | .mr-2 { 777 | margin-right: 0.5rem; 778 | } 779 | 780 | .mr-3 { 781 | margin-right: 0.75rem; 782 | } 783 | 784 | .mr-auto { 785 | margin-right: auto; 786 | } 787 | 788 | .mt-2 { 789 | margin-top: 0.5rem; 790 | } 791 | 792 | .mt-2\.5 { 793 | margin-top: 0.625rem; 794 | } 795 | 796 | .mt-5 { 797 | margin-top: 1.25rem; 798 | } 799 | 800 | .box-border { 801 | box-sizing: border-box; 802 | } 803 | 804 | .block { 805 | display: block; 806 | } 807 | 808 | .inline { 809 | display: inline; 810 | } 811 | 812 | .flex { 813 | display: flex; 814 | } 815 | 816 | .inline-flex { 817 | display: inline-flex; 818 | } 819 | 820 | .h-28 { 821 | height: 7rem; 822 | } 823 | 824 | .h-3 { 825 | height: 0.75rem; 826 | } 827 | 828 | .h-4 { 829 | height: 1rem; 830 | } 831 | 832 | .h-5 { 833 | height: 1.25rem; 834 | } 835 | 836 | .min-h-fit { 837 | min-height: -moz-fit-content; 838 | min-height: fit-content; 839 | } 840 | 841 | .min-h-screen { 842 | min-height: 100vh; 843 | } 844 | 845 | .w-1\/4 { 846 | width: 25%; 847 | } 848 | 849 | .w-3 { 850 | width: 0.75rem; 851 | } 852 | 853 | .w-4 { 854 | width: 1rem; 855 | } 856 | 857 | .w-5 { 858 | width: 1.25rem; 859 | } 860 | 861 | .w-full { 862 | width: 100%; 863 | } 864 | 865 | .min-w-\[150px\] { 866 | min-width: 150px; 867 | } 868 | 869 | .max-w-3xl { 870 | max-width: 48rem; 871 | } 872 | 873 | .flex-shrink-0 { 874 | flex-shrink: 0; 875 | } 876 | 877 | .resize-none { 878 | resize: none; 879 | } 880 | 881 | .flex-col { 882 | flex-direction: column; 883 | } 884 | 885 | .flex-wrap { 886 | flex-wrap: wrap; 887 | } 888 | 889 | .items-center { 890 | align-items: center; 891 | } 892 | 893 | .justify-end { 894 | justify-content: flex-end; 895 | } 896 | 897 | .justify-center { 898 | justify-content: center; 899 | } 900 | 901 | .justify-between { 902 | justify-content: space-between; 903 | } 904 | 905 | .gap-2 { 906 | gap: 0.5rem; 907 | } 908 | 909 | .rounded { 910 | border-radius: 0.25rem; 911 | } 912 | 913 | .rounded-lg { 914 | border-radius: 0.5rem; 915 | } 916 | 917 | .rounded-md { 918 | border-radius: 0.375rem; 919 | } 920 | 921 | .border { 922 | border-width: 1px; 923 | } 924 | 925 | .border-2 { 926 | border-width: 2px; 927 | } 928 | 929 | .border-b { 930 | border-bottom-width: 1px; 931 | } 932 | 933 | .border-t { 934 | border-top-width: 1px; 935 | } 936 | 937 | .\!border-red-400 { 938 | --tw-border-opacity: 1 !important; 939 | border-color: rgb(248 113 113 / var(--tw-border-opacity)) !important; 940 | } 941 | 942 | .border-blue-300 { 943 | --tw-border-opacity: 1; 944 | border-color: rgb(147 197 253 / var(--tw-border-opacity)); 945 | } 946 | 947 | .border-gray-200 { 948 | --tw-border-opacity: 1; 949 | border-color: rgb(229 231 235 / var(--tw-border-opacity)); 950 | } 951 | 952 | .border-gray-500 { 953 | --tw-border-opacity: 1; 954 | border-color: rgb(107 114 128 / var(--tw-border-opacity)); 955 | } 956 | 957 | .border-green-400 { 958 | --tw-border-opacity: 1; 959 | border-color: rgb(74 222 128 / var(--tw-border-opacity)); 960 | } 961 | 962 | .border-red-300 { 963 | --tw-border-opacity: 1; 964 | border-color: rgb(252 165 165 / var(--tw-border-opacity)); 965 | } 966 | 967 | .border-zinc-200 { 968 | --tw-border-opacity: 1; 969 | border-color: rgb(228 228 231 / var(--tw-border-opacity)); 970 | } 971 | 972 | .border-zinc-300 { 973 | --tw-border-opacity: 1; 974 | border-color: rgb(212 212 216 / var(--tw-border-opacity)); 975 | } 976 | 977 | .border-zinc-400 { 978 | --tw-border-opacity: 1; 979 | border-color: rgb(161 161 170 / var(--tw-border-opacity)); 980 | } 981 | 982 | .border-t-zinc-300 { 983 | --tw-border-opacity: 1; 984 | border-top-color: rgb(212 212 216 / var(--tw-border-opacity)); 985 | } 986 | 987 | .\!bg-red-500 { 988 | --tw-bg-opacity: 1 !important; 989 | background-color: rgb(239 68 68 / var(--tw-bg-opacity)) !important; 990 | } 991 | 992 | .\!bg-transparent { 993 | background-color: transparent !important; 994 | } 995 | 996 | .bg-black { 997 | --tw-bg-opacity: 1; 998 | background-color: rgb(0 0 0 / var(--tw-bg-opacity)); 999 | } 1000 | 1001 | .bg-blue-50 { 1002 | --tw-bg-opacity: 1; 1003 | background-color: rgb(239 246 255 / var(--tw-bg-opacity)); 1004 | } 1005 | 1006 | .bg-gray-100 { 1007 | --tw-bg-opacity: 1; 1008 | background-color: rgb(243 244 246 / var(--tw-bg-opacity)); 1009 | } 1010 | 1011 | .bg-green-100 { 1012 | --tw-bg-opacity: 1; 1013 | background-color: rgb(220 252 231 / var(--tw-bg-opacity)); 1014 | } 1015 | 1016 | .bg-red-50 { 1017 | --tw-bg-opacity: 1; 1018 | background-color: rgb(254 242 242 / var(--tw-bg-opacity)); 1019 | } 1020 | 1021 | .p-2 { 1022 | padding: 0.5rem; 1023 | } 1024 | 1025 | .p-3 { 1026 | padding: 0.75rem; 1027 | } 1028 | 1029 | .p-4 { 1030 | padding: 1rem; 1031 | } 1032 | 1033 | .px-2 { 1034 | padding-left: 0.5rem; 1035 | padding-right: 0.5rem; 1036 | } 1037 | 1038 | .px-2\.5 { 1039 | padding-left: 0.625rem; 1040 | padding-right: 0.625rem; 1041 | } 1042 | 1043 | .px-5 { 1044 | padding-left: 1.25rem; 1045 | padding-right: 1.25rem; 1046 | } 1047 | 1048 | .py-0 { 1049 | padding-top: 0px; 1050 | padding-bottom: 0px; 1051 | } 1052 | 1053 | .py-0\.5 { 1054 | padding-top: 0.125rem; 1055 | padding-bottom: 0.125rem; 1056 | } 1057 | 1058 | .py-2 { 1059 | padding-top: 0.5rem; 1060 | padding-bottom: 0.5rem; 1061 | } 1062 | 1063 | .py-2\.5 { 1064 | padding-top: 0.625rem; 1065 | padding-bottom: 0.625rem; 1066 | } 1067 | 1068 | .py-3 { 1069 | padding-top: 0.75rem; 1070 | padding-bottom: 0.75rem; 1071 | } 1072 | 1073 | .pb-3 { 1074 | padding-bottom: 0.75rem; 1075 | } 1076 | 1077 | .pt-2 { 1078 | padding-top: 0.5rem; 1079 | } 1080 | 1081 | .text-center { 1082 | text-align: center; 1083 | } 1084 | 1085 | .text-sm { 1086 | font-size: 0.875rem; 1087 | line-height: 1.25rem; 1088 | } 1089 | 1090 | .text-xl { 1091 | font-size: 1.25rem; 1092 | line-height: 1.75rem; 1093 | } 1094 | 1095 | .text-xs { 1096 | font-size: 0.75rem; 1097 | line-height: 1rem; 1098 | } 1099 | 1100 | .font-bold { 1101 | font-weight: 700; 1102 | } 1103 | 1104 | .font-medium { 1105 | font-weight: 500; 1106 | } 1107 | 1108 | .\!text-red-500 { 1109 | --tw-text-opacity: 1 !important; 1110 | color: rgb(239 68 68 / var(--tw-text-opacity)) !important; 1111 | } 1112 | 1113 | .\!text-white { 1114 | --tw-text-opacity: 1 !important; 1115 | color: rgb(255 255 255 / var(--tw-text-opacity)) !important; 1116 | } 1117 | 1118 | .text-blue-800 { 1119 | --tw-text-opacity: 1; 1120 | color: rgb(30 64 175 / var(--tw-text-opacity)); 1121 | } 1122 | 1123 | .text-gray-800 { 1124 | --tw-text-opacity: 1; 1125 | color: rgb(31 41 55 / var(--tw-text-opacity)); 1126 | } 1127 | 1128 | .text-green-800 { 1129 | --tw-text-opacity: 1; 1130 | color: rgb(22 101 52 / var(--tw-text-opacity)); 1131 | } 1132 | 1133 | .text-red-400 { 1134 | --tw-text-opacity: 1; 1135 | color: rgb(248 113 113 / var(--tw-text-opacity)); 1136 | } 1137 | 1138 | .text-red-800 { 1139 | --tw-text-opacity: 1; 1140 | color: rgb(153 27 27 / var(--tw-text-opacity)); 1141 | } 1142 | 1143 | .text-white { 1144 | --tw-text-opacity: 1; 1145 | color: rgb(255 255 255 / var(--tw-text-opacity)); 1146 | } 1147 | 1148 | .text-zinc-100 { 1149 | --tw-text-opacity: 1; 1150 | color: rgb(244 244 245 / var(--tw-text-opacity)); 1151 | } 1152 | 1153 | .text-zinc-400 { 1154 | --tw-text-opacity: 1; 1155 | color: rgb(161 161 170 / var(--tw-text-opacity)); 1156 | } 1157 | 1158 | .text-zinc-500 { 1159 | --tw-text-opacity: 1; 1160 | color: rgb(113 113 122 / var(--tw-text-opacity)); 1161 | } 1162 | 1163 | .\!shadow-none { 1164 | --tw-shadow: 0 0 #0000 !important; 1165 | --tw-shadow-colored: 0 0 #0000 !important; 1166 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), 1167 | var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow) !important; 1168 | } 1169 | 1170 | .transition-all { 1171 | transition-property: all; 1172 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 1173 | transition-duration: 150ms; 1174 | } 1175 | 1176 | .btn { 1177 | display: flex; 1178 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) 1179 | rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) 1180 | scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); 1181 | align-items: center; 1182 | border-radius: 0.375rem; 1183 | border-width: 2px; 1184 | --tw-border-opacity: 1; 1185 | border-color: rgb(0 0 0 / var(--tw-border-opacity)); 1186 | --tw-bg-opacity: 1; 1187 | background-color: rgb(0 0 0 / var(--tw-bg-opacity)); 1188 | padding-left: 1.25rem; 1189 | padding-right: 1.25rem; 1190 | padding-top: 0.5rem; 1191 | padding-bottom: 0.5rem; 1192 | --tw-text-opacity: 1; 1193 | color: rgb(255 255 255 / var(--tw-text-opacity)); 1194 | outline: 2px solid transparent; 1195 | outline-offset: 2px; 1196 | transition-property: color, background-color, border-color, 1197 | text-decoration-color, fill, stroke; 1198 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 1199 | transition-duration: 150ms; 1200 | } 1201 | 1202 | .btn:focus { 1203 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 1204 | var(--tw-ring-offset-width) var(--tw-ring-offset-color); 1205 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 1206 | calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color); 1207 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), 1208 | var(--tw-shadow, 0 0 #0000); 1209 | } 1210 | 1211 | .btn:hover { 1212 | cursor: pointer; 1213 | background-color: transparent; 1214 | --tw-text-opacity: 1; 1215 | color: rgb(0 0 0 / var(--tw-text-opacity)); 1216 | } 1217 | 1218 | .btn.secondary { 1219 | --tw-border-opacity: 1; 1220 | border-color: rgb(161 161 170 / var(--tw-border-opacity)); 1221 | background-color: transparent; 1222 | --tw-text-opacity: 1; 1223 | color: rgb(113 113 122 / var(--tw-text-opacity)); 1224 | } 1225 | 1226 | .btn.secondary:hover { 1227 | --tw-border-opacity: 1; 1228 | border-color: rgb(0 0 0 / var(--tw-border-opacity)); 1229 | --tw-bg-opacity: 1; 1230 | background-color: rgb(0 0 0 / var(--tw-bg-opacity)); 1231 | --tw-text-opacity: 1; 1232 | color: rgb(255 255 255 / var(--tw-text-opacity)); 1233 | } 1234 | 1235 | .btn.mini { 1236 | padding-left: 0.75rem; 1237 | padding-right: 0.75rem; 1238 | padding-top: 0.125rem; 1239 | padding-bottom: 0.125rem; 1240 | } 1241 | 1242 | .badge { 1243 | } 1244 | 1245 | .hover\:border-2:hover { 1246 | border-width: 2px; 1247 | } 1248 | 1249 | .hover\:border-black:hover { 1250 | --tw-border-opacity: 1; 1251 | border-color: rgb(0 0 0 / var(--tw-border-opacity)); 1252 | } 1253 | 1254 | .hover\:\!bg-red-700:hover { 1255 | --tw-bg-opacity: 1 !important; 1256 | background-color: rgb(185 28 28 / var(--tw-bg-opacity)) !important; 1257 | } 1258 | 1259 | .hover\:bg-zinc-800:hover { 1260 | --tw-bg-opacity: 1; 1261 | background-color: rgb(39 39 42 / var(--tw-bg-opacity)); 1262 | } 1263 | 1264 | .hover\:text-black:hover { 1265 | --tw-text-opacity: 1; 1266 | color: rgb(0 0 0 / var(--tw-text-opacity)); 1267 | } 1268 | 1269 | .hover\:text-red-600:hover { 1270 | --tw-text-opacity: 1; 1271 | color: rgb(220 38 38 / var(--tw-text-opacity)); 1272 | } 1273 | 1274 | .hover\:underline:hover { 1275 | text-decoration-line: underline; 1276 | } 1277 | 1278 | .hover\:shadow-md:hover { 1279 | --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); 1280 | --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 1281 | 0 2px 4px -2px var(--tw-shadow-color); 1282 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), 1283 | var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); 1284 | } 1285 | 1286 | .focus\:border-black:focus { 1287 | --tw-border-opacity: 1; 1288 | border-color: rgb(0 0 0 / var(--tw-border-opacity)); 1289 | } 1290 | 1291 | .focus\:border-zinc-900:focus { 1292 | --tw-border-opacity: 1; 1293 | border-color: rgb(24 24 27 / var(--tw-border-opacity)); 1294 | } 1295 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { initApp } from '@/app' 2 | import { db } from '@/lib/db' 3 | 4 | const port = process.env.PORT || 3000 5 | 6 | bootServer(db) 7 | 8 | function bootServer(db: any) { 9 | const app = initApp({ db }) 10 | 11 | app.listen(port, () => { 12 | console.log(`Listening on http://localhost:${port}`) 13 | }) 14 | } 15 | -------------------------------------------------------------------------------- /src/styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .btn { 6 | @apply px-5 py-2 rounded-md border-black border-2 bg-black text-white outline-none focus:ring-4 transform duration-150 transition-colors flex items-center; 7 | } 8 | 9 | .btn:hover { 10 | cursor: pointer; 11 | @apply bg-transparent text-black; 12 | } 13 | 14 | .btn.secondary { 15 | @apply bg-transparent text-zinc-500 border-zinc-400 hover:border-black hover:text-white hover:bg-black; 16 | } 17 | 18 | .btn.mini { 19 | @apply px-3 py-0.5; 20 | } 21 | 22 | .badge { 23 | } 24 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import { db } from '@/lib/db' 2 | import { pushToQueue } from '@/lib/queue' 3 | 4 | declare module 'express' { 5 | interface Request { 6 | db: typeof db 7 | pushToQueue: typeof pushToQueue 8 | } 9 | 10 | interface Response { 11 | // Error response Helpers from middleware/errors.ts 12 | badParameters(err: Error): void 13 | serverError(err: Error): void 14 | unauthorized(): void 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/views/auth/login.njk: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/base.njk' %} 2 | 3 | {% block title %} 4 | Login 5 | {% endblock %} 6 | 7 | {% block body %} 8 |
9 |

10 | Login 11 |

12 |
13 |
14 |
15 | 18 |
19 |
20 | 24 |
25 |
26 | 45 |
46 |

47 | Dont have an account? Register 48 |

49 |
50 | {% endblock %} 51 | -------------------------------------------------------------------------------- /src/views/auth/register.njk: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/base.njk' %} 2 | 3 | {% block title %} 4 | Register 5 | {% endblock %} 6 | 7 | {% block body %} 8 |
9 |

10 | Register 11 |

12 |
13 |
14 |
15 | 18 |
19 |
20 | 24 |
25 |
26 | 30 |
31 |
32 | 51 |
52 |

53 | Already have an account? Login 54 |

55 |
56 | {% endblock %} 57 | -------------------------------------------------------------------------------- /src/views/dashboard.njk: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/base.njk' %} 2 | 3 | {% block body %} 4 |
5 |
6 |
7 | {# Tool Bar #} 8 |
9 |
10 | New Post 11 |
12 |
13 | {# List #} 14 |
15 | {% if posts %} 16 | {% for post in posts %} 17 |
18 |
19 | {{ post.content }} 20 |
21 |
22 |
23 | {% if post.published %} 24 | 25 | Published | {{ post.updatedAt.toLocaleDateString() }} 26 | 27 | {% else %} 28 | 29 | Draft | {{ post.updatedAt.toLocaleDateString() }} 30 | 31 | {% endif %} 32 |
33 | 56 |
57 |
58 | {% endfor %} 59 | {% else %} 60 |
61 |

62 | No Data 63 |

64 |
65 | {% endif %} 66 |
67 |
68 |
69 |
70 | {% endblock %} 71 | -------------------------------------------------------------------------------- /src/views/index.njk: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/base.njk' %} 2 | 3 | {% block body %} 4 |
5 | {% for post in posts %} 6 | 7 |
8 |
9 |
10 | ~{{ post.user.email }} 11 |
12 |
13 | {{ post.createdAt.toLocaleDateString() }} 14 |
15 |
16 |
17 | {{ post.content }} 18 |
19 |
20 |
21 | {% endfor %} 22 |
23 | {% if hasPrev %} 24 | 26 | Prev 27 | 28 | {% endif %} 29 | {% if hasNext %} 30 | 32 | Next 33 | 34 | {% endif %} 35 |
36 |
37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /src/views/layouts/base.njk: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {% block title %} 8 | 9 | {% endblock %} 10 | 11 | 12 | 14 | 15 | 16 | 17 |
18 |

19 | Blog 20 |

21 | 33 |
34 | 35 | {# Alerts #} 36 |
37 | {% if messages.info %} 38 |
39 | 53 |
54 | {% endif %} 55 | 56 | {% if messages.error %} 57 | 71 | {% endif %} 72 |
73 | {# Alerts end #} 74 | 75 | {% block body %} 76 | 77 | {% endblock %} 78 | 79 |
80 | 81 | 82 | -------------------------------------------------------------------------------- /src/views/posts/delete.njk: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/base.njk' %} 2 | 3 | {% block body %} 4 |
5 |
6 |
7 |

8 | Are you sure, you want to delete this Post? 9 |

10 |

11 | This action is irreverisble. 12 |

13 | {# Actions #} 14 |
15 | Cancel 16 | 19 |
20 |
21 |
22 |
23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /src/views/posts/new.njk: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/base.njk' %} 2 | 3 | {% block body %} 4 |
5 |
6 |
7 |
8 | {# Tool Bar #} 9 |
10 |
11 |
12 | 17 |
18 | 19 |
20 |
21 |
22 | 28 |
29 |
30 |
31 |
32 |
33 | {% endblock %} 34 | -------------------------------------------------------------------------------- /src/views/posts/public-view.njk: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/base.njk' %} 2 | 3 | {% block body %} 4 |
5 |
6 |
7 |
8 |
9 | ~{{ post.user.email }} 10 |
11 |
12 | {{ post.createdAt.toLocaleDateString() }} 13 |
14 |
15 |
16 | {{ post.content }} 17 |
18 |
19 |
20 |
21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /src/views/posts/show.njk: -------------------------------------------------------------------------------- 1 | {% extends 'layouts/base.njk' %} 2 | 3 | {% block body %} 4 |
5 |
6 |
7 |
8 | {# Tool Bar #} 9 |
10 |
11 |
12 | 19 |
20 | 23 | 26 | Delete 27 | 28 |
29 |
30 |
31 | 37 |
38 |
39 |
40 |
41 |
42 | {% endblock %} 43 | -------------------------------------------------------------------------------- /src/worker.ts: -------------------------------------------------------------------------------- 1 | import './jobs/test-queue-handler' 2 | import './jobs/email-queue-handler' 3 | 4 | setInterval(() => {}, 1 << 30) 5 | 6 | console.log('Started Worker') 7 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./src/views/**/*.{njk,html,js}'], 4 | theme: { 5 | fontFamily: { 6 | sans: 'Inter', 7 | }, 8 | extend: {}, 9 | }, 10 | plugins: [require('@tailwindcss/forms')], 11 | } 12 | -------------------------------------------------------------------------------- /tests/basic.spec.ts: -------------------------------------------------------------------------------- 1 | import http from 'http' 2 | import { initApp } from '../src/app' 3 | import { describe, expect, it, beforeAll } from 'vitest' 4 | import { db } from '../src/lib/db' 5 | import listen from 'test-listen' 6 | import axios from 'axios' 7 | 8 | let tCtx: any = {} 9 | 10 | beforeAll(async () => { 11 | tCtx.server = http.createServer(initApp({ db })) 12 | tCtx.prefixUrl = await listen(tCtx.server) 13 | const $fetcher = axios.create({ 14 | baseURL: tCtx.prefixUrl, 15 | }) 16 | tCtx.fetcher = $fetcher 17 | }) 18 | 19 | describe('Ping', () => { 20 | it('should get value from ping', async () => { 21 | const result = await tCtx.fetcher.get('/api/ping') 22 | expect(Object.keys(result.data)).toMatchInlineSnapshot(` 23 | [ 24 | "success", 25 | "now", 26 | ] 27 | `) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | import { config as loadEnv } from 'dotenv' 2 | import { afterEach, beforeEach, vi } from 'vitest' 3 | import Redis from 'ioredis-mock' 4 | import { existsSync } from 'fs' 5 | 6 | if (existsSync('.env')) { 7 | loadEnv({ 8 | path: '.env', 9 | }) 10 | } 11 | 12 | beforeEach(() => { 13 | vi.clearAllMocks() 14 | vi.mock('ioredis', async () => { 15 | // const mod = await vi.importActual('ioredis') 16 | const mock = await vi.importActual('ioredis-mock') 17 | return { 18 | // @ts-expect-error untyped import 19 | Redis: mock.default, 20 | } 21 | }) 22 | }) 23 | 24 | afterEach(async () => { 25 | await new Redis().flushall() 26 | }) 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "lib": ["ES2018", "dom"], 5 | "target": "es2017", 6 | "forceConsistentCasingInFileNames": true, 7 | "esModuleInterop": true, 8 | "removeComments": true, 9 | "preserveConstEnums": true, 10 | "outDir": "dist", 11 | "experimentalDecorators": true, 12 | "sourceMap": false, 13 | "baseUrl": "./", 14 | "types": ["./src/types.d.ts"], 15 | "paths": { 16 | "@/*": ["./src/*"] 17 | } 18 | }, 19 | "include": ["./src/**/*", "./tests/**/*"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { defineConfig } from 'vite' 3 | export default defineConfig({ 4 | plugins: [], 5 | 6 | test: { 7 | setupFiles: ['./tests/setup.ts'], 8 | alias: { 9 | '@/': new URL('./src/', import.meta.url).pathname, 10 | }, 11 | }, 12 | }) 13 | --------------------------------------------------------------------------------