Daily Quote: Vite/React + MUI M3 frontend, Express API, Postgres, Zitadel auth, observability, backups
Some checks failed
CI / build (push) Failing after 2m12s
Some checks failed
CI / build (push) Failing after 2m12s
This commit is contained in:
commit
308a53fc08
3
.env.example
Normal file
3
.env.example
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
PUBLIC_HOSTNAME=<vm-hostname>.exe.xyz
|
||||||
|
ZITADEL_MASTERKEY=<32-char-random-string>
|
||||||
|
ZITADEL_DB_PW=<random-password>
|
||||||
32
.gitea/workflows/ci.yml
Normal file
32
.gitea/workflows/ci.yml
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Install client deps
|
||||||
|
working-directory: client
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build client
|
||||||
|
working-directory: client
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Install server deps
|
||||||
|
working-directory: server
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Syntax-check server
|
||||||
|
working-directory: server
|
||||||
|
run: node --check src/index.js
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.log
|
||||||
|
.gitea-admin-pw
|
||||||
|
.zitadel-secrets
|
||||||
|
.zitadel-demo-user
|
||||||
62
README.md
Normal file
62
README.md
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
# Daily Quote
|
||||||
|
|
||||||
|
A small full-stack "quote of the day" app, built for the privatecloud VM `private-cloud-daily-quote`.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Frontend**: Vite + React, styled with [MUI](https://mui.com) using a Material Design 3 theme (color roles, type scale, shape/elevation).
|
||||||
|
- **API**: Express (Node.js), serving both `/api/*` and the built SPA on port **3000**.
|
||||||
|
- **Database**: Postgres 16, run via Docker Compose (`quotes`, `users`, `favorites` tables — see `db/init.sql`).
|
||||||
|
- **Platform auth**: [Zitadel](https://zitadel.com), self-hosted via Docker, OIDC Authorization Code + PKCE. Gates "submit a quote" and "favorites"; browsing is public.
|
||||||
|
- **Git hosting**: [Gitea](https://gitea.io), self-hosted via Docker (this repo's `origin`).
|
||||||
|
- **CI/CD**: Gitea Actions, using a local `act_runner` container. See `.gitea/workflows/ci.yml`.
|
||||||
|
- **Observability**: `prom-client` metrics at `/api/metrics`, scraped by Prometheus, visualized in a provisioned Grafana dashboard.
|
||||||
|
- **Backup**: nightly `pg_dump` via a systemd timer (`quoteapp-backup.timer`), 7-day retention, dumps to `~/backups`.
|
||||||
|
|
||||||
|
## Local services (Docker)
|
||||||
|
|
||||||
|
| Service | Internal port | Host port | Notes |
|
||||||
|
|-------------|---------------|-----------|-------|
|
||||||
|
| Postgres | 5432 | 5432 (loopback only) | app + zitadel databases |
|
||||||
|
| Zitadel | 8080 | 8080 | public via `https://<host>:8080` |
|
||||||
|
| Gitea | 3000 | 3001 | public via `https://<host>:3001` |
|
||||||
|
| Gitea SSH | 22 | 2222 (loopback only) | |
|
||||||
|
| act_runner | - | - | registered against Gitea Actions |
|
||||||
|
| Prometheus | 9090 | 9090 (loopback only) | |
|
||||||
|
| Grafana | 3000 | 3002 | public via `https://<host>:3002`, `admin`/`admin` |
|
||||||
|
|
||||||
|
Bring the stack up with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the app
|
||||||
|
|
||||||
|
The app itself runs directly on the VM (not in Docker) as a systemd service on port 3000:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl status quote-app
|
||||||
|
```
|
||||||
|
|
||||||
|
To rebuild after a frontend change:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd client && npm run build
|
||||||
|
sudo systemctl restart quote-app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auth notes
|
||||||
|
|
||||||
|
Zitadel is reachable at `https://<vm-host>:8080`. Since exe.dev's proxy only marks one
|
||||||
|
port ("3000", the app) fully public, signing in requires being an authorized user of
|
||||||
|
this workspace to reach the Zitadel login page on port 8080 — browsing and reading
|
||||||
|
quotes on the main app is public regardless. A demo user (`demo`) was created for
|
||||||
|
testing; see `.zitadel-demo-user` (not committed) for its password.
|
||||||
|
|
||||||
|
## Backups
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./backup/backup.sh # run manually
|
||||||
|
systemctl list-timers quoteapp-backup.timer
|
||||||
|
```
|
||||||
16
backup/backup.sh
Executable file
16
backup/backup.sh
Executable file
@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Nightly Postgres backup for the Daily Quote app. Invoked by quoteapp-backup.timer.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKUP_DIR="/home/exedev/backups"
|
||||||
|
RETENTION_DAYS=7
|
||||||
|
TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
FILE="$BACKUP_DIR/quoteapp-$TIMESTAMP.sql.gz"
|
||||||
|
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
docker exec quoteapp-postgres pg_dump -U quoteapp -d quoteapp | gzip > "$FILE"
|
||||||
|
|
||||||
|
find "$BACKUP_DIR" -name 'quoteapp-*.sql.gz' -mtime +"$RETENTION_DAYS" -delete
|
||||||
|
|
||||||
|
echo "Backed up quoteapp database to $FILE"
|
||||||
24
client/.gitignore
vendored
Normal file
24
client/.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
8
client/.oxlintrc.json
Normal file
8
client/.oxlintrc.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
16
client/README.md
Normal file
16
client/README.md
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# React + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the Oxlint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.
|
||||||
13
client/index.html
Normal file
13
client/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>client</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2401
client/package-lock.json
generated
Normal file
2401
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
client/package.json
Normal file
29
client/package.json
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.14.0",
|
||||||
|
"@emotion/styled": "^11.14.1",
|
||||||
|
"@fontsource/roboto": "^5.3.0",
|
||||||
|
"@mui/icons-material": "^9.2.0",
|
||||||
|
"@mui/material": "^9.2.0",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"react-router-dom": "^7.18.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"oxlint": "^1.71.0",
|
||||||
|
"vite": "^8.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
client/public/favicon.svg
Normal file
1
client/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
client/public/icons.svg
Normal file
24
client/public/icons.svg
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
82
client/src/App.jsx
Normal file
82
client/src/App.jsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { NavLink, Route, Routes } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
AppBar,
|
||||||
|
Avatar,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Tab,
|
||||||
|
Tabs,
|
||||||
|
Toolbar,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import AutoAwesomeRoundedIcon from '@mui/icons-material/AutoAwesomeRounded';
|
||||||
|
import Today from './pages/Today';
|
||||||
|
import Browse from './pages/Browse';
|
||||||
|
import Submit from './pages/Submit';
|
||||||
|
import Favorites from './pages/Favorites';
|
||||||
|
import { useAuth } from './AuthContext';
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ label: 'Today', path: '/' },
|
||||||
|
{ label: 'Browse', path: '/browse' },
|
||||||
|
{ label: 'Submit', path: '/submit' },
|
||||||
|
{ label: 'Favorites', path: '/favorites' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { user, logout, loading } = useAuth();
|
||||||
|
const pathToIndex = (pathname) => {
|
||||||
|
const idx = NAV.findIndex((n) => n.path === pathname);
|
||||||
|
return idx === -1 ? 0 : idx;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
|
||||||
|
<AppBar position="sticky">
|
||||||
|
<Toolbar sx={{ gap: 2 }}>
|
||||||
|
<AutoAwesomeRoundedIcon color="primary" />
|
||||||
|
<Typography variant="h6" component="div" sx={{ flexGrow: 0, mr: 2 }}>
|
||||||
|
Daily Quote
|
||||||
|
</Typography>
|
||||||
|
<Tabs
|
||||||
|
value={pathToIndex(window.location.pathname)}
|
||||||
|
textColor="primary"
|
||||||
|
indicatorColor="primary"
|
||||||
|
sx={{ flexGrow: 1 }}
|
||||||
|
>
|
||||||
|
{NAV.map((n) => (
|
||||||
|
<Tab key={n.path} label={n.label} component={NavLink} to={n.path} />
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
{!loading && user && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Avatar sx={{ width: 28, height: 28, bgcolor: 'secondary.main', fontSize: 14 }}>
|
||||||
|
{(user.displayName || user.email || '?')[0].toUpperCase()}
|
||||||
|
</Avatar>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ display: { xs: 'none', sm: 'block' } }}>
|
||||||
|
{user.displayName || user.email}
|
||||||
|
</Typography>
|
||||||
|
<Button size="small" onClick={logout}>
|
||||||
|
Sign out
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{!loading && !user && (
|
||||||
|
<Button variant="outlined" href="/api/auth/login">
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Today />} />
|
||||||
|
<Route path="/browse" element={<Browse />} />
|
||||||
|
<Route path="/submit" element={<Submit />} />
|
||||||
|
<Route path="/favorites" element={<Favorites />} />
|
||||||
|
</Routes>
|
||||||
|
</Container>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
client/src/AuthContext.jsx
Normal file
39
client/src/AuthContext.jsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }) {
|
||||||
|
const [user, setUser] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.me();
|
||||||
|
setUser(data.authenticated ? data.user : null);
|
||||||
|
} catch {
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
await api.logout();
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, loading, refresh, logout }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
return useContext(AuthContext);
|
||||||
|
}
|
||||||
25
client/src/api.js
Normal file
25
client/src/api.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
const BASE = '/api';
|
||||||
|
|
||||||
|
async function request(path, options = {}) {
|
||||||
|
const res = await fetch(`${BASE}${path}`, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'same-origin',
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) throw new Error(data?.error || `Request failed (${res.status})`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
today: () => request('/quotes/today'),
|
||||||
|
list: () => request('/quotes'),
|
||||||
|
submit: (text, author) =>
|
||||||
|
request('/quotes', { method: 'POST', body: JSON.stringify({ text, author }) }),
|
||||||
|
me: () => request('/auth/me'),
|
||||||
|
logout: () => request('/auth/logout', { method: 'POST' }),
|
||||||
|
favorites: () => request('/favorites'),
|
||||||
|
addFavorite: (quoteId) => request(`/favorites/${quoteId}`, { method: 'PUT' }),
|
||||||
|
removeFavorite: (quoteId) => request(`/favorites/${quoteId}`, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
11
client/src/index.css
Normal file
11
client/src/index.css
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
25
client/src/main.jsx
Normal file
25
client/src/main.jsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { ThemeProvider, CssBaseline } from '@mui/material';
|
||||||
|
import '@fontsource/roboto/300.css';
|
||||||
|
import '@fontsource/roboto/400.css';
|
||||||
|
import '@fontsource/roboto/500.css';
|
||||||
|
import '@fontsource/roboto/700.css';
|
||||||
|
import './index.css';
|
||||||
|
import App from './App.jsx';
|
||||||
|
import { theme } from './theme.js';
|
||||||
|
import { AuthProvider } from './AuthContext.jsx';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')).render(
|
||||||
|
<StrictMode>
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</ThemeProvider>
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
109
client/src/pages/Browse.jsx
Normal file
109
client/src/pages/Browse.jsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
Skeleton,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import FavoriteIcon from '@mui/icons-material/Favorite';
|
||||||
|
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { useAuth } from '../AuthContext';
|
||||||
|
|
||||||
|
export default function Browse() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [quotes, setQuotes] = useState(null);
|
||||||
|
const [favoriteIds, setFavoriteIds] = useState(new Set());
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.list().then(setQuotes).catch((err) => setError(err.message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) {
|
||||||
|
setFavoriteIds(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api
|
||||||
|
.favorites()
|
||||||
|
.then((rows) => setFavoriteIds(new Set(rows.map((r) => r.id))))
|
||||||
|
.catch(() => setFavoriteIds(new Set()));
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
async function toggleFavorite(quoteId) {
|
||||||
|
if (!user) return;
|
||||||
|
const isFav = favoriteIds.has(quoteId);
|
||||||
|
try {
|
||||||
|
if (isFav) {
|
||||||
|
await api.removeFavorite(quoteId);
|
||||||
|
} else {
|
||||||
|
await api.addFavorite(quoteId);
|
||||||
|
}
|
||||||
|
setFavoriteIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
isFav ? next.delete(quoteId) : next.add(quoteId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
Browse quotes
|
||||||
|
</Typography>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setError('')}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{quotes === null &&
|
||||||
|
Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Grid key={i} size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
|
<Skeleton variant="rounded" height={160} />
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
{quotes?.map((q) => (
|
||||||
|
<Grid key={q.id} size={{ xs: 12, sm: 6, md: 4 }}>
|
||||||
|
<Card variant="outlined" sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||||
|
<CardContent sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
<Typography variant="body1" sx={{ flexGrow: 1 }}>
|
||||||
|
“{q.text}”
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
— {q.author}
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title={user ? 'Toggle favorite' : 'Sign in to favorite'}>
|
||||||
|
<span>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
disabled={!user}
|
||||||
|
onClick={() => toggleFavorite(q.id)}
|
||||||
|
>
|
||||||
|
{favoriteIds.has(q.id) ? (
|
||||||
|
<FavoriteIcon fontSize="small" sx={{ color: 'tertiary.main' }} />
|
||||||
|
) : (
|
||||||
|
<FavoriteBorderIcon fontSize="small" />
|
||||||
|
)}
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
client/src/pages/Favorites.jsx
Normal file
53
client/src/pages/Favorites.jsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Box, Button, Card, CardContent, Stack, Typography } from '@mui/material';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { useAuth } from '../AuthContext';
|
||||||
|
|
||||||
|
export default function Favorites() {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const [favorites, setFavorites] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) api.favorites().then(setFavorites).catch(() => setFavorites([]));
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
if (loading) return null;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ maxWidth: 480, mx: 'auto', mt: 6, textAlign: 'center' }}>
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
Sign in to see your favorites
|
||||||
|
</Typography>
|
||||||
|
<Button variant="contained" href="/api/auth/login" sx={{ mt: 2 }}>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Your favorites
|
||||||
|
</Typography>
|
||||||
|
{favorites?.length === 0 && (
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
No favorites yet — browse quotes and tap the heart icon to save one.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{favorites?.map((q) => (
|
||||||
|
<Card key={q.id} variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="body1">“{q.text}”</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
— {q.author}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
client/src/pages/Submit.jsx
Normal file
76
client/src/pages/Submit.jsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Alert, Box, Button, Card, CardContent, Stack, TextField, Typography } from '@mui/material';
|
||||||
|
import { api } from '../api';
|
||||||
|
import { useAuth } from '../AuthContext';
|
||||||
|
|
||||||
|
export default function Submit() {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const [text, setText] = useState('');
|
||||||
|
const [author, setAuthor] = useState('');
|
||||||
|
const [status, setStatus] = useState(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
setStatus(null);
|
||||||
|
try {
|
||||||
|
await api.submit(text, author);
|
||||||
|
setText('');
|
||||||
|
setAuthor('');
|
||||||
|
setStatus({ severity: 'success', message: 'Thanks! Your quote was added.' });
|
||||||
|
} catch (err) {
|
||||||
|
setStatus({ severity: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return null;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ maxWidth: 480, mx: 'auto', mt: 6, textAlign: 'center' }}>
|
||||||
|
<Typography variant="h5" gutterBottom>
|
||||||
|
Sign in to submit a quote
|
||||||
|
</Typography>
|
||||||
|
<Typography color="text.secondary" sx={{ mb: 3 }}>
|
||||||
|
Quote submissions are linked to your account via platform auth.
|
||||||
|
</Typography>
|
||||||
|
<Button variant="contained" href="/api/auth/login">
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ maxWidth: 560, mx: 'auto' }}>
|
||||||
|
<Typography variant="h4" gutterBottom>
|
||||||
|
Submit a quote
|
||||||
|
</Typography>
|
||||||
|
<Card variant="outlined">
|
||||||
|
<CardContent>
|
||||||
|
<Box component="form" onSubmit={handleSubmit}>
|
||||||
|
<Stack spacing={2}>
|
||||||
|
{status && <Alert severity={status.severity}>{status.message}</Alert>}
|
||||||
|
<TextField
|
||||||
|
label="Quote"
|
||||||
|
multiline
|
||||||
|
minRows={3}
|
||||||
|
required
|
||||||
|
value={text}
|
||||||
|
onChange={(e) => setText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Author"
|
||||||
|
placeholder="Anonymous"
|
||||||
|
value={author}
|
||||||
|
onChange={(e) => setAuthor(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button type="submit" variant="contained" size="large">
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
client/src/pages/Today.jsx
Normal file
46
client/src/pages/Today.jsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Box, Card, CardContent, CircularProgress, Stack, Typography } from '@mui/material';
|
||||||
|
import FormatQuoteRoundedIcon from '@mui/icons-material/FormatQuoteRounded';
|
||||||
|
import { api } from '../api';
|
||||||
|
|
||||||
|
export default function Today() {
|
||||||
|
const [quote, setQuote] = useState(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.today().then(setQuote).catch(() => setQuote(null));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', mt: { xs: 4, sm: 8 } }}>
|
||||||
|
{quote === undefined && <CircularProgress />}
|
||||||
|
{quote === null && <Typography color="text.secondary">No quotes yet.</Typography>}
|
||||||
|
{quote && (
|
||||||
|
<Card
|
||||||
|
elevation={2}
|
||||||
|
sx={{
|
||||||
|
maxWidth: 640,
|
||||||
|
width: '100%',
|
||||||
|
bgcolor: 'primary.light',
|
||||||
|
px: { xs: 3, sm: 5 },
|
||||||
|
py: { xs: 4, sm: 6 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent>
|
||||||
|
<Stack spacing={3} alignItems="center" textAlign="center">
|
||||||
|
<Typography variant="overline" color="text.secondary" letterSpacing={2}>
|
||||||
|
Quote of the Day
|
||||||
|
</Typography>
|
||||||
|
<FormatQuoteRoundedIcon sx={{ fontSize: 40, color: 'primary.main' }} />
|
||||||
|
<Typography variant="h3" component="p">
|
||||||
|
{quote.text}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" color="text.secondary">
|
||||||
|
— {quote.author}
|
||||||
|
</Typography>
|
||||||
|
</Stack>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
client/src/theme.js
Normal file
93
client/src/theme.js
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { createTheme } from '@mui/material/styles';
|
||||||
|
|
||||||
|
// Material Design 3 baseline color roles (M3 "Purple" seed color),
|
||||||
|
// mapped onto MUI's theme palette + a custom `tertiary` role.
|
||||||
|
const m3 = {
|
||||||
|
primary: '#6750A4',
|
||||||
|
onPrimary: '#FFFFFF',
|
||||||
|
primaryContainer: '#EADDFF',
|
||||||
|
secondary: '#625B71',
|
||||||
|
secondaryContainer: '#E8DEF8',
|
||||||
|
tertiary: '#7D5260',
|
||||||
|
tertiaryContainer: '#FFD8E4',
|
||||||
|
error: '#B3261E',
|
||||||
|
errorContainer: '#F9DEDC',
|
||||||
|
background: '#FFFBFE',
|
||||||
|
surface: '#FFFBFE',
|
||||||
|
surfaceVariant: '#E7E0EC',
|
||||||
|
outline: '#79747E',
|
||||||
|
onSurface: '#1C1B1F',
|
||||||
|
onSurfaceVariant: '#49454F',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const theme = createTheme({
|
||||||
|
palette: {
|
||||||
|
mode: 'light',
|
||||||
|
primary: { main: m3.primary, contrastText: m3.onPrimary, light: m3.primaryContainer },
|
||||||
|
secondary: { main: m3.secondary, light: m3.secondaryContainer },
|
||||||
|
tertiary: { main: m3.tertiary, light: m3.tertiaryContainer },
|
||||||
|
error: { main: m3.error, light: m3.errorContainer },
|
||||||
|
background: { default: m3.background, paper: m3.surface },
|
||||||
|
text: { primary: m3.onSurface, secondary: m3.onSurfaceVariant },
|
||||||
|
divider: m3.outline,
|
||||||
|
},
|
||||||
|
shape: {
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
|
||||||
|
h1: { fontSize: '3.5rem', fontWeight: 400, lineHeight: 1.15, letterSpacing: '-0.25px' }, // Display Large
|
||||||
|
h2: { fontSize: '2.25rem', fontWeight: 400, lineHeight: 1.2 }, // Headline Large
|
||||||
|
h3: { fontSize: '1.75rem', fontWeight: 400, lineHeight: 1.25 }, // Headline Medium
|
||||||
|
h4: { fontSize: '1.5rem', fontWeight: 500, lineHeight: 1.3 }, // Title Large
|
||||||
|
h5: { fontSize: '1.25rem', fontWeight: 500 }, // Title Medium
|
||||||
|
h6: { fontSize: '1rem', fontWeight: 500 }, // Title Small
|
||||||
|
body1: { fontSize: '1rem', lineHeight: 1.5 },
|
||||||
|
body2: { fontSize: '0.875rem', lineHeight: 1.43 },
|
||||||
|
button: { textTransform: 'none', fontWeight: 500 },
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiAppBar: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
backgroundColor: m3.surface,
|
||||||
|
color: m3.onSurface,
|
||||||
|
boxShadow: 'none',
|
||||||
|
borderBottom: `1px solid ${m3.surfaceVariant}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiCard: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultProps: {
|
||||||
|
elevation: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderRadius: 100, // M3 "full" shape for buttons
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultProps: {
|
||||||
|
disableElevation: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiChip: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: { borderRadius: 8 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiPaper: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
backgroundImage: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
12
client/vite.config.js
Normal file
12
client/vite.config.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
68
db/init.sql
Normal file
68
db/init.sql
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
-- Daily Quote app schema
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
sub TEXT UNIQUE NOT NULL,
|
||||||
|
email TEXT,
|
||||||
|
display_name TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS quotes (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
author TEXT NOT NULL DEFAULT 'Unknown',
|
||||||
|
submitted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS favorites (
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
quote_id INTEGER NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, quote_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO quotes (text, author) VALUES
|
||||||
|
('The only way to do great work is to love what you do.', 'Steve Jobs'),
|
||||||
|
('Life is what happens to you while you''re busy making other plans.', 'John Lennon'),
|
||||||
|
('In the middle of difficulty lies opportunity.', 'Albert Einstein'),
|
||||||
|
('It does not matter how slowly you go as long as you do not stop.', 'Confucius'),
|
||||||
|
('The journey of a thousand miles begins with a single step.', 'Lao Tzu'),
|
||||||
|
('That which does not kill us makes us stronger.', 'Friedrich Nietzsche'),
|
||||||
|
('Whether you think you can or you think you can''t, you''re right.', 'Henry Ford'),
|
||||||
|
('The best way to predict the future is to invent it.', 'Alan Kay'),
|
||||||
|
('Simplicity is the ultimate sophistication.', 'Leonardo da Vinci'),
|
||||||
|
('Not all those who wander are lost.', 'J.R.R. Tolkien'),
|
||||||
|
('The unexamined life is not worth living.', 'Socrates'),
|
||||||
|
('I think, therefore I am.', 'René Descartes'),
|
||||||
|
('What we think, we become.', 'Buddha'),
|
||||||
|
('The only impossible journey is the one you never begin.', 'Tony Robbins'),
|
||||||
|
('Believe you can and you''re halfway there.', 'Theodore Roosevelt'),
|
||||||
|
('It always seems impossible until it''s done.', 'Nelson Mandela'),
|
||||||
|
('The future belongs to those who believe in the beauty of their dreams.', 'Eleanor Roosevelt'),
|
||||||
|
('Success is not final, failure is not fatal: it is the courage to continue that counts.', 'Winston Churchill'),
|
||||||
|
('You miss 100% of the shots you don''t take.', 'Wayne Gretzky'),
|
||||||
|
('The only limit to our realization of tomorrow is our doubts of today.', 'Franklin D. Roosevelt'),
|
||||||
|
('Do one thing every day that scares you.', 'Eleanor Roosevelt'),
|
||||||
|
('Everything you''ve ever wanted is on the other side of fear.', 'George Addair'),
|
||||||
|
('Act as if what you do makes a difference. It does.', 'William James'),
|
||||||
|
('Turn your wounds into wisdom.', 'Oprah Winfrey'),
|
||||||
|
('Your time is limited, so don''t waste it living someone else''s life.', 'Steve Jobs'),
|
||||||
|
('The mind is everything. What you think you become.', 'Buddha'),
|
||||||
|
('Twenty years from now you will be more disappointed by the things you didn''t do than by the ones you did do.', 'Mark Twain'),
|
||||||
|
('Either you run the day, or the day runs you.', 'Jim Rohn'),
|
||||||
|
('The only person you are destined to become is the person you decide to be.', 'Ralph Waldo Emerson'),
|
||||||
|
('Go confidently in the direction of your dreams. Live the life you have imagined.', 'Henry David Thoreau'),
|
||||||
|
('Do not wait to strike till the iron is hot; but make it hot by striking.', 'William Butler Yeats'),
|
||||||
|
('When one door of happiness closes, another opens.', 'Helen Keller'),
|
||||||
|
('Everything has beauty, but not everyone can see.', 'Confucius'),
|
||||||
|
('You can never cross the ocean until you have the courage to lose sight of the shore.', 'Christopher Columbus'),
|
||||||
|
('Change your thoughts and you change your world.', 'Norman Vincent Peale'),
|
||||||
|
('Nothing is impossible, the word itself says ''I''m possible''!', 'Audrey Hepburn'),
|
||||||
|
('The best revenge is massive success.', 'Frank Sinatra'),
|
||||||
|
('If you set your goals ridiculously high and it''s a failure, you will fail above everyone else''s success.', 'James Cameron'),
|
||||||
|
('Life is 10% what happens to you and 90% how you react to it.', 'Charles R. Swindoll'),
|
||||||
|
('The way to get started is to quit talking and begin doing.', 'Walt Disney'),
|
||||||
|
('Don''t let yesterday take up too much of today.', 'Will Rogers')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
121
docker-compose.yml
Normal file
121
docker-compose.yml
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: quoteapp-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: quoteapp
|
||||||
|
POSTGRES_PASSWORD: quoteapp
|
||||||
|
POSTGRES_DB: quoteapp
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./db/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U quoteapp"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
zitadel:
|
||||||
|
image: ghcr.io/zitadel/zitadel:latest
|
||||||
|
container_name: quoteapp-zitadel
|
||||||
|
restart: unless-stopped
|
||||||
|
command: 'start-from-init --masterkeyFromEnv --tlsMode external'
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
ZITADEL_MASTERKEY: ${ZITADEL_MASTERKEY}
|
||||||
|
ZITADEL_DATABASE_POSTGRES_HOST: postgres
|
||||||
|
ZITADEL_DATABASE_POSTGRES_PORT: "5432"
|
||||||
|
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
|
||||||
|
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
|
||||||
|
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${ZITADEL_DB_PW}
|
||||||
|
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
|
||||||
|
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: quoteapp
|
||||||
|
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: quoteapp
|
||||||
|
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
|
||||||
|
ZITADEL_EXTERNALDOMAIN: ${PUBLIC_HOSTNAME}
|
||||||
|
ZITADEL_EXTERNALPORT: "8080"
|
||||||
|
ZITADEL_EXTERNALSECURE: "true"
|
||||||
|
ZITADEL_FIRSTINSTANCE_ORG_NAME: quoteapp
|
||||||
|
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: bootstrap-admin
|
||||||
|
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Bootstrap Admin
|
||||||
|
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2030-01-01T00:00:00Z"
|
||||||
|
ZITADEL_FIRSTINSTANCE_PATPATH: /bootstrap/admin.pat
|
||||||
|
ZITADEL_MACHINE_IDENTIFICATION_HOSTNAME_ENABLED: "true"
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- zitadel_bootstrap:/bootstrap
|
||||||
|
|
||||||
|
gitea:
|
||||||
|
image: gitea/gitea:1.22
|
||||||
|
container_name: quoteapp-gitea
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
GITEA__server__DOMAIN: ${PUBLIC_HOSTNAME}
|
||||||
|
GITEA__server__ROOT_URL: https://${PUBLIC_HOSTNAME}:3001/
|
||||||
|
GITEA__server__HTTP_PORT: 3000
|
||||||
|
GITEA__server__SSH_PORT: 2222
|
||||||
|
GITEA__security__INSTALL_LOCK: "true"
|
||||||
|
GITEA__service__DISABLE_REGISTRATION: "true"
|
||||||
|
GITEA__database__DB_TYPE: sqlite3
|
||||||
|
GITEA__actions__ENABLED: "true"
|
||||||
|
ports:
|
||||||
|
- "3001:3000"
|
||||||
|
- "127.0.0.1:2222:22"
|
||||||
|
volumes:
|
||||||
|
- gitea_data:/data
|
||||||
|
|
||||||
|
gitea-runner:
|
||||||
|
image: gitea/act_runner:latest
|
||||||
|
container_name: quoteapp-gitea-runner
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- gitea
|
||||||
|
environment:
|
||||||
|
GITEA_INSTANCE_URL: http://gitea:3000
|
||||||
|
GITEA_RUNNER_REGISTRATION_TOKEN_FILE: /data/token
|
||||||
|
GITEA_RUNNER_NAME: vm-runner
|
||||||
|
volumes:
|
||||||
|
- gitea_runner_data:/data
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus:latest
|
||||||
|
container_name: quoteapp-prometheus
|
||||||
|
restart: unless-stopped
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
volumes:
|
||||||
|
- ./observability/prometheus.yml:/etc/prometheus/prometheus.yml:ro
|
||||||
|
- prometheus_data:/prometheus
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:9090:9090"
|
||||||
|
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana:latest
|
||||||
|
container_name: quoteapp-grafana
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
GF_SECURITY_ADMIN_USER: admin
|
||||||
|
GF_SECURITY_ADMIN_PASSWORD: admin
|
||||||
|
GF_USERS_ALLOW_SIGN_UP: "false"
|
||||||
|
volumes:
|
||||||
|
- ./observability/grafana/provisioning:/etc/grafana/provisioning:ro
|
||||||
|
- grafana_data:/var/lib/grafana
|
||||||
|
ports:
|
||||||
|
- "3002:3000"
|
||||||
|
depends_on:
|
||||||
|
- prometheus
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
zitadel_bootstrap:
|
||||||
|
gitea_data:
|
||||||
|
gitea_runner_data:
|
||||||
|
prometheus_data:
|
||||||
|
grafana_data:
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
{
|
||||||
|
"title": "Daily Quote — App Metrics",
|
||||||
|
"uid": "daily-quote-app",
|
||||||
|
"timezone": "browser",
|
||||||
|
"schemaVersion": 39,
|
||||||
|
"version": 1,
|
||||||
|
"refresh": "30s",
|
||||||
|
"time": { "from": "now-6h", "to": "now" },
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "HTTP requests / sec",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "sum(rate(http_request_duration_seconds_count[5m])) by (route)",
|
||||||
|
"legendFormat": "{{route}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "p95 request latency (s)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, route))",
|
||||||
|
"legendFormat": "{{route}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "Quotes submitted (total)",
|
||||||
|
"type": "stat",
|
||||||
|
"gridPos": { "h": 8, "w": 6, "x": 0, "y": 8 },
|
||||||
|
"targets": [{ "expr": "quotes_submitted_total" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "Node.js process memory (RSS)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": { "h": 8, "w": 18, "x": 6, "y": 8 },
|
||||||
|
"targets": [{ "expr": "process_resident_memory_bytes", "legendFormat": "RSS" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
11
observability/grafana/provisioning/dashboards/dashboards.yml
Normal file
11
observability/grafana/provisioning/dashboards/dashboards.yml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: default
|
||||||
|
orgId: 1
|
||||||
|
folder: ''
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
updateIntervalSeconds: 30
|
||||||
|
options:
|
||||||
|
path: /etc/grafana/provisioning/dashboards
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
datasources:
|
||||||
|
- name: Prometheus
|
||||||
|
type: prometheus
|
||||||
|
access: proxy
|
||||||
|
url: http://prometheus:9090
|
||||||
|
isDefault: true
|
||||||
|
editable: true
|
||||||
8
observability/prometheus.yml
Normal file
8
observability/prometheus.yml
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
global:
|
||||||
|
scrape_interval: 15s
|
||||||
|
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: quote-app
|
||||||
|
metrics_path: /api/metrics
|
||||||
|
static_configs:
|
||||||
|
- targets: ['host.docker.internal:3000']
|
||||||
18
server/.env.example
Normal file
18
server/.env.example
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
PORT=3000
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
PGHOST=localhost
|
||||||
|
PGPORT=5432
|
||||||
|
PGDATABASE=quoteapp
|
||||||
|
PGUSER=quoteapp
|
||||||
|
PGPASSWORD=quoteapp
|
||||||
|
|
||||||
|
SESSION_SECRET=change-me
|
||||||
|
|
||||||
|
# Platform auth (Zitadel). EXTERNAL_BASE is the browser-facing HTTPS URL
|
||||||
|
# (through the exe.dev proxy); INTERNAL_BASE is the plain-HTTP address the
|
||||||
|
# server uses directly on the VM for token exchange / userinfo calls.
|
||||||
|
ZITADEL_EXTERNAL_BASE=https://<vm-hostname>.exe.xyz:8080
|
||||||
|
ZITADEL_INTERNAL_BASE=http://<vm-hostname>.exe.xyz:8080
|
||||||
|
ZITADEL_CLIENT_ID=
|
||||||
|
ZITADEL_REDIRECT_URI=https://<vm-hostname>.exe.xyz/api/auth/callback
|
||||||
1180
server/package-lock.json
generated
Normal file
1180
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
server/package.json
Normal file
19
server/package.json
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "quote-app-server",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/index.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.21.1",
|
||||||
|
"express-session": "^1.18.1",
|
||||||
|
"morgan": "^1.10.0",
|
||||||
|
"openid-client": "^5.7.0",
|
||||||
|
"pg": "^8.13.1",
|
||||||
|
"prom-client": "^15.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
36
server/src/auth.js
Normal file
36
server/src/auth.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { Issuer, generators } from 'openid-client';
|
||||||
|
|
||||||
|
// Zitadel sits behind exe.dev's per-port HTTPS proxy: browsers must be sent to
|
||||||
|
// the public https://<host>:8080 URL, but server-to-server calls (token
|
||||||
|
// exchange, userinfo) stay on the VM and talk to Zitadel's plain-HTTP
|
||||||
|
// listener directly over the private address exe.dev already maps in
|
||||||
|
// /etc/hosts — hence two base URLs instead of a single discovered issuer.
|
||||||
|
const EXTERNAL_BASE = process.env.ZITADEL_EXTERNAL_BASE;
|
||||||
|
const INTERNAL_BASE = process.env.ZITADEL_INTERNAL_BASE;
|
||||||
|
|
||||||
|
let client = null;
|
||||||
|
|
||||||
|
export function getOidcClient() {
|
||||||
|
if (!EXTERNAL_BASE || !INTERNAL_BASE || !process.env.ZITADEL_CLIENT_ID) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
if (!client) {
|
||||||
|
const issuer = new Issuer({
|
||||||
|
issuer: EXTERNAL_BASE,
|
||||||
|
authorization_endpoint: `${EXTERNAL_BASE}/oauth/v2/authorize`,
|
||||||
|
token_endpoint: `${INTERNAL_BASE}/oauth/v2/token`,
|
||||||
|
userinfo_endpoint: `${INTERNAL_BASE}/oidc/v1/userinfo`,
|
||||||
|
jwks_uri: `${INTERNAL_BASE}/oauth/v2/keys`,
|
||||||
|
end_session_endpoint: `${EXTERNAL_BASE}/oidc/v1/end_session`,
|
||||||
|
});
|
||||||
|
client = new issuer.Client({
|
||||||
|
client_id: process.env.ZITADEL_CLIENT_ID,
|
||||||
|
redirect_uris: [process.env.ZITADEL_REDIRECT_URI],
|
||||||
|
response_types: ['code'],
|
||||||
|
token_endpoint_auth_method: 'none',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { generators };
|
||||||
21
server/src/db.js
Normal file
21
server/src/db.js
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import pg from 'pg';
|
||||||
|
|
||||||
|
const { Pool } = pg;
|
||||||
|
|
||||||
|
export const pool = new Pool({
|
||||||
|
host: process.env.PGHOST || 'localhost',
|
||||||
|
port: Number(process.env.PGPORT || 5432),
|
||||||
|
database: process.env.PGDATABASE || 'quoteapp',
|
||||||
|
user: process.env.PGUSER || 'quoteapp',
|
||||||
|
password: process.env.PGPASSWORD || 'quoteapp',
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function findOrCreateUser({ sub, email, displayName }) {
|
||||||
|
const existing = await pool.query('SELECT * FROM users WHERE sub = $1', [sub]);
|
||||||
|
if (existing.rows.length > 0) return existing.rows[0];
|
||||||
|
const inserted = await pool.query(
|
||||||
|
'INSERT INTO users (sub, email, display_name) VALUES ($1, $2, $3) RETURNING *',
|
||||||
|
[sub, email || null, displayName || null]
|
||||||
|
);
|
||||||
|
return inserted.rows[0];
|
||||||
|
}
|
||||||
61
server/src/index.js
Normal file
61
server/src/index.js
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import express from 'express';
|
||||||
|
import session from 'express-session';
|
||||||
|
import morgan from 'morgan';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { pool } from './db.js';
|
||||||
|
import { register, metricsMiddleware } from './metrics.js';
|
||||||
|
import { quotesRouter } from './routes/quotes.js';
|
||||||
|
import { favoritesRouter } from './routes/favorites.js';
|
||||||
|
import { authRouter } from './routes/auth.js';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const app = express();
|
||||||
|
const PORT = Number(process.env.PORT || 3000);
|
||||||
|
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
app.use(morgan(process.env.NODE_ENV === 'production' ? 'combined' : 'dev'));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(
|
||||||
|
session({
|
||||||
|
secret: process.env.SESSION_SECRET || 'dev-secret-change-me',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: { maxAge: 7 * 24 * 60 * 60 * 1000, sameSite: 'lax' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
app.use(metricsMiddleware);
|
||||||
|
|
||||||
|
app.use('/api/quotes', quotesRouter);
|
||||||
|
app.use('/api/favorites', favoritesRouter);
|
||||||
|
app.use('/api/auth', authRouter);
|
||||||
|
|
||||||
|
app.get('/api/health', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
res.json({ status: 'ok', db: true });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(503).json({ status: 'degraded', db: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/metrics', async (req, res) => {
|
||||||
|
res.set('Content-Type', register.contentType);
|
||||||
|
res.end(await register.metrics());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const clientDist = path.join(__dirname, '../../client/dist');
|
||||||
|
app.use(express.static(clientDist));
|
||||||
|
app.get(/^(?!\/api).*/, (req, res) => {
|
||||||
|
res.sendFile(path.join(clientDist, 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Daily Quote server listening on port ${PORT}`);
|
||||||
|
});
|
||||||
27
server/src/metrics.js
Normal file
27
server/src/metrics.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import client from 'prom-client';
|
||||||
|
|
||||||
|
export const register = new client.Registry();
|
||||||
|
client.collectDefaultMetrics({ register });
|
||||||
|
|
||||||
|
export const httpRequestDuration = new client.Histogram({
|
||||||
|
name: 'http_request_duration_seconds',
|
||||||
|
help: 'Duration of HTTP requests in seconds',
|
||||||
|
labelNames: ['method', 'route', 'status'],
|
||||||
|
buckets: [0.01, 0.05, 0.1, 0.3, 0.5, 1, 2, 5],
|
||||||
|
});
|
||||||
|
register.registerMetric(httpRequestDuration);
|
||||||
|
|
||||||
|
export const quotesSubmittedTotal = new client.Counter({
|
||||||
|
name: 'quotes_submitted_total',
|
||||||
|
help: 'Total number of quotes submitted by users',
|
||||||
|
});
|
||||||
|
register.registerMetric(quotesSubmittedTotal);
|
||||||
|
|
||||||
|
export function metricsMiddleware(req, res, next) {
|
||||||
|
const end = httpRequestDuration.startTimer();
|
||||||
|
res.on('finish', () => {
|
||||||
|
const route = req.route?.path ? req.baseUrl + req.route.path : req.path;
|
||||||
|
end({ method: req.method, route, status: res.statusCode });
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
}
|
||||||
62
server/src/routes/auth.js
Normal file
62
server/src/routes/auth.js
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { getOidcClient, generators } from '../auth.js';
|
||||||
|
import { findOrCreateUser } from '../db.js';
|
||||||
|
|
||||||
|
export const authRouter = Router();
|
||||||
|
|
||||||
|
authRouter.get('/me', (req, res) => {
|
||||||
|
res.json({ authenticated: Boolean(req.session.user), user: req.session.user || null });
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.get('/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const client = await getOidcClient();
|
||||||
|
if (!client) return res.status(503).send('Platform auth (Zitadel) is not configured yet.');
|
||||||
|
const codeVerifier = generators.codeVerifier();
|
||||||
|
const state = generators.state();
|
||||||
|
req.session.oidc = { codeVerifier, state };
|
||||||
|
const url = client.authorizationUrl({
|
||||||
|
scope: 'openid profile email',
|
||||||
|
code_challenge: generators.codeChallenge(codeVerifier),
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
res.redirect(url);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('OIDC login error', err);
|
||||||
|
res.status(503).send('Platform auth (Zitadel) is unreachable right now.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.get('/callback', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const client = await getOidcClient();
|
||||||
|
if (!client || !req.session.oidc) return res.redirect('/');
|
||||||
|
const params = client.callbackParams(req);
|
||||||
|
const tokenSet = await client.callback(process.env.ZITADEL_REDIRECT_URI, params, {
|
||||||
|
code_verifier: req.session.oidc.codeVerifier,
|
||||||
|
state: req.session.oidc.state,
|
||||||
|
});
|
||||||
|
const claims = tokenSet.claims();
|
||||||
|
const dbUser = await findOrCreateUser({
|
||||||
|
sub: claims.sub,
|
||||||
|
email: claims.email,
|
||||||
|
displayName: claims.name || claims.preferred_username || claims.email,
|
||||||
|
});
|
||||||
|
req.session.user = {
|
||||||
|
id: dbUser.id,
|
||||||
|
sub: dbUser.sub,
|
||||||
|
email: dbUser.email,
|
||||||
|
displayName: dbUser.display_name,
|
||||||
|
};
|
||||||
|
delete req.session.oidc;
|
||||||
|
res.redirect('/');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('OIDC callback error', err);
|
||||||
|
res.redirect('/?auth_error=1');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
authRouter.post('/logout', (req, res) => {
|
||||||
|
req.session.destroy(() => res.status(204).end());
|
||||||
|
});
|
||||||
46
server/src/routes/favorites.js
Normal file
46
server/src/routes/favorites.js
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { pool } from '../db.js';
|
||||||
|
|
||||||
|
export const favoritesRouter = Router();
|
||||||
|
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
if (!req.session.user) return res.status(401).json({ error: 'Sign in required.' });
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
favoritesRouter.get('/', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT q.* FROM favorites f JOIN quotes q ON q.id = f.quote_id
|
||||||
|
WHERE f.user_id = $1 ORDER BY f.created_at DESC`,
|
||||||
|
[req.session.user.id]
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
favoritesRouter.put('/:quoteId', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO favorites (user_id, quote_id) VALUES ($1, $2) ON CONFLICT DO NOTHING',
|
||||||
|
[req.session.user.id, req.params.quoteId]
|
||||||
|
);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
favoritesRouter.delete('/:quoteId', requireAuth, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await pool.query('DELETE FROM favorites WHERE user_id = $1 AND quote_id = $2', [
|
||||||
|
req.session.user.id,
|
||||||
|
req.params.quoteId,
|
||||||
|
]);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
50
server/src/routes/quotes.js
Normal file
50
server/src/routes/quotes.js
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import { pool } from '../db.js';
|
||||||
|
import { quotesSubmittedTotal } from '../metrics.js';
|
||||||
|
|
||||||
|
export const quotesRouter = Router();
|
||||||
|
|
||||||
|
quotesRouter.get('/today', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query('SELECT id FROM quotes ORDER BY id');
|
||||||
|
if (rows.length === 0) return res.json(null);
|
||||||
|
const dayNumber = Math.floor(Date.now() / 86400000); // days since epoch, UTC
|
||||||
|
const index = dayNumber % rows.length;
|
||||||
|
const quoteId = rows[index].id;
|
||||||
|
const quote = await pool.query('SELECT * FROM quotes WHERE id = $1', [quoteId]);
|
||||||
|
res.json(quote.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
quotesRouter.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT q.*, u.display_name AS submitted_by_name
|
||||||
|
FROM quotes q LEFT JOIN users u ON u.id = q.submitted_by
|
||||||
|
ORDER BY q.created_at DESC`
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
quotesRouter.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!req.session.user) return res.status(401).json({ error: 'Sign in required to submit a quote.' });
|
||||||
|
const { text, author } = req.body;
|
||||||
|
if (!text || typeof text !== 'string' || !text.trim()) {
|
||||||
|
return res.status(400).json({ error: 'Quote text is required.' });
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
'INSERT INTO quotes (text, author, submitted_by) VALUES ($1, $2, $3) RETURNING *',
|
||||||
|
[text.trim().slice(0, 1000), (author || 'Anonymous').trim().slice(0, 200), req.session.user.id]
|
||||||
|
);
|
||||||
|
quotesSubmittedTotal.inc();
|
||||||
|
res.status(201).json(rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
16
systemd/quote-app.service
Normal file
16
systemd/quote-app.service
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Daily Quote app (Vite/React + Express + Postgres)
|
||||||
|
After=network.target docker.service
|
||||||
|
Wants=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=exedev
|
||||||
|
WorkingDirectory=/home/exedev/quote-app/server
|
||||||
|
EnvironmentFile=/home/exedev/quote-app/server/.env
|
||||||
|
ExecStart=/usr/bin/node /home/exedev/quote-app/server/src/index.js
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
8
systemd/quoteapp-backup.service
Normal file
8
systemd/quoteapp-backup.service
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Daily Quote app - Postgres backup
|
||||||
|
After=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
User=exedev
|
||||||
|
ExecStart=/home/exedev/quote-app/backup/backup.sh
|
||||||
9
systemd/quoteapp-backup.timer
Normal file
9
systemd/quoteapp-backup.timer
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Nightly backup of the Daily Quote Postgres database
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=*-*-* 03:15:00
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Loading…
Reference in New Issue
Block a user