diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index c7679af..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/client/.env.production b/client/.env.production new file mode 100644 index 0000000..8eb4c9e --- /dev/null +++ b/client/.env.production @@ -0,0 +1 @@ +VITE_GRAPHQL_URL=http://it2810-01.idi.ntnu.no:3001/ \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index e580398..cb9fa52 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -12,6 +12,7 @@ "graphql": "^16.14.2", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-router": "^8.4.0", "rxjs": "^7.8.2" }, "devDependencies": { @@ -617,6 +618,12 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@remix-run/route-pattern": { + "version": "0.22.1", + "resolved": "https://registry.npmjs.org/@remix-run/route-pattern/-/route-pattern-0.22.1.tgz", + "integrity": "sha512-czdGJWh09Lapvcqt7Vb09KlrU2PhRJ8xQaI+AItTuD9aDJ5MAgxnS38lnys6Ll+6RJEqPiaUqTwIQhVLwFvv+w==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm-eabi": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.11.tgz", @@ -1403,6 +1410,12 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2531,6 +2544,28 @@ "react": "^19.3.0" } }, + "node_modules/react-router": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-8.4.0.tgz", + "integrity": "sha512-JtydAkBvU9UTEe5qNC+POhu+ZBNM7rlKY2IegwXc7Idj7xfRG16x5RZrw3mju+XlqBxfvb2ky9P3jUp9WyWC1g==", + "license": "MIT", + "dependencies": { + "@remix-run/route-pattern": "^0.22.1", + "cookie-es": "^3.1.1" + }, + "engines": { + "node": ">=22.22.0" + }, + "peerDependencies": { + "react": ">=19.2.7", + "react-dom": ">=19.2.7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/rolldown": { "version": "1.2.11", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.11.tgz", diff --git a/client/package.json b/client/package.json index 55cb7d5..a82364f 100644 --- a/client/package.json +++ b/client/package.json @@ -14,6 +14,7 @@ "graphql": "^16.14.2", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-router": "^8.4.0", "rxjs": "^7.8.2" }, "devDependencies": { diff --git a/client/src/apollo/client.ts b/client/src/apollo/client.ts new file mode 100644 index 0000000..e0a5bff --- /dev/null +++ b/client/src/apollo/client.ts @@ -0,0 +1,17 @@ +import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'; + +/** + * Apollo Client handles communication with our GraphQL backend and is also + * our state management: InMemoryCache stores query results, so the same + * query isn't sent to the server again (fewer requests, more sustainable). + * + * The backend URL is read from the environment variable VITE_GRAPHQL_URL, + * so the same code works locally and on the VM. Without it, we use the + * local backend on port 3001. + */ +export const client = new ApolloClient({ + link: new HttpLink({ + uri: import.meta.env.VITE_GRAPHQL_URL ?? 'http://localhost:3001/', + }), + cache: new InMemoryCache(), +}); \ No newline at end of file diff --git a/client/src/main.tsx b/client/src/main.tsx index 3db6379..e1bbd9e 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,30 +1,17 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import { ApolloClient, HttpLink, InMemoryCache } from '@apollo/client'; +import { BrowserRouter } from 'react-router'; import { ApolloProvider } from '@apollo/client/react'; +import { client } from './apollo/client'; +import App from './App'; import './index.css'; -import App from './App.tsx'; - -/** - * Apollo Client handles communication with our GraphQL backend and is also - * our state management: InMemoryCache stores query results, so the same - * query isn't sent to the server again (fewer requests, more sustainable). - * - * The backend URL is read from the environment variable VITE_GRAPHQL_URL, - * so the same code works locally and on the VM. Without it, we use the - * local backend on port 3001. - */ -const client = new ApolloClient({ - link: new HttpLink({ - uri: import.meta.env.VITE_GRAPHQL_URL ?? 'http://localhost:3001/', - }), - cache: new InMemoryCache(), -}); createRoot(document.getElementById('root')!).render( - - - + + + + + , ); diff --git a/client/vite.config.ts b/client/vite.config.ts index 9982072..2a1e3b1 100644 --- a/client/vite.config.ts +++ b/client/vite.config.ts @@ -3,5 +3,7 @@ import { defineConfig } from 'vite' // https://vite.dev/config/ export default defineConfig({ + // The app is served from http://it2810-xx.idi.ntnu.no/project2/ + base: '/project2/', plugins: [react()], }) diff --git a/deploy/apache-project2.conf b/deploy/apache-project2.conf new file mode 100644 index 0000000..d7632c8 --- /dev/null +++ b/deploy/apache-project2.conf @@ -0,0 +1,20 @@ +# Apache config for the Wine not? client, served at /project2. +# Installed to /etc/apache2/conf-available/project2.conf on the VM by deploy/deploy.sh on every deploy. + + + # Unknown paths (e.g. /project2/wine/42) get index.html, + # so React Router can handle them and a page refresh doesn't give 404. + FallbackResource /project2/index.html + + # index.html and version.txt are always re-checked, + # so a new deploy shows up immediately. + + Header set Cache-Control "no-cache" + + + +# Vite gives built files hashed names, so browsers can cache them for a year. +# Returning visitors don't download them again (less data transferred). + + Header set Cache-Control "public, max-age=31536000, immutable" + diff --git a/deploy/backend.env.example b/deploy/backend.env.example new file mode 100644 index 0000000..1f02da8 --- /dev/null +++ b/deploy/backend.env.example @@ -0,0 +1,9 @@ +# Template for /etc/wine-not/backend.env on the VM. The real file, with the +# password, only exists on the VM and can be read with sudo. +# Same variable names as backend/.env on a laptop. +PORT=3001 +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=winenot +DB_USER=winenot_app +DB_PASSWORD= diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100755 index 0000000..12538e1 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Builds the backend and the client on the VM and puts them live. Runs as root. +# +# Normally started by deploy/push-to-vm.sh on a laptop, which sends a new +# copy of main: +# bash deploy.sh --install +# +# To rebuild the code that's already on the VM (e.g. after a migration), +# on the VM: +# sudo bash /opt/wine-not/deploy/deploy.sh +# +# Both parts are built before anything goes live. If a build fails, the +# script stops and the version that's already live keeps running. +set -euo pipefail + +main() { + local app_dir=/opt/wine-not + local web_dir=/var/www/html/project2 + + # Only one deploy at a time; a second one waits for the first to finish. + exec 9>/run/wine-not-deploy.lock + flock 9 + + if [ "${1:-}" = "--install" ]; then + local src="$2" commit="$3" + echo "==> Copying commit $commit to $app_dir" + mkdir -p "$app_dir" + chmod 755 "$src" # the backend runs as user winenot and must read the code + # Replace the code, but keep installed packages, builds and the CSV data. + rsync -a --delete \ + --exclude node_modules --exclude dist --exclude /backend/data \ + "$src/" "$app_dir/" + echo "$commit" > "$app_dir/COMMIT" + fi + + echo "==> Backend: install and build" + cd "$app_dir/backend" + npm ci + npm run build + + echo "==> Client: install and build" + cd "$app_dir/client" + npm ci + npm run build + + echo "==> Server setup: backend service and Apache config" + install -m 644 "$app_dir/deploy/wine-not-api.service" /etc/systemd/system/wine-not-api.service + systemctl daemon-reload + systemctl enable --quiet wine-not-api + install -m 644 "$app_dir/deploy/apache-project2.conf" /etc/apache2/conf-available/project2.conf + a2enmod --quiet headers + a2enconf --quiet project2 + apache2ctl configtest + + echo "==> Going live" + systemctl restart wine-not-api + mkdir -p "$web_dir" + rsync -a --delete "$app_dir/client/dist/" "$web_dir/" + systemctl restart apache2 + + # Shows which commit is live at /project2/version.txt + local commit_live + commit_live="$(cat "$app_dir/COMMIT" 2>/dev/null || echo unknown)" + echo "$commit_live deployed $(date '+%Y-%m-%d %H:%M')" > "$web_dir/version.txt" + + sleep 2 + echo "==> Done. Commit $commit_live. Backend status: $(systemctl is-active wine-not-api)" +} + +# Everything is inside a function so bash reads the whole script before +# running it, even if the file is replaced while it runs. +main "$@" diff --git a/deploy/push-to-vm.sh b/deploy/push-to-vm.sh new file mode 100755 index 0000000..cfe210f --- /dev/null +++ b/deploy/push-to-vm.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Deploys the newest version of main to the VM. +# +# Run it on your LAPTOP, from the repo, on the NTNU network or VPN: +# bash deploy/push-to-vm.sh +# +# The VM has no access to the Git server, so your laptop packs up what's on +# main, copies it to the VM and runs deploy/deploy.sh there. +# You may be asked for your NTNU password up to three times: +# once to copy, once to log in and once for sudo. +set -euo pipefail + +VM_HOST="it2810-01.idi.ntnu.no" + +if [ "$#" -ne 1 ]; then + echo "Usage: bash deploy/push-to-vm.sh " >&2 + exit 1 +fi +vm_user="$1" + +cd "$(git rev-parse --show-toplevel)" + +echo "==> Getting the newest main" +git fetch --quiet origin main +commit="$(git rev-parse --short origin/main)" +echo " Commit $commit: $(git log -1 --format=%s origin/main)" + +# Only committed files on main are packed: no node_modules, .env or CSV data. +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT +git archive --format=tar.gz -o "$tmp_dir/wine-not.tar.gz" origin/main + +echo "==> Copying to $VM_HOST" +remote_archive="/tmp/wine-not-$commit.tar.gz" +scp -q "$tmp_dir/wine-not.tar.gz" "$vm_user@$VM_HOST:$remote_archive" + +echo "==> Building and starting on the VM" +# Unpacks into a temporary folder and runs the deploy.sh from this commit, +# which copies the code to /opt/wine-not, builds it and puts it live. +ssh -t "$vm_user@$VM_HOST" \ + "sudo bash -c 'set -e; d=\$(mktemp -d); tar -xzf $remote_archive -C \"\$d\"; rm -f $remote_archive; bash \"\$d/deploy/deploy.sh\" --install \"\$d\" $commit; rm -rf \"\$d\"'" + +echo "==> Live at http://$VM_HOST/project2/" diff --git a/deploy/wine-not-api.service b/deploy/wine-not-api.service new file mode 100644 index 0000000..c469722 --- /dev/null +++ b/deploy/wine-not-api.service @@ -0,0 +1,19 @@ +# systemd service that runs the Wine not? GraphQL backend on port 3001. +# Installed to /etc/systemd/system/ on the VM by deploy/deploy.sh on every deploy. +[Unit] +Description=Wine not? GraphQL backend +After=network.target postgresql.service + +[Service] +User=winenot +Group=winenot +WorkingDirectory=/opt/wine-not/backend +# Database settings and port. This file exists only on the VM, never in Git. +EnvironmentFile=/etc/wine-not/backend.env +Environment=NODE_ENV=production +ExecStart=/usr/bin/node dist/index.js +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/with-env.sh b/deploy/with-env.sh new file mode 100755 index 0000000..d4d8dd8 --- /dev/null +++ b/deploy/with-env.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Runs one command with the backend's database settings loaded from +# /etc/wine-not/backend.env (the secret file that only exists on the VM). +# The DB_ settings are also given to psql (as PGHOST, PGUSER, ...), +# so psql connects to the same database as the backend. +# +# Usage on the VM, from /opt/wine-not/backend: +# sudo bash ../deploy/with-env.sh npm run import +# sudo bash ../deploy/with-env.sh psql -f db/migrations/.sql +set -euo pipefail + +if [ "$#" -eq 0 ]; then + echo "Usage: sudo bash ../deploy/with-env.sh [args...]" >&2 + exit 1 +fi + +set -a # export every variable defined in the file +# shellcheck source=/dev/null +. /etc/wine-not/backend.env +set +a + +export PGHOST="${DB_HOST:-localhost}" +export PGPORT="${DB_PORT:-5432}" +export PGDATABASE="${DB_NAME:-}" +export PGUSER="${DB_USER:-}" +export PGPASSWORD="${DB_PASSWORD:-}" + +exec "$@"