Daily Quote: Vite/React + MUI M3 frontend, Express API, Postgres, Zitadel auth, observability, backups
Some checks failed
CI / build (push) Failing after 2m12s

This commit is contained in:
Daily Quote Builder 2026-07-20 16:43:47 +00:00
commit 308a53fc08
43 changed files with 5028 additions and 0 deletions

3
.env.example Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

29
client/package.json Normal file
View 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"
}
}

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
View 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
View 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>
);
}

View 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
View 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
View File

@ -0,0 +1,11 @@
:root {
color-scheme: light;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}

25
client/src/main.jsx Normal file
View 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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:

View File

@ -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" }]
}
]
}

View 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

View File

@ -0,0 +1,9 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
editable: true

View 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
View 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

File diff suppressed because it is too large Load Diff

19
server/package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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());
});

View 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);
}
});

View 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
View 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

View 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

View 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