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