Compare commits

68 Commits

Author SHA1 Message Date
aa14b4c185 Pin Listening header so only song content scrolls
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m36s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 10:53:43 +01:00
06a18eac3d Remove link from Daisy Green Holland Park entry
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 10:50:16 +01:00
a8debe2f51 Remove Air Cadet Force qualification from Programming CV
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 22m38s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:25:32 +01:00
548f2350d2 Add Daisy Green Holland Park to hospitality experience
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
Adds the new Daisy Green Holland Park role (May 2026 – Present) to
CVHospitality, and expands CVProgramming's hospitality section to
list each venue individually.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 12:24:20 +01:00
3f5803d4fc Hatsune miku menu first commit
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m3s
2026-05-19 14:57:36 +01:00
60bd906251 Add school grades and Air Cadets to Programming CV
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m41s
Adds University Academy of Engineering Southbank section with A-Level
and GCSE grades, plus CVQO Level 2 BTEC via the Air Cadet Force.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 22:46:49 +01:00
7d888ea4cb Adding hatsune miku images
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 38s
2026-05-14 12:42:49 +01:00
3b14c3453c Include grades
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m1s
2026-05-13 09:45:50 +01:00
842943e7e8 Add Analyst CV template
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 27m35s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 08:56:51 +01:00
aa6de883be Move Open-WebUI to chat.${DOMAIN} subdomain
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 34s
Open-WebUI's SvelteKit frontend hardcodes asset/API paths at build time,
so subpath hosting under /openwebui/ produced 404s on /_app/... assets.
Move it to its own subdomain so it can run at root.

- certbot: request cert with chat.${DOMAIN} as a third SAN via --expand
- nginx (prod): drop /openwebui blocks; add chat.${DOMAIN} HTTP redirect
  + HTTPS server with the existing admin auth gate
- nginx (dev): drop /openwebui blocks (no chat.localhost in dev)
- compose: WEBUI_URL points to https://chat.${DOMAIN}

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 17:29:29 +01:00
26a35719eb Add Open-WebUI service behind /openwebui/ admin gate
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 13m13s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-30 15:51:27 +01:00
3844a32751 Big formatting spree
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m50s
2026-04-29 09:06:41 +01:00
b41e67fe1a Making all CV's abide by same styling
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 16s
2026-04-27 11:45:15 +01:00
9bcb21910d Making all CV's abide by same styling
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-04-27 11:45:01 +01:00
a116ec2614 Add Programming CV and revise General CV layout and content
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 24s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:58:56 +01:00
6f204d4164 Revise Electrical CV layout and content
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 18s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-24 03:51:55 +01:00
de803dea9f Add To The Rise Bakery to Hospitality CV and trim interests
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 33s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:59:48 +01:00
a520944fe3 Align Hospitality CV layout with General CV style
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m36s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-23 14:53:49 +01:00
22a836cb95 Add other CVs back
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 21m27s
2026-04-23 13:58:55 +01:00
12967c573b Rewrite Electrical CV in a more natural voice
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 18s
Drop the Crown and Anchor entry and rework Profile, Key Strengths,
Experience and Additional so the copy reads more like something I'd
actually write rather than polished boilerplate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 12:21:40 +01:00
14233e88a8 Generalize Electrical CV and drop Why Electrical section
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 21s
Rework the apprenticeship-specific profile into a general electrical
apprenticeship pitch, folding in the useful framing from Why Electrical,
and tighten experience and Additional copy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 12:12:10 +01:00
2bcb47a1a1 Add Electrical CV template for apprenticeship applications
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 23s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-21 12:08:17 +01:00
8636dfedb9 Replace go-imap library with custom IMAP client, simplify CV layout styles, bump vite, move SEED_DB to backend
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m19s
- Rewrite email_imap.go to use a minimal hand-rolled IMAP client instead of go-imap/go-message,
  for better compatibility with Outlook's non-standard responses
- Consolidate and simplify CVLayout.vue CSS overrides
- Bump vite from 7.1.11 to 7.3.2
- Move SEED_DB env var from nginx to backend in dev compose
- Add /app/src/wasm volume exclusion in dev compose

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 08:53:34 +01:00
c20b1c2691 Fix CV layout colors: force transparent bg on links, inherit color on td/textarea
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 11m24s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-18 08:14:47 +01:00
0b6ffedb70 Add padding below headers in Listening/Steam and adjust layout sizing
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 27s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 02:57:54 +01:00
a14b78a1b9 Add CreateBookmark form with toggle in Bookmarks header
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 25s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 02:51:51 +01:00
254541a370 Center ToggleHeader button vertically in LinkTable
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 20s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 02:49:38 +01:00
f390bf82cc Resize bookmark headers
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 20s
2026-04-16 00:42:53 +01:00
b96b7d7a93 Split schema.graphql and schema.resolvers.go into per-domain files
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m36s
Move Query/Mutation field declarations from the monolithic schema.graphql
into each domain's .graphql file using extend type, so gqlgen places
resolvers in the matching *.resolvers.go files. Extract helper functions
into *_helpers.go files to prevent gqlgen from commenting them out.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 18:57:23 +01:00
37171478b1 refactored code, removed *_helper.go files and placed them in *.resolvers.go files for uniformity
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m23s
2026-04-15 18:33:19 +01:00
00364aca23 Fix nginx stale DNS caching causing backend to appear down after restarts
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 22s
Use Docker's embedded DNS resolver (127.0.0.11) with nginx variables in
proxy_pass directives so upstream hostnames are re-resolved at runtime
instead of being cached forever at startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 16:29:50 +01:00
3d97ccf38c Switch IMAP library from go-imap/v2 to v1 for Outlook compatibility
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m16s
go-imap/v2's strict wire parser rejects Outlook's non-standard IMAP
login responses. v1 is more lenient and handles these gracefully.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 14:16:04 +01:00
1e22bacdc9 Add email sync service for automated job application tracking
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m40s
Background poller fetches emails via IMAP or Microsoft Graph API,
classifies them with Claude Haiku, and creates/updates JobApplication
records automatically. Includes manual sync endpoint and OAuth callback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 13:59:24 +01:00
8d10f75f2b Move bookmarks to home folder, reduce header size and fix import link
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 28s
2026-04-14 17:31:53 +01:00
68dca953f2 Restore JS scroll animations, move WASM AutoScroll to util/wasm/
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 17s
Revert AutoScroll and Headline components to their original JS
requestAnimationFrame implementations. Keep the WASM-based AutoScroll
as an alternative at util/wasm/AutoScroll.vue.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:53:32 +01:00
c684fcb858 Reduce toggle button size by half
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 22s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:49:37 +01:00
2b5745b946 Move scroll animations to Rust/WASM, enable Hasura, and move bookmarks to home sidebar
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 12m7s
Port AutoScroll and Headline scroll logic from Vue/JS to Rust compiled
to WASM via wasm-pack. Add multi-stage Docker build for WASM compilation,
Vite WASM plugins, and top-level await for WASM init. Enable Hasura
service in docker-compose. Move bookmarks from a separate route to an
inline sidebar component on the home page. Fix ToggleHeader click
propagation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 16:34:24 +01:00
b56f8253d9 Harden backend against critical and high security vulnerabilities
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m51s
- Fix WebSocket CheckOrigin to use proper url.Parse instead of string stripping
- Add admin auth checks to Users/User GraphQL queries
- Remove GraphQL GET transport to prevent CSRF via cross-site links
- Add application-level IP-based login rate limiting (5 attempts/min)
- Add path traversal bounds check on radio file upload
- Require DEV_MODE for GraphQL introspection and playground
- Move notes backend endpoint behind admin middleware
- Add dedicated Nginx rate limit zone for GraphQL (10r/s)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 13:27:33 +01:00
798c8e7f50 Fix horizontal scrollbar and style slim themed scrollbars for Chrome/Edge
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 22s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 13:18:13 +01:00
0e7f34edc7 Update README with recent features and route changes
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m34s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 11:08:42 +01:00
7aff171ef8 Remove REST handlers superseded by GraphQL resolvers
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
Deleted handle_activity.go, handle_favorites.go, handle_post.go, and
handle_user.go — all logic already exists in schema.resolvers.go.
Removed corresponding REST routes from main.go. Moved UserCredentials
struct (used by Login handler) into handle_auth.go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 11:08:09 +01:00
cc6a423ef0 Add backend healthcheck and autoheal for automatic restart
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 19s
Adds a Docker healthcheck to the backend service (polling GET / every 30s)
and the willfarrell/autoheal container to automatically restart any unhealthy containers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 10:59:48 +01:00
759614e92d Add job application quick reference for storing profile links and experience
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m34s
Auth-protected CRUD for personal info (LinkedIn, GitHub, etc.) and
experience entries, stored in the database so nothing sensitive is in
the public repo. Displayed as a categorized panel on the Job Applications page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 12:38:53 +01:00
81c5684102 Fix appliedAt date format to RFC3339 for GraphQL mutations
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 15s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 12:34:12 +01:00
a911e6ca69 Add inline admin create forms to home page components
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 28s
Lazy-load create forms (Post, Activity, Favorite, Rowing) directly
into their corresponding home components with an admin-only toggle
button, replacing the need to navigate to the admin page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 12:13:13 +01:00
66f32cdbd2 Add database-backed bookmarks via GraphQL
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m47s
Replace hardcoded bookmarks in the frontend with a GORM-backed Bookmark
model exposed through GraphQL query and admin-only create/delete mutations.
Frontend groups bookmarks by category dynamically from the store.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 12:04:13 +01:00
390f69858c Redirect to original URL after admin login
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m49s
Pass the requested URI as ?redirect= when nginx denies access, so the
login page can forward the user to their intended destination on success.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 11:55:42 +01:00
c3db00abf2 Fix logout not clearing cookies due to missing path and domain
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 11:53:42 +01:00
4a0300d4b4 Fix auth guard watcher to use Vue 3 watch instead of $watch
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 19s
$watch is a Vue 2 instance method not available on Pinia stores.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 11:48:27 +01:00
18b50f1ce6 Split admin login into its own route and add auth guard to /admin
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 28s
- Add /admin/login route for Login.vue as a standalone page
- Add requiresAdmin guard to /admin route
- Update auth guard redirect to /admin/login with redirect query param
- Update nginx @auth_denied to redirect to /admin/login
- Remove Login component from Admin.vue; drop v-if auth checks (guard handles access)
- Remove stale view files from old views/ structure (moved in prior commit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 11:19:10 +01:00
4d154ff837 Reorganise views/ directory structure to match routes
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 16s
Move shrines and bookmarks under home/, landing and 404 into own
subdirectories, and retire Notes.vue (served by external service).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 11:15:46 +01:00
869d9a168e Move admin auth guard to Vue Router
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 24s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 11:09:12 +01:00
fc9d3c97bf Add Jobs link to CV and fix auth race on job applications page
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 19s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 11:03:00 +01:00
0dc1c278c2 Move job applications to /cv/jobs route and add layout system
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m41s
- Add DefaultLayout and CVLayout with nested routing
- Job applications is now a standalone page at /cv/jobs with a back link
- Remove JobApplications embed from CV.vue

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 10:09:22 +01:00
a0f99d9fba Add CSV export to job applications tracker
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m29s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 09:53:30 +01:00
8f3c369ed8 Add job application tracker (admin-only)
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
Full CRUD GraphQL API for tracking job applications with status workflow.
Frontend component in CV view, hidden from print. Login now redirects to
intended route after auth via query param.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 09:51:31 +01:00
81f5fafb61 Redirect auth-denied users to /admin login page instead of homepage
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 23s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 22:05:22 +01:00
c335bf14d6 Add token refresh to ValidateAdmin for seamless session renewal
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m55s
When the access token is missing or expired, the handler now falls back
to the refresh token, verifies the user is still admin via DB lookup,
and issues fresh cookies in the subrequest response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 21:35:45 +01:00
d344497393 Gate searxng, notes, and hasura behind admin auth via nginx auth_request
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
Add ValidateAdmin endpoint that checks JWT admin claim for use as an
nginx auth_request subrequest. Widen cookie path from backend endpoint
to "/" so the access_token is sent on all paths. Extend access token
lifetime from 24h to 7 days. Disable hasura service by default via
Docker profile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 21:33:41 +01:00
ee97ec9b23 Pin app-network subnet to match trusted proxy CIDR
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 38s
Gin's trusted proxies list is hardcoded to 172.28.0.0/16, but Docker was
assigning the bridge network whatever subnet was free, so c.ClientIP()
often returned nginx's container IP instead of the real client.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 00:43:29 +01:00
34934e7d13 Enable gin release mode outside dev
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m53s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 00:38:44 +01:00
1d472d382b Remove padding beneath header
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 15s
2026-04-08 01:33:26 +01:00
4ebe886579 Remove padding beneath header
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 24s
2026-04-08 01:32:02 +01:00
dd5412cb79 Fix sidebar on mobile
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 15s
2026-04-08 01:30:03 +01:00
4000baf755 Fix mobile heights
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 20s
2026-04-08 01:26:00 +01:00
a15aa040f4 Remove horizontal side-scrolling on Home page
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m35s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 00:35:17 +01:00
a03cce3e04 Comment on AI slop and fix ai slop
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 7s
2026-04-07 16:46:31 +01:00
400d100426 Consolidate readme.md and README.md into single comprehensive README
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 7s
Merges both files and adds full endpoint reference, .env variable table,
setup guides (Spotify, Certbot, Obsidian/Quartz, Icecast), deprecated
endpoint notes, and updated architecture with all services.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 16:16:37 +01:00
162 changed files with 12950 additions and 6410 deletions

3
.gitignore vendored
View File

@@ -17,6 +17,9 @@ gitea-runner/nohup.out
# Rust build artifacts # Rust build artifacts
**/target/ **/target/
# Generated WASM output
vue/src/wasm/
# Logs # Logs
logs logs
*.log *.log

351
README.md
View File

@@ -1,54 +1,359 @@
# web_server # My Web
Dockerized multi-service personal website. ![screenshot](vue/public/img/screenshot.png)
## Untracked Files Requiring Manual Setup Welcome to the source code for my website! Please contact me if you would like to collaborate and thank you for visiting.
These files are git-ignored and must be created or obtained manually before running the stack. This website is self-hosted on my Raspberry Pi. Any interference or hax and the killswitch will activate and stop the UK national grid power system so please don't tamper with my domain :).
## The use of AI
This has been created with a heavy amount of AI. Initially, I began with articles from [medium](https://medium.com/) informing me of how to use Nginx to host static sites and reverse proxy to other services. They were incredible helpful and I wish I were able to give credit them, but sadly they were read too long ago.
After hearing all the hype on LinkedIn and feeling the pressure to keep up a high output without AI, I eventually caved in. There is an immense advantage not having to scan though documentation to find relevant functions, asking how other developers implement their infrastructure and finding what command will achieve your outcome quickly. Sure, it would be good to have that already cached in your own human memory. But my reasoning is that you _will_ have cached it after being reminded by AI enough times, similar to how flashcards work. I may not be an expert in the specific tool, though I've been able to get good enough at it for my own purposes.
## Architecture
All services run in Docker containers orchestrated by Docker Compose behind Nginx as a reverse proxy on a single bridge network.
```
vue ── Frontend build (outputs dist to shared volume)
nginx (80, 443) ── Frontend SPA + Reverse Proxy
backend (8080) ── Go API (GraphQL + REST)
db (5432) ── PostgreSQL 16
icecast2 (8000) ── Audio Streaming (Icecast2 + Liquidsoap)
gitea (3000) ── Self-Hosted Git
quartz (8080) ── Obsidian Notes Publisher (Quartz v4.4.0)
searxng (8080) ── Meta Search Engine
hasura (8080) ── Hasura GraphQL Engine (Docker profile: hasura)
autoheal ── Auto-restart unhealthy containers
certbot ── SSL Certificate Management (disabled in dev)
```
## Tech Stack
**Frontend** - Vue 3, Vite, Tailwind CSS v4, Pinia, Vue Router, markdown-it (wikilinks + KaTeX), Rust/WASM
**Backend** - Go (Gin), gqlgen (GraphQL), GORM, PostgreSQL, JWT auth, WebSockets
**Integrations** - Spotify API, Steam API, Anthropic Claude API, Icecast2
**Infrastructure** - Docker Compose, Nginx, Let's Encrypt (Certbot), Gitea + Act Runner
## Features
- Spotify integration (currently playing, recently played)
- Steam integration (online status, recent games)
- Obsidian note viewer via Quartz
- Live radio streaming via Icecast2 + Liquidsoap
- Real-time chat over WebSockets with image/video uploads
- Blog with admin panel (CRUD)
- Activity and rowing session tracking
- AI image processing integration to extract rowing data from images (will be superseeded by openCV scanning)
- Fan shrines (GTO, Evangelion, Demoman, Skip Skip Benben)
- Self-hosted Git (Gitea) with CI/CD and commit feed on homepage
- Printable CV with role-specific sections
- Job application tracker with status workflow and CSV export (admin-only, `/cv/jobs`)
- Database-backed bookmarks grouped by category, managed via GraphQL (admin-only)
- SearXNG meta search engine (admin-only)
- Hasura GraphQL console (admin-only)
- Admin-gated routes: `/searxng`, `/notes`, `/hasura` require admin JWT via Nginx `auth_request`
- Landing page with animated stamps section
- Route transitions (slide/fade) and performance optimizations (gzip, WOFF2 fonts, lazy loading)
- Backend healthcheck with autoheal container for automatic recovery
## Pages
| Route | Description |
| -------------- | -------------------------------------------------- |
| `/` | Landing page |
| `/stp` | Home dashboard with grid layout |
| `/admin` | Admin login + panel (authenticated) |
| `/cv` | Curriculum Vitae (printable) |
| `/cv/jobs` | Job application tracker (admin-only, hidden print) |
| `/bookmarks` | Bookmarks (database-backed, grouped by category) |
| `/notes/:path` | Obsidian note viewer (via Quartz, admin-only) |
| `/shrines` | Fan shrine index + individual shrines |
## API
### GraphQL
**Endpoint:** `POST /api/graphql`
**Playground:** `GET /api/graphql` (when `GQL_PLAYGROUND=true`)
| Operation | Type | Description |
| ------------------------------------------ | -------- | ----------------------- |
| `users` | Query | Get all users |
| `user(id)` | Query | Get user by ID |
| `me` | Query | Get authenticated user |
| `posts` | Query | Get all posts |
| `post(id)` | Query | Get post by ID |
| `activities` | Query | Get all activities |
| `favorites` | Query | Get all favorites |
| `rowingSessions` | Query | Get all rowing sessions |
| `post(id)` | Query | Get post by ID |
| `activities` | Query | Get all activities |
| `favorites` | Query | Get all favorites |
| `rowingSessions` | Query | Get all rowing sessions |
| `messages` | Query | Get all messages |
| `spotifyListening` | Query | Currently playing track |
| `spotifyRecent` | Query | Recently played tracks |
| `giteaFeed` | Query | Latest Gitea activity |
| `steamStatus` | Query | Steam online status |
| `login` | Mutation | Authenticate user |
| `logout` | Mutation | Logout |
| `refreshToken` | Mutation | Refresh auth token |
| `createPost` / `updatePost` / `deletePost` | Mutation | Post CRUD (admin) |
| `createUser` / `deleteUser` | Mutation | User management (admin) |
| `setUserAdmin` | Mutation | Toggle admin status |
| `createFavorite` | Mutation | Add favorite (admin) |
| `createActivity` | Mutation | Add activity (admin) |
| `bookmarks` | Query | Get all bookmarks |
| `createBookmark` / `deleteBookmark` | Mutation | Bookmark CRUD (admin) |
| `jobApplications` | Query | Get all job applications (admin) |
| `createJobApplication` / `updateJobApplication` / `deleteJobApplication` | Mutation | Job application CRUD (admin) |
### REST Endpoints
**Auth**
| Method | Path | Description |
| --- | --- | --- |
| POST | `/api/auth/login` | Login (rate limited: 5/min) |
| POST | `/api/auth/refresh` | Refresh token |
| GET | `/api/auth/check` | Check token validity |
| POST | `/api/auth/logout` | Logout |
| GET | `/api/auth/validate-admin` | Validate admin JWT (used by Nginx auth_request) |
Access tokens are valid for 7 days; refresh tokens for 365 days. `ValidateAdmin` also refreshes the access token if it is within 24 hours of expiry.
**Spotify**
| Method | Path | Description |
| --- | --- | --- |
| GET | `/api/spotify/callback` | OAuth callback |
| GET | `/api/spotify/listening` | Currently playing |
| GET | `/api/spotify/recent` | Recently played |
**Public**
| Method | Path | Description |
| --- | --- | --- |
| GET | `/api/favorites` | Get all favorites |
| GET | `/api/rowing` | Get all rowing sessions |
| GET | `/api/activity` | Get all activities |
| GET | `/api/posts` | Get all posts |
| GET | `/api/posts/:id` | Get post by ID |
| GET | `/api/user` | Get all users |
| GET | `/api/user/:id` | Get user by ID |
**Protected (auth required)**
| Method | Path | Description |
| --- | --- | --- |
| POST | `/api/messages/upload` | Upload message file (rate limited: 5/min) |
**Admin (auth + admin required)**
| Method | Path | Description |
| --- | --- | --- |
| POST | `/api/favorites` | Create favorite |
| POST | `/api/rowing` | Create rowing session |
| POST | `/api/activity` | Create activity |
| POST | `/api/posts` | Create post |
| PUT | `/api/posts/:id` | Update post |
| DELETE | `/api/posts/:id` | Delete post |
| POST | `/api/user` | Create user |
| PUT | `/api/user/:id` | Update user |
| DELETE | `/api/user/:id` | Delete user |
| PATCH | `/api/user/:id/admin` | Set/unset admin |
| POST | `/api/radio/upload` | Upload radio song |
| GET | `/api/radio/songs` | List radio songs |
| DELETE | `/api/radio/songs/:filename` | Delete radio song |
| PATCH | `/api/radio/songs/:filename/disable` | Disable radio song |
| PATCH | `/api/radio/songs/:filename/enable` | Enable radio song |
**WebSocket**
| Method | Path | Description |
| --- | --- | --- |
| GET | `/api/ws` | WebSocket chat (10s ping keepalive) |
### Nginx Proxy Routes
| Route | Target | Notes |
| ---------- | ------------ | ------------------------------------------ |
| `/api` | backend:8080 | API (rate limited: 30r/s) |
| `/radio` | icecast:8000 | Audio streaming |
| `/gitea` | gitea:3000 | Git service |
| `/hasura` | hasura:8080 | GraphQL console + WebSocket (admin-only) |
| `/notes` | quartz:8080 | Obsidian notes (admin-only) |
| `/searxng` | searxng:8080 | Search engine (admin-only) |
| `/uploads` | local alias | User-uploaded files |
`/hasura`, `/notes`, and `/searxng` are protected by `auth_request` to `GET /api/auth/validate-admin`. Requests without a valid admin JWT are rejected with 401.
### Deprecated Endpoints
**Backend note API** (`GET /api/notes/*path`) - The backend has a REST endpoint that serves note files directly from the mounted Obsidian vault. This is superseded by the Quartz service which now handles note rendering at `/notes/`. The backend endpoint still exists in code but is no longer the primary note serving path.
## Setup
### `.env` ### `.env`
Environment variables used by all services. No example file is provided — see `docker-compose.yml` for the full list of referenced variables (database credentials, hostnames, secrets, Spotify OAuth, Gitea tokens, etc.). Create a `.env` file in the project root. All services read from this file.
### `gitea/config/app.ini` | Variable | Description |
| --------------------------------- | ------------------------------------------------------------------------------- |
| `POSTGRES_USER` | PostgreSQL username |
| `POSTGRES_PASSWORD` | PostgreSQL password |
| `POSTGRES_DB` | Main app database name |
| `POSTGRES_PORT` | PostgreSQL port (typically `5432`) |
| `POSTGRES_HOST` | PostgreSQL hostname (use `db` for Docker) |
| `GITEA_HOST` | Gitea hostname (use `gitea` for Docker) |
| `GITEA_PORT` | Gitea HTTP port (typically `3000`) |
| `GITEA_INTERNAL_TOKEN` | Gitea internal API token (generate with `gitea generate secret INTERNAL_TOKEN`) |
| `GITEA_LFS_JWT_SECRET` | Gitea LFS JWT secret (generate with `gitea generate secret LFS_JWT_SECRET`) |
| `GITEA_OAUTH2_JWT_SECRET` | Gitea OAuth2 JWT secret (generate with `gitea generate secret JWT_SECRET`) |
| `POSTGRES_GITEA_DB` | Gitea database name |
| `UPTIMEKUMA_HOST` | Uptime Kuma hostname (planned) |
| `UPTIMEKUMA_PORT` | Uptime Kuma port (planned) |
| `SEARXNG_HOST` | SearXNG hostname (use `searxng` for Docker) |
| `SEARXNG_PORT` | SearXNG port (typically `8080`) |
| `SEARXNG_SECRET_KEY` | SearXNG secret key (random hex string) |
| `WALLABAG_HOST` | Wallabag hostname (planned) |
| `WALLABAG_PORT` | Wallabag port (planned) |
| `QUARTZ_HOST` | Quartz hostname (use `quartz` for Docker) |
| `QUARTZ_PORT` | Quartz port (typically `8080`) |
| `GITEA_RUNNER_HOST` | Gitea runner hostname |
| `GITEA_RUNNER_NAME` | Gitea runner display name |
| `GITEA_RUNNER_REGISTRATION_TOKEN` | Token to register Gitea Actions runner |
| `BACKEND_PORT` | Backend port (typically `8080`) |
| `BACKEND_HOST` | Backend hostname (use `backend` for Docker) |
| `BACKEND_SECRET` | JWT signing secret |
| `BACKEND_ENDPOINT` | API path prefix (typically `/api`) |
| `OBSIDIAN_DIR` | Absolute path to Obsidian vault on host machine |
| `SPOTIFY_CLIENT_ID` | Spotify app client ID |
| `SPOTIFY_CLIENT_SECRET` | Spotify app client secret |
| `SPOTIFY_REDIRECT_URI` | Spotify OAuth redirect (e.g. `https://www.<DOMAIN>/api/spotify/callback`) |
| `SPOTIFY_AUTH_STATE` | Arbitrary state string for Spotify OAuth |
| `ICECAST_SOURCE_PASSWORD` | Icecast source connection password |
| `ICECAST_RELAY_PASSWORD` | Icecast relay password |
| `ICECAST_ADMIN_USER` | Icecast admin username |
| `ICECAST_ADMIN_PASSWORD` | Icecast admin password |
| `ICECAST_HOST` | Icecast hostname (use `icecast` for Docker) |
| `ICECAST_PORT` | Icecast port (typically `8000`) |
| `ICECAST_MOUNT` | Icecast mount point (e.g. `/stream`) |
| `LIQUIDSOAP_HARBOR_MOUNT` | Liquidsoap live input mount (e.g. `/live`) |
| `LIQUIDSOAP_HARBOR_PORT` | Liquidsoap harbor port (e.g. `8005`) |
| `DOMAIN` | Production domain name |
| `EMAIL` | Email for Let's Encrypt registration |
| `CLAUDE_API_KEY` | Anthropic Claude API key |
| `STEAM_API_KEY` | Steam Web API key |
| `STEAM_ID` | Steam user ID |
| `HASURA_GRAPHQL_ADMIN_SECRET` | Hasura admin secret |
| `HASURA_HOST` | Hasura hostname (use `hasura` for Docker) |
| `HASURA_PORT` | Hasura port (typically `8080`) |
| `SEED_DB` | Set to `true` to seed test data on startup |
Gitea application config. Copy from the template and fill in secrets: ### Gitea Config
Copy from the template and fill in secrets:
```sh ```sh
cp gitea/config/app.ini.template gitea/config/app.ini cp gitea/config/app.ini.template gitea/config/app.ini
``` ```
Populate `LFS_JWT_SECRET`, `SECRET_KEY`, `INTERNAL_TOKEN`, `JWT_SECRET`, and the database `PASSWD`. Populate `LFS_JWT_SECRET`, `SECRET_KEY`, `INTERNAL_TOKEN`, `JWT_SECRET`, and the database `PASSWD`. Alternatively, the Gitea entrypoint generates `app.ini` from the template using environment variables.
### `searxng/settings.yml` ### SearXNG Config
SearXNG settings. Copy from the template: Copy from the template:
```sh ```sh
cp searxng/settings.yml.template searxng/settings.yml cp searxng/settings.yml.template searxng/settings.yml
``` ```
The template uses environment variable substitution (`${BASE_URL}`, `${SEARXNG_SECRET_KEY}`) at container build time, so this file is generated by the Dockerfile's `entrypoint.sh`. If running outside Docker, fill in values manually. The Docker entrypoint handles environment variable substitution (`${BASE_URL}`, `${SEARXNG_SECRET_KEY}`) automatically, so manual setup is only needed when running outside Docker.
### `certbot/conf/` and `certbot/www/` ### Spotify Token Setup
Let's Encrypt certificate storage. In production, certbot populates these automatically on first run. For local/dev use, either: 4. After authorization, Spotify redirects to the callback endpoint which stores tokens at `/backend/token/spotify_token.json`
5. Tokens are refreshed automatically; the file persists across container restarts via volume mount
- Use dev mode (`docker-compose.dev.yml`) which skips SSL, or ### Obsidian Notes Setup
- Place self-signed certs in `certbot/conf/live/localhost/` (`fullchain.pem`, `privkey.pem`).
### `backend/token/` 1. Set `OBSIDIAN_DIR` in `.env` to the absolute path of your Obsidian vault on the host machine
2. The vault is mounted read-only into the Quartz container at `/quartz/content`
3. Quartz builds a static site from the vault on startup and serves it at `/notes/`
4. The backend also mounts the vault at `/backend/notes` (legacy, see deprecated endpoints above)
Directory where the backend persists Spotify OAuth tokens (`spotify_token.json`). Created automatically at runtime — no manual setup needed, but the directory is git-ignored so it won't exist on a fresh clone. Docker mounts `./backend/token/:/backend/token` so the directory is created by Docker. ### SSL Certificates (Certbot)
### `icecast2/fallback_music/` **Initial setup (production):**
MP3 files used as fallback music for the Icecast2/Liquidsoap radio stream. Place at least one `.mp3` file here. A `.gitkeep` is tracked to preserve the directory. 1. Set `DOMAIN` and `EMAIL` in `.env`
2. On first run, Nginx starts with `nginx_setup.conf.template` which only serves the ACME challenge route at `/.well-known/acme-challenge/`
3. Certbot requests a certificate via `certbot certonly --webroot`
4. Once the certificate is issued to `certbot/conf/live/<DOMAIN>/`, restart Nginx — it will detect the certs and switch to the full `nginx.conf.template`
5. Certbot checks for renewal every 12 hours automatically
### `gitea-runner/act_runner` **Dev mode:** Nginx generates a self-signed certificate for localhost automatically.
The Gitea Actions runner binary. Download from [Gitea's releases](https://gitea.com/gitea/act_runner/releases) for your platform and place in `gitea-runner/`. ### Icecast Radio
### `gitea-runner/.runner` Place at least one `.mp3` file in `icecast2/fallback_music/`. Liquidsoap plays these as fallback when no live source is connected. Connect a live source to port 8001 on the `LIQUIDSOAP_HARBOR_MOUNT`.
Runner registration state file. Generated automatically when `gitea-runner/run.sh` runs for the first time (requires `GITEA_RUNNER_REGISTRATION_TOKEN` in `.env`). ### Gitea Runner
1. Download the `act_runner` binary from [Gitea releases](https://gitea.com/gitea/act_runner/releases) and place in `gitea-runner/`
2. Set `GITEA_RUNNER_REGISTRATION_TOKEN` in `.env`
3. The runner registers automatically on first startup
## Dev Mode
Run the full stack over plain HTTP without SSL certificates:
```sh
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
```
This:
- Uses an HTTP-only Nginx config with all routing (SPA, backend proxy, radio, gitea, etc.)
- Generates a self-signed certificate for localhost
- Disables certbot
- Seeds the database with test data (`SEED_DB=true`)
- Enables GraphQL playground and introspection
- Enables Hasura console and dev mode
Visit `http://localhost` to test.
### Frontend only (hot reload)
```sh
cd vue && npm run dev
```
Vite dev server proxies `/api` to `localhost:8080`, `/gitea` to `localhost:3000`, `/radio` to `localhost:8000`.
## Untracked Files
These files are git-ignored and must be created manually:
| File | Notes |
| ------------------------------- | --------------------------------------------------------------- |
| `.env` | See setup section above |
| `gitea/config/app.ini` | Copy from `app.ini.template` or let entrypoint generate it |
| `searxng/settings.yml` | Copy from `settings.yml.template` or let entrypoint generate it |
| `certbot/conf/`, `certbot/www/` | Created automatically by certbot; use dev mode to skip |
| `backend/token/` | Created automatically by Docker volume mount |
| `icecast2/fallback_music/*.mp3` | Place at least one MP3 file |
| `gitea-runner/act_runner` | Download from Gitea releases |
| `gitea-runner/.runner` | Generated on first runner startup |
## Future Ideas
- More Rust to WASM
- ML for chatboards
- Cache requests
- Design more webpages
- Calendar to show radio times
- Nice smooth function background and transitions
- Design shrines
- Redis (not really but practical experience)

View File

@@ -22,6 +22,7 @@ require (
github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect

View File

@@ -66,6 +66,15 @@ github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7c
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -388,6 +397,7 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=

View File

@@ -57,3 +57,21 @@ models:
fields: fields:
deletedAt: deletedAt:
resolver: false resolver: false
Bookmark:
model:
- adam-french.co.uk/backend/models.Bookmark
fields:
deletedAt:
resolver: false
JobApplication:
model:
- adam-french.co.uk/backend/models.JobApplication
fields:
deletedAt:
resolver: false
JobAppReference:
model:
- adam-french.co.uk/backend/models.JobAppReference
fields:
deletedAt:
resolver: false

View File

@@ -7,7 +7,9 @@ package graph
import ( import (
"context" "context"
"fmt"
"adam-french.co.uk/backend/graph/model"
"adam-french.co.uk/backend/models" "adam-french.co.uk/backend/models"
) )
@@ -16,6 +18,33 @@ func (r *activityResolver) ID(ctx context.Context, obj *models.Activity) (int, e
return int(obj.ID), nil return int(obj.ID), nil
} }
// CreateActivity is the resolver for the createActivity field.
func (r *mutationResolver) CreateActivity(ctx context.Context, input model.CreateActivityInput) (*models.Activity, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
activity := models.Activity{Type: input.Type, Name: input.Name, Link: input.Link}
if err := r.Store.DB.Create(&activity).Error; err != nil {
return nil, err
}
return &activity, nil
}
// Activities is the resolver for the activities field.
func (r *queryResolver) Activities(ctx context.Context) ([]*models.Activity, error) {
var activities []models.Activity
if err := r.Store.DB.Order("created_at DESC").Find(&activities).Error; err != nil {
return nil, err
}
result := make([]*models.Activity, len(activities))
for i := range activities {
result[i] = &activities[i]
}
return result, nil
}
// Activity returns ActivityResolver implementation. // Activity returns ActivityResolver implementation.
func (r *Resolver) Activity() ActivityResolver { return &activityResolver{r} } func (r *Resolver) Activity() ActivityResolver { return &activityResolver{r} }

View File

@@ -0,0 +1,146 @@
package graph
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.88
import (
"context"
"fmt"
"net/http"
"adam-french.co.uk/backend/graph/model"
"adam-french.co.uk/backend/models"
"golang.org/x/crypto/bcrypt"
)
// Login is the resolver for the login field.
func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (*model.AuthPayload, error) {
gc := GinContextFromCtx(ctx)
if gc == nil {
return nil, fmt.Errorf("could not get gin context")
}
if !r.Store.LoginLimiter.Allow(gc.ClientIP()) {
return nil, fmt.Errorf("too many login attempts, please try again later")
}
var user models.User
if err := r.Store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
return nil, fmt.Errorf("invalid credentials")
}
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil {
return nil, fmt.Errorf("invalid credentials")
}
tokens, err := r.Store.Auth.GenerateJWT(&user)
if err != nil {
return nil, fmt.Errorf("failed to generate tokens")
}
gc.SetSameSite(http.SameSiteLaxMode)
gc.SetCookie(
"access_token",
tokens.AccessToken,
int(r.Store.Auth.Config.AccessTokenLifetime.Seconds()),
"/",
r.Store.Auth.Config.Domain,
true, true,
)
gc.SetCookie(
"refresh_token",
tokens.RefreshToken,
int(r.Store.Auth.Config.RefreshTokenLifetime.Seconds()),
"/",
r.Store.Auth.Config.Domain,
true, true,
)
return &model.AuthPayload{User: &user}, nil
}
// Logout is the resolver for the logout field.
func (r *mutationResolver) Logout(ctx context.Context) (bool, error) {
gc := GinContextFromCtx(ctx)
if gc == nil {
return false, fmt.Errorf("could not get gin context")
}
gc.SetSameSite(http.SameSiteLaxMode)
gc.SetCookie("access_token", "", -1, "/", r.Store.Auth.Config.Domain, true, true)
gc.SetCookie("refresh_token", "", -1, "/", r.Store.Auth.Config.Domain, true, true)
return true, nil
}
// RefreshToken is the resolver for the refreshToken field.
func (r *mutationResolver) RefreshToken(ctx context.Context) (*model.AuthPayload, error) {
gc := GinContextFromCtx(ctx)
if gc == nil {
return nil, fmt.Errorf("could not get gin context")
}
refreshToken, err := gc.Cookie("refresh_token")
if err != nil {
return nil, fmt.Errorf("unauthorized")
}
claims, err := r.Store.Auth.VerifyJWT(refreshToken)
if err != nil {
return nil, fmt.Errorf("unauthorized")
}
userIDF, ok := (*claims)["id"].(float64)
if !ok {
return nil, fmt.Errorf("invalid token claims")
}
var user models.User
user.ID = uint(userIDF)
if err := r.Store.DB.First(&user).Error; err != nil {
return nil, fmt.Errorf("user not found")
}
tokens, err := r.Store.Auth.GenerateJWT(&user)
if err != nil {
return nil, fmt.Errorf("failed to generate tokens")
}
gc.SetSameSite(http.SameSiteLaxMode)
gc.SetCookie(
"access_token",
tokens.AccessToken,
int(r.Store.Auth.Config.AccessTokenLifetime.Seconds()),
"/",
r.Store.Auth.Config.Domain,
true, true,
)
gc.SetCookie(
"refresh_token",
tokens.RefreshToken,
int(r.Store.Auth.Config.RefreshTokenLifetime.Seconds()),
"/",
r.Store.Auth.Config.Domain,
true, true,
)
return &model.AuthPayload{User: &user}, nil
}
// Me is the resolver for the me field.
func (r *queryResolver) Me(ctx context.Context) (*models.User, error) {
userID, ok := UserIDFromCtx(ctx)
if !ok {
return nil, fmt.Errorf("unauthorized")
}
var user models.User
user.ID = userID
if err := r.Store.DB.First(&user).Error; err != nil {
return nil, fmt.Errorf("user not found")
}
return &user, nil
}

View File

@@ -0,0 +1,64 @@
package graph
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.88
import (
"context"
"fmt"
"adam-french.co.uk/backend/graph/model"
"adam-french.co.uk/backend/models"
)
// ID is the resolver for the id field.
func (r *bookmarkResolver) ID(ctx context.Context, obj *models.Bookmark) (int, error) {
return int(obj.ID), nil
}
// CreateBookmark is the resolver for the createBookmark field.
func (r *mutationResolver) CreateBookmark(ctx context.Context, input model.CreateBookmarkInput) (*models.Bookmark, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
bookmark := models.Bookmark{Category: input.Category, Name: input.Name, Link: input.Link}
if err := r.Store.DB.Create(&bookmark).Error; err != nil {
return nil, err
}
return &bookmark, nil
}
// DeleteBookmark is the resolver for the deleteBookmark field.
func (r *mutationResolver) DeleteBookmark(ctx context.Context, id int) (*models.Bookmark, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
var bookmark models.Bookmark
if err := r.Store.DB.First(&bookmark, id).Error; err != nil {
return nil, err
}
if err := r.Store.DB.Delete(&bookmark).Error; err != nil {
return nil, err
}
return &bookmark, nil
}
// Bookmarks is the resolver for the bookmarks field.
func (r *queryResolver) Bookmarks(ctx context.Context) ([]*models.Bookmark, error) {
var bookmarks []models.Bookmark
if err := r.Store.DB.Order("category ASC, created_at ASC").Find(&bookmarks).Error; err != nil {
return nil, err
}
result := make([]*models.Bookmark, len(bookmarks))
for i := range bookmarks {
result[i] = &bookmarks[i]
}
return result, nil
}
// Bookmark returns BookmarkResolver implementation.
func (r *Resolver) Bookmark() BookmarkResolver { return &bookmarkResolver{r} }
type bookmarkResolver struct{ *Resolver }

View File

@@ -7,7 +7,9 @@ package graph
import ( import (
"context" "context"
"fmt"
"adam-french.co.uk/backend/graph/model"
"adam-french.co.uk/backend/models" "adam-french.co.uk/backend/models"
) )
@@ -16,6 +18,33 @@ func (r *favoriteResolver) ID(ctx context.Context, obj *models.Favorite) (int, e
return int(obj.ID), nil return int(obj.ID), nil
} }
// CreateFavorite is the resolver for the createFavorite field.
func (r *mutationResolver) CreateFavorite(ctx context.Context, input model.CreateFavoriteInput) (*models.Favorite, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
favorite := models.Favorite{Type: input.Type, Name: input.Name, Link: input.Link}
if err := r.Store.DB.Create(&favorite).Error; err != nil {
return nil, err
}
return &favorite, nil
}
// Favorites is the resolver for the favorites field.
func (r *queryResolver) Favorites(ctx context.Context) ([]*models.Favorite, error) {
var favorites []models.Favorite
if err := r.Store.DB.Order("created_at DESC").Find(&favorites).Error; err != nil {
return nil, err
}
result := make([]*models.Favorite, len(favorites))
for i := range favorites {
result[i] = &favorites[i]
}
return result, nil
}
// Favorite returns FavoriteResolver implementation. // Favorite returns FavoriteResolver implementation.
func (r *Resolver) Favorite() FavoriteResolver { return &favoriteResolver{r} } func (r *Resolver) Favorite() FavoriteResolver { return &favoriteResolver{r} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,34 @@
package graph
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.88
import (
"context"
"time"
"adam-french.co.uk/backend/graph/model"
"adam-french.co.uk/backend/services"
)
// GiteaFeed is the resolver for the giteaFeed field.
func (r *queryResolver) GiteaFeed(ctx context.Context) (*model.GiteaFeedItem, error) {
if r.Store.GiteaFeedFresh() {
return mapGiteaFeed(r.Store.GiteaFeed), nil
}
feed, err := services.FetchLatestFeed(r.Store.GiteaHost, r.Store.GiteaPort)
if err != nil {
return nil, err
}
if feed == nil {
return nil, nil
}
r.Store.GiteaFeed = feed
r.Store.GiteaFeedFetchedAt = time.Now()
return mapGiteaFeed(feed), nil
}

View File

@@ -0,0 +1,93 @@
package graph
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.88
import (
"context"
"fmt"
"adam-french.co.uk/backend/graph/model"
"adam-french.co.uk/backend/models"
)
// ID is the resolver for the id field.
func (r *jobAppReferenceResolver) ID(ctx context.Context, obj *models.JobAppReference) (int, error) {
return int(obj.ID), nil
}
// CreateJobAppReference is the resolver for the createJobAppReference field.
func (r *mutationResolver) CreateJobAppReference(ctx context.Context, input model.CreateJobAppReferenceInput) (*models.JobAppReference, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
ref := models.JobAppReference{
Category: input.Category,
Label: input.Label,
Value: input.Value,
}
if input.SortOrder != nil {
ref.SortOrder = *input.SortOrder
}
if err := r.Store.DB.Create(&ref).Error; err != nil {
return nil, err
}
return &ref, nil
}
// UpdateJobAppReference is the resolver for the updateJobAppReference field.
func (r *mutationResolver) UpdateJobAppReference(ctx context.Context, id int, input model.UpdateJobAppReferenceInput) (*models.JobAppReference, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
var ref models.JobAppReference
if err := r.Store.DB.First(&ref, id).Error; err != nil {
return nil, err
}
if input.Category != nil {
ref.Category = *input.Category
}
if input.Label != nil {
ref.Label = *input.Label
}
if input.Value != nil {
ref.Value = *input.Value
}
if input.SortOrder != nil {
ref.SortOrder = *input.SortOrder
}
if err := r.Store.DB.Save(&ref).Error; err != nil {
return nil, err
}
return &ref, nil
}
// DeleteJobAppReference is the resolver for the deleteJobAppReference field.
func (r *mutationResolver) DeleteJobAppReference(ctx context.Context, id int) (bool, error) {
if !IsAdminFromCtx(ctx) {
return false, fmt.Errorf("admin access required")
}
if err := r.Store.DB.Delete(&models.JobAppReference{}, id).Error; err != nil {
return false, err
}
return true, nil
}
// JobAppReferences is the resolver for the jobAppReferences field.
func (r *queryResolver) JobAppReferences(ctx context.Context) ([]*models.JobAppReference, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
var refs []*models.JobAppReference
if err := r.Store.DB.Order("category ASC, sort_order ASC, created_at ASC").Find(&refs).Error; err != nil {
return nil, err
}
return refs, nil
}
// JobAppReference returns JobAppReferenceResolver implementation.
func (r *Resolver) JobAppReference() JobAppReferenceResolver { return &jobAppReferenceResolver{r} }
type jobAppReferenceResolver struct{ *Resolver }

View File

@@ -0,0 +1,115 @@
package graph
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.88
import (
"context"
"fmt"
"adam-french.co.uk/backend/graph/model"
"adam-french.co.uk/backend/models"
)
// ID is the resolver for the id field.
func (r *jobApplicationResolver) ID(ctx context.Context, obj *models.JobApplication) (int, error) {
return int(obj.ID), nil
}
// CreateJobApplication is the resolver for the createJobApplication field.
func (r *mutationResolver) CreateJobApplication(ctx context.Context, input model.CreateJobApplicationInput) (*models.JobApplication, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
app := models.JobApplication{
JobTitle: input.JobTitle,
Company: input.Company,
Location: input.Location,
URL: input.URL,
Status: input.Status,
Notes: input.Notes,
AppliedAt: input.AppliedAt,
}
if err := r.Store.DB.Create(&app).Error; err != nil {
return nil, err
}
return &app, nil
}
// UpdateJobApplication is the resolver for the updateJobApplication field.
func (r *mutationResolver) UpdateJobApplication(ctx context.Context, id int, input model.UpdateJobApplicationInput) (*models.JobApplication, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
var app models.JobApplication
if err := r.Store.DB.First(&app, id).Error; err != nil {
return nil, err
}
if input.JobTitle != nil {
app.JobTitle = *input.JobTitle
}
if input.Company != nil {
app.Company = *input.Company
}
if input.Location != nil {
app.Location = input.Location
}
if input.URL != nil {
app.URL = input.URL
}
if input.Status != nil {
app.Status = *input.Status
}
if input.Notes != nil {
app.Notes = input.Notes
}
if input.AppliedAt != nil {
app.AppliedAt = input.AppliedAt
}
if err := r.Store.DB.Save(&app).Error; err != nil {
return nil, err
}
return &app, nil
}
// DeleteJobApplication is the resolver for the deleteJobApplication field.
func (r *mutationResolver) DeleteJobApplication(ctx context.Context, id int) (bool, error) {
if !IsAdminFromCtx(ctx) {
return false, fmt.Errorf("admin access required")
}
if err := r.Store.DB.Delete(&models.JobApplication{}, id).Error; err != nil {
return false, err
}
return true, nil
}
// JobApplications is the resolver for the jobApplications field.
func (r *queryResolver) JobApplications(ctx context.Context) ([]*models.JobApplication, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
var apps []*models.JobApplication
if err := r.Store.DB.Order("created_at desc").Find(&apps).Error; err != nil {
return nil, err
}
return apps, nil
}
// JobApplication is the resolver for the jobApplication field.
func (r *queryResolver) JobApplication(ctx context.Context, id int) (*models.JobApplication, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
var app models.JobApplication
if err := r.Store.DB.First(&app, id).Error; err != nil {
return nil, err
}
return &app, nil
}
// JobApplication returns JobApplicationResolver implementation.
func (r *Resolver) JobApplication() JobApplicationResolver { return &jobApplicationResolver{r} }
type jobApplicationResolver struct{ *Resolver }

View File

@@ -21,6 +21,19 @@ func (r *messageResolver) AuthorID(ctx context.Context, obj *models.Message) (in
return int(obj.AuthorID), nil return int(obj.AuthorID), nil
} }
// Messages is the resolver for the messages field.
func (r *queryResolver) Messages(ctx context.Context) ([]*models.Message, error) {
var messages []models.Message
if err := r.Store.DB.Order("created_at DESC").Find(&messages).Error; err != nil {
return nil, err
}
result := make([]*models.Message, len(messages))
for i := range messages {
result[i] = &messages[i]
}
return result, nil
}
// Message returns MessageResolver implementation. // Message returns MessageResolver implementation.
func (r *Resolver) Message() MessageResolver { return &messageResolver{r} } func (r *Resolver) Message() MessageResolver { return &messageResolver{r} }

View File

@@ -18,12 +18,35 @@ type CreateActivityInput struct {
Link *string `json:"link,omitempty"` Link *string `json:"link,omitempty"`
} }
type CreateBookmarkInput struct {
Category string `json:"category"`
Name string `json:"name"`
Link string `json:"link"`
}
type CreateFavoriteInput struct { type CreateFavoriteInput struct {
Type string `json:"type"` Type string `json:"type"`
Name string `json:"name"` Name string `json:"name"`
Link *string `json:"link,omitempty"` Link *string `json:"link,omitempty"`
} }
type CreateJobAppReferenceInput struct {
Category string `json:"category"`
Label string `json:"label"`
Value string `json:"value"`
SortOrder *int `json:"sortOrder,omitempty"`
}
type CreateJobApplicationInput struct {
JobTitle string `json:"jobTitle"`
Company string `json:"company"`
Location *string `json:"location,omitempty"`
URL *string `json:"url,omitempty"`
Status string `json:"status"`
Notes *string `json:"notes,omitempty"`
AppliedAt *time.Time `json:"appliedAt,omitempty"`
}
type CreatePostInput struct { type CreatePostInput struct {
Title string `json:"title"` Title string `json:"title"`
Content string `json:"content"` Content string `json:"content"`
@@ -96,6 +119,23 @@ type SteamStatus struct {
RecentGames []*SteamGame `json:"recentGames"` RecentGames []*SteamGame `json:"recentGames"`
} }
type UpdateJobAppReferenceInput struct {
Category *string `json:"category,omitempty"`
Label *string `json:"label,omitempty"`
Value *string `json:"value,omitempty"`
SortOrder *int `json:"sortOrder,omitempty"`
}
type UpdateJobApplicationInput struct {
JobTitle *string `json:"jobTitle,omitempty"`
Company *string `json:"company,omitempty"`
Location *string `json:"location,omitempty"`
URL *string `json:"url,omitempty"`
Status *string `json:"status,omitempty"`
Notes *string `json:"notes,omitempty"`
AppliedAt *time.Time `json:"appliedAt,omitempty"`
}
type UpdatePostInput struct { type UpdatePostInput struct {
Title string `json:"title"` Title string `json:"title"`
Content string `json:"content"` Content string `json:"content"`

View File

@@ -7,15 +7,111 @@ package graph
import ( import (
"context" "context"
"fmt"
"adam-french.co.uk/backend/graph/model"
"adam-french.co.uk/backend/models" "adam-french.co.uk/backend/models"
) )
// CreatePost is the resolver for the createPost field.
func (r *mutationResolver) CreatePost(ctx context.Context, input model.CreatePostInput) (*models.Post, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
userID, ok := UserIDFromCtx(ctx)
if !ok {
return nil, fmt.Errorf("unauthorized")
}
post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID}
if err := r.Store.DB.Create(&post).Error; err != nil {
return nil, err
}
return &post, nil
}
// UpdatePost is the resolver for the updatePost field.
func (r *mutationResolver) UpdatePost(ctx context.Context, id int, input model.UpdatePostInput) (*models.Post, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
userID, ok := UserIDFromCtx(ctx)
if !ok {
return nil, fmt.Errorf("unauthorized")
}
var post models.Post
if err := r.Store.DB.First(&post, id).Error; err != nil {
return nil, fmt.Errorf("post not found")
}
if post.AuthorID != userID {
return nil, fmt.Errorf("you can only update your own posts")
}
post.Title = input.Title
post.Content = input.Content
if err := r.Store.DB.Save(&post).Error; err != nil {
return nil, err
}
return &post, nil
}
// DeletePost is the resolver for the deletePost field.
func (r *mutationResolver) DeletePost(ctx context.Context, id int) (*models.Post, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
userID, ok := UserIDFromCtx(ctx)
if !ok {
return nil, fmt.Errorf("unauthorized")
}
var post models.Post
if err := r.Store.DB.First(&post, id).Error; err != nil {
return nil, fmt.Errorf("post not found")
}
if post.AuthorID != userID {
return nil, fmt.Errorf("you can only delete your own posts")
}
r.Store.DB.Delete(&post)
return &post, nil
}
// ID is the resolver for the id field. // ID is the resolver for the id field.
func (r *postResolver) ID(ctx context.Context, obj *models.Post) (int, error) { func (r *postResolver) ID(ctx context.Context, obj *models.Post) (int, error) {
return int(obj.ID), nil return int(obj.ID), nil
} }
// Posts is the resolver for the posts field.
func (r *queryResolver) Posts(ctx context.Context) ([]*models.Post, error) {
var posts []models.Post
if err := r.Store.DB.Preload("Author").Order("created_at DESC").Find(&posts).Error; err != nil {
return nil, err
}
result := make([]*models.Post, len(posts))
for i := range posts {
result[i] = &posts[i]
}
return result, nil
}
// Post is the resolver for the post field.
func (r *queryResolver) Post(ctx context.Context, id int) (*models.Post, error) {
var post models.Post
if err := r.Store.DB.Preload("Author").First(&post, id).Error; err != nil {
return nil, fmt.Errorf("post not found")
}
return &post, nil
}
// Post returns PostResolver implementation. // Post returns PostResolver implementation.
func (r *Resolver) Post() PostResolver { return &postResolver{r} } func (r *Resolver) Post() PostResolver { return &postResolver{r} }

View File

@@ -11,6 +11,19 @@ import (
"adam-french.co.uk/backend/models" "adam-french.co.uk/backend/models"
) )
// RowingSessions is the resolver for the rowingSessions field.
func (r *queryResolver) RowingSessions(ctx context.Context) ([]*models.Rowing, error) {
var rows []models.Rowing
if err := r.Store.DB.Order("created_at DESC").Find(&rows).Error; err != nil {
return nil, err
}
result := make([]*models.Rowing, len(rows))
for i := range rows {
result[i] = &rows[i]
}
return result, nil
}
// ID is the resolver for the id field. // ID is the resolver for the id field.
func (r *rowingResolver) ID(ctx context.Context, obj *models.Rowing) (int, error) { func (r *rowingResolver) ID(ctx context.Context, obj *models.Rowing) (int, error) {
return int(obj.ID), nil return int(obj.ID), nil

View File

@@ -5,505 +5,6 @@ package graph
// will be copied through when generating and any unknown code will be moved to the end. // will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.88 // Code generated by github.com/99designs/gqlgen version v0.17.88
import (
"context"
"fmt"
"net/http"
"time"
"adam-french.co.uk/backend/graph/model"
"adam-french.co.uk/backend/models"
"adam-french.co.uk/backend/services"
spotify "github.com/zmb3/spotify/v2"
"golang.org/x/crypto/bcrypt"
)
// Login is the resolver for the login field.
func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (*model.AuthPayload, error) {
gc := GinContextFromCtx(ctx)
if gc == nil {
return nil, fmt.Errorf("could not get gin context")
}
var user models.User
if err := r.Store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
return nil, fmt.Errorf("invalid credentials")
}
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil {
return nil, fmt.Errorf("invalid credentials")
}
tokens, err := r.Store.Auth.GenerateJWT(&user)
if err != nil {
return nil, fmt.Errorf("failed to generate tokens")
}
gc.SetSameSite(http.SameSiteLaxMode)
gc.SetCookie(
"access_token",
tokens.AccessToken,
int(r.Store.Auth.Config.AccessTokenLifetime.Seconds()),
r.Store.Auth.Config.Endpoint,
r.Store.Auth.Config.Domain,
true, true,
)
gc.SetCookie(
"refresh_token",
tokens.RefreshToken,
int(r.Store.Auth.Config.RefreshTokenLifetime.Seconds()),
r.Store.Auth.Config.Endpoint,
r.Store.Auth.Config.Domain,
true, true,
)
return &model.AuthPayload{User: &user}, nil
}
// Logout is the resolver for the logout field.
func (r *mutationResolver) Logout(ctx context.Context) (bool, error) {
gc := GinContextFromCtx(ctx)
if gc == nil {
return false, fmt.Errorf("could not get gin context")
}
gc.SetSameSite(http.SameSiteLaxMode)
gc.SetCookie("access_token", "", -1, "", "", true, true)
gc.SetCookie("refresh_token", "", -1, "", "", true, true)
return true, nil
}
// RefreshToken is the resolver for the refreshToken field.
func (r *mutationResolver) RefreshToken(ctx context.Context) (*model.AuthPayload, error) {
gc := GinContextFromCtx(ctx)
if gc == nil {
return nil, fmt.Errorf("could not get gin context")
}
refreshToken, err := gc.Cookie("refresh_token")
if err != nil {
return nil, fmt.Errorf("unauthorized")
}
claims, err := r.Store.Auth.VerifyJWT(refreshToken)
if err != nil {
return nil, fmt.Errorf("unauthorized")
}
userIDF, ok := (*claims)["id"].(float64)
if !ok {
return nil, fmt.Errorf("invalid token claims")
}
var user models.User
user.ID = uint(userIDF)
if err := r.Store.DB.First(&user).Error; err != nil {
return nil, fmt.Errorf("user not found")
}
tokens, err := r.Store.Auth.GenerateJWT(&user)
if err != nil {
return nil, fmt.Errorf("failed to generate tokens")
}
gc.SetSameSite(http.SameSiteLaxMode)
gc.SetCookie(
"access_token",
tokens.AccessToken,
int(r.Store.Auth.Config.AccessTokenLifetime.Seconds()),
r.Store.Auth.Config.Endpoint,
r.Store.Auth.Config.Domain,
true, true,
)
gc.SetCookie(
"refresh_token",
tokens.RefreshToken,
int(r.Store.Auth.Config.RefreshTokenLifetime.Seconds()),
r.Store.Auth.Config.Endpoint,
r.Store.Auth.Config.Domain,
true, true,
)
return &model.AuthPayload{User: &user}, nil
}
// CreatePost is the resolver for the createPost field.
func (r *mutationResolver) CreatePost(ctx context.Context, input model.CreatePostInput) (*models.Post, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
userID, ok := UserIDFromCtx(ctx)
if !ok {
return nil, fmt.Errorf("unauthorized")
}
post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID}
if err := r.Store.DB.Create(&post).Error; err != nil {
return nil, err
}
return &post, nil
}
// UpdatePost is the resolver for the updatePost field.
func (r *mutationResolver) UpdatePost(ctx context.Context, id int, input model.UpdatePostInput) (*models.Post, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
userID, ok := UserIDFromCtx(ctx)
if !ok {
return nil, fmt.Errorf("unauthorized")
}
var post models.Post
if err := r.Store.DB.First(&post, id).Error; err != nil {
return nil, fmt.Errorf("post not found")
}
if post.AuthorID != userID {
return nil, fmt.Errorf("you can only update your own posts")
}
post.Title = input.Title
post.Content = input.Content
if err := r.Store.DB.Save(&post).Error; err != nil {
return nil, err
}
return &post, nil
}
// DeletePost is the resolver for the deletePost field.
func (r *mutationResolver) DeletePost(ctx context.Context, id int) (*models.Post, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
userID, ok := UserIDFromCtx(ctx)
if !ok {
return nil, fmt.Errorf("unauthorized")
}
var post models.Post
if err := r.Store.DB.First(&post, id).Error; err != nil {
return nil, fmt.Errorf("post not found")
}
if post.AuthorID != userID {
return nil, fmt.Errorf("you can only delete your own posts")
}
r.Store.DB.Delete(&post)
return &post, nil
}
// CreateUser is the resolver for the createUser field.
func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*models.User, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
user := models.User{Username: input.Username, Password: hashedPassword}
if err := r.Store.DB.Create(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// DeleteUser is the resolver for the deleteUser field.
func (r *mutationResolver) DeleteUser(ctx context.Context, id int) (*models.User, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
var user models.User
if err := r.Store.DB.First(&user, id).Error; err != nil {
return nil, fmt.Errorf("user not found")
}
if err := r.Store.DB.Delete(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// SetUserAdmin is the resolver for the setUserAdmin field.
func (r *mutationResolver) SetUserAdmin(ctx context.Context, id int, admin bool) (*models.User, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
callerID, ok := UserIDFromCtx(ctx)
if !ok {
return nil, fmt.Errorf("unauthorized")
}
if uint(id) == callerID {
return nil, fmt.Errorf("cannot change your own admin status")
}
var user models.User
if err := r.Store.DB.First(&user, id).Error; err != nil {
return nil, fmt.Errorf("user not found")
}
user.Admin = admin
if err := r.Store.DB.Save(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// CreateFavorite is the resolver for the createFavorite field.
func (r *mutationResolver) CreateFavorite(ctx context.Context, input model.CreateFavoriteInput) (*models.Favorite, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
favorite := models.Favorite{Type: input.Type, Name: input.Name, Link: input.Link}
if err := r.Store.DB.Create(&favorite).Error; err != nil {
return nil, err
}
return &favorite, nil
}
// CreateActivity is the resolver for the createActivity field.
func (r *mutationResolver) CreateActivity(ctx context.Context, input model.CreateActivityInput) (*models.Activity, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
activity := models.Activity{Type: input.Type, Name: input.Name, Link: input.Link}
if err := r.Store.DB.Create(&activity).Error; err != nil {
return nil, err
}
return &activity, nil
}
// Users is the resolver for the users field.
func (r *queryResolver) Users(ctx context.Context) ([]*models.User, error) {
var users []models.User
if err := r.Store.DB.Find(&users).Error; err != nil {
return nil, err
}
result := make([]*models.User, len(users))
for i := range users {
result[i] = &users[i]
}
return result, nil
}
// User is the resolver for the user field.
func (r *queryResolver) User(ctx context.Context, id int) (*models.User, error) {
var user models.User
if err := r.Store.DB.First(&user, id).Error; err != nil {
return nil, fmt.Errorf("user not found")
}
return &user, nil
}
// Posts is the resolver for the posts field.
func (r *queryResolver) Posts(ctx context.Context) ([]*models.Post, error) {
var posts []models.Post
if err := r.Store.DB.Preload("Author").Order("created_at DESC").Find(&posts).Error; err != nil {
return nil, err
}
result := make([]*models.Post, len(posts))
for i := range posts {
result[i] = &posts[i]
}
return result, nil
}
// Post is the resolver for the post field.
func (r *queryResolver) Post(ctx context.Context, id int) (*models.Post, error) {
var post models.Post
if err := r.Store.DB.Preload("Author").First(&post, id).Error; err != nil {
return nil, fmt.Errorf("post not found")
}
return &post, nil
}
// Activities is the resolver for the activities field.
func (r *queryResolver) Activities(ctx context.Context) ([]*models.Activity, error) {
var activities []models.Activity
if err := r.Store.DB.Order("created_at DESC").Find(&activities).Error; err != nil {
return nil, err
}
result := make([]*models.Activity, len(activities))
for i := range activities {
result[i] = &activities[i]
}
return result, nil
}
// Favorites is the resolver for the favorites field.
func (r *queryResolver) Favorites(ctx context.Context) ([]*models.Favorite, error) {
var favorites []models.Favorite
if err := r.Store.DB.Order("created_at DESC").Find(&favorites).Error; err != nil {
return nil, err
}
result := make([]*models.Favorite, len(favorites))
for i := range favorites {
result[i] = &favorites[i]
}
return result, nil
}
// RowingSessions is the resolver for the rowingSessions field.
func (r *queryResolver) RowingSessions(ctx context.Context) ([]*models.Rowing, error) {
var rows []models.Rowing
if err := r.Store.DB.Order("created_at DESC").Find(&rows).Error; err != nil {
return nil, err
}
result := make([]*models.Rowing, len(rows))
for i := range rows {
result[i] = &rows[i]
}
return result, nil
}
// Messages is the resolver for the messages field.
func (r *queryResolver) Messages(ctx context.Context) ([]*models.Message, error) {
var messages []models.Message
if err := r.Store.DB.Order("created_at DESC").Find(&messages).Error; err != nil {
return nil, err
}
result := make([]*models.Message, len(messages))
for i := range messages {
result[i] = &messages[i]
}
return result, nil
}
// SpotifyListening is the resolver for the spotifyListening field.
func (r *queryResolver) SpotifyListening(ctx context.Context) (*model.SpotifyPlaying, error) {
if r.Store.SpotifyClient == nil {
return nil, nil
}
playing, err := r.Store.SpotifyClient.PlayerCurrentlyPlaying(ctx)
if err != nil {
return nil, err
}
result := &model.SpotifyPlaying{Playing: playing.Playing}
if playing.Item != nil {
result.Track = mapSpotifyTrack(playing.Item)
}
return result, nil
}
// SpotifyRecent is the resolver for the spotifyRecent field.
func (r *queryResolver) SpotifyRecent(ctx context.Context) ([]*model.SpotifyRecentItem, error) {
if r.Store.SpotifyClient == nil {
return []*model.SpotifyRecentItem{}, nil
}
if r.Store.RecentSongsFresh() {
return mapRecentItems(*r.Store.RecentSongs), nil
}
opts := spotify.RecentlyPlayedOptions{Limit: 3}
played, err := r.Store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts)
if err != nil {
return nil, err
}
r.Store.RecentSongs = &played
r.Store.RecentSongsFetchedAt = time.Now()
return mapRecentItems(played), nil
}
// GiteaFeed is the resolver for the giteaFeed field.
func (r *queryResolver) GiteaFeed(ctx context.Context) (*model.GiteaFeedItem, error) {
if r.Store.GiteaFeedFresh() {
return mapGiteaFeed(r.Store.GiteaFeed), nil
}
feed, err := services.FetchLatestFeed(r.Store.GiteaHost, r.Store.GiteaPort)
if err != nil {
return nil, err
}
if feed == nil {
return nil, nil
}
r.Store.GiteaFeed = feed
r.Store.GiteaFeedFetchedAt = time.Now()
return mapGiteaFeed(feed), nil
}
// SteamStatus is the resolver for the steamStatus field.
func (r *queryResolver) SteamStatus(ctx context.Context) (*model.SteamStatus, error) {
if r.Store.SteamAPIKey == "" {
return nil, nil
}
if r.Store.SteamFresh() {
return &model.SteamStatus{
Online: r.Store.SteamOnline,
RecentGames: mapSteamGames(r.Store.SteamRecentGames),
}, nil
}
games, err := services.FetchRecentlyPlayedGames(r.Store.SteamAPIKey, r.Store.SteamID)
if err != nil {
return nil, err
}
summary, err := services.FetchPlayerSummary(r.Store.SteamAPIKey, r.Store.SteamID)
if err != nil {
return nil, err
}
online := false
if summary != nil {
online = summary.PersonaState > 0
}
r.Store.SteamRecentGames = games
r.Store.SteamOnline = online
r.Store.SteamFetchedAt = time.Now()
return &model.SteamStatus{
Online: online,
RecentGames: mapSteamGames(games),
}, nil
}
// Me is the resolver for the me field.
func (r *queryResolver) Me(ctx context.Context) (*models.User, error) {
userID, ok := UserIDFromCtx(ctx)
if !ok {
return nil, fmt.Errorf("unauthorized")
}
var user models.User
user.ID = userID
if err := r.Store.DB.First(&user).Error; err != nil {
return nil, fmt.Errorf("user not found")
}
return &user, nil
}
// Mutation returns MutationResolver implementation. // Mutation returns MutationResolver implementation.
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} } func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }

View File

@@ -12,3 +12,11 @@ input CreateActivityInput {
name: String! name: String!
link: String link: String
} }
extend type Query {
activities: [Activity!]!
}
extend type Mutation {
createActivity(input: CreateActivityInput!): Activity!
}

View File

@@ -6,3 +6,13 @@ input LoginInput {
type AuthPayload { type AuthPayload {
user: User! user: User!
} }
extend type Query {
me: User
}
extend type Mutation {
login(input: LoginInput!): AuthPayload!
logout: Boolean!
refreshToken: AuthPayload!
}

View File

@@ -0,0 +1,23 @@
type Bookmark {
id: ID!
createdAt: Time!
updatedAt: Time!
category: String!
name: String!
link: String!
}
input CreateBookmarkInput {
category: String!
name: String!
link: String!
}
extend type Query {
bookmarks: [Bookmark!]!
}
extend type Mutation {
createBookmark(input: CreateBookmarkInput!): Bookmark!
deleteBookmark(id: ID!): Bookmark!
}

View File

@@ -12,3 +12,11 @@ input CreateFavoriteInput {
name: String! name: String!
link: String link: String
} }
extend type Query {
favorites: [Favorite!]!
}
extend type Mutation {
createFavorite(input: CreateFavoriteInput!): Favorite!
}

View File

@@ -6,3 +6,7 @@ type GiteaFeedItem {
commitMessage: String! commitMessage: String!
createdAt: Time! createdAt: Time!
} }
extend type Query {
giteaFeed: GiteaFeedItem
}

View File

@@ -0,0 +1,33 @@
type JobAppReference {
id: ID!
createdAt: Time!
updatedAt: Time!
category: String!
label: String!
value: String!
sortOrder: Int!
}
input CreateJobAppReferenceInput {
category: String!
label: String!
value: String!
sortOrder: Int
}
input UpdateJobAppReferenceInput {
category: String
label: String
value: String
sortOrder: Int
}
extend type Query {
jobAppReferences: [JobAppReference!]!
}
extend type Mutation {
createJobAppReference(input: CreateJobAppReferenceInput!): JobAppReference!
updateJobAppReference(id: ID!, input: UpdateJobAppReferenceInput!): JobAppReference!
deleteJobAppReference(id: ID!): Boolean!
}

View File

@@ -0,0 +1,43 @@
type JobApplication {
id: ID!
createdAt: Time!
updatedAt: Time!
jobTitle: String!
company: String!
location: String
url: String
status: String!
notes: String
appliedAt: Time
}
input CreateJobApplicationInput {
jobTitle: String!
company: String!
location: String
url: String
status: String!
notes: String
appliedAt: Time
}
input UpdateJobApplicationInput {
jobTitle: String
company: String
location: String
url: String
status: String
notes: String
appliedAt: Time
}
extend type Query {
jobApplications: [JobApplication!]!
jobApplication(id: ID!): JobApplication
}
extend type Mutation {
createJobApplication(input: CreateJobApplicationInput!): JobApplication!
updateJobApplication(id: ID!, input: UpdateJobApplicationInput!): JobApplication!
deleteJobApplication(id: ID!): Boolean!
}

View File

@@ -5,3 +5,7 @@ type Message {
fileUrl: String fileUrl: String
createdAt: Time! createdAt: Time!
} }
extend type Query {
messages: [Message!]!
}

View File

@@ -16,3 +16,14 @@ input UpdatePostInput {
title: String! title: String!
content: String! content: String!
} }
extend type Query {
posts: [Post!]!
post(id: ID!): Post
}
extend type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Post!
}

View File

@@ -7,3 +7,7 @@ type Rowing {
timePer500m: Float! timePer500m: Float!
calories: Float! calories: Float!
} }
extend type Query {
rowingSessions: [Rowing!]!
}

View File

@@ -1,31 +1,4 @@
scalar Time scalar Time
type Query { type Query
users: [User!]! type Mutation
user(id: ID!): User
posts: [Post!]!
post(id: ID!): Post
activities: [Activity!]!
favorites: [Favorite!]!
rowingSessions: [Rowing!]!
messages: [Message!]!
spotifyListening: SpotifyPlaying
spotifyRecent: [SpotifyRecentItem!]
giteaFeed: GiteaFeedItem
steamStatus: SteamStatus
me: User
}
type Mutation {
login(input: LoginInput!): AuthPayload!
logout: Boolean!
refreshToken: AuthPayload!
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Post!
createUser(input: CreateUserInput!): User!
deleteUser(id: ID!): User!
setUserAdmin(id: ID!, admin: Boolean!): User!
createFavorite(input: CreateFavoriteInput!): Favorite!
createActivity(input: CreateActivityInput!): Activity!
}

View File

@@ -26,3 +26,8 @@ type SpotifyRecentItem {
track: SpotifyTrack! track: SpotifyTrack!
playedAt: Time! playedAt: Time!
} }
extend type Query {
spotifyListening: SpotifyPlaying
spotifyRecent: [SpotifyRecentItem!]
}

View File

@@ -10,3 +10,7 @@ type SteamStatus {
online: Boolean! online: Boolean!
recentGames: [SteamGame!]! recentGames: [SteamGame!]!
} }
extend type Query {
steamStatus: SteamStatus
}

View File

@@ -10,3 +10,14 @@ input CreateUserInput {
username: String! username: String!
password: String! password: String!
} }
extend type Query {
users: [User!]!
user(id: ID!): User
}
extend type Mutation {
createUser(input: CreateUserInput!): User!
deleteUser(id: ID!): User!
setUserAdmin(id: ID!, admin: Boolean!): User!
}

View File

@@ -0,0 +1,55 @@
package graph
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.88
import (
"context"
"time"
"adam-french.co.uk/backend/graph/model"
spotify "github.com/zmb3/spotify/v2"
)
// SpotifyListening is the resolver for the spotifyListening field.
func (r *queryResolver) SpotifyListening(ctx context.Context) (*model.SpotifyPlaying, error) {
if r.Store.SpotifyClient == nil {
return nil, nil
}
playing, err := r.Store.SpotifyClient.PlayerCurrentlyPlaying(ctx)
if err != nil {
return nil, err
}
result := &model.SpotifyPlaying{Playing: playing.Playing}
if playing.Item != nil {
result.Track = mapSpotifyTrack(playing.Item)
}
return result, nil
}
// SpotifyRecent is the resolver for the spotifyRecent field.
func (r *queryResolver) SpotifyRecent(ctx context.Context) ([]*model.SpotifyRecentItem, error) {
if r.Store.SpotifyClient == nil {
return []*model.SpotifyRecentItem{}, nil
}
if r.Store.RecentSongsFresh() {
return mapRecentItems(*r.Store.RecentSongs), nil
}
opts := spotify.RecentlyPlayedOptions{Limit: 3}
played, err := r.Store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts)
if err != nil {
return nil, err
}
r.Store.RecentSongs = &played
r.Store.RecentSongsFetchedAt = time.Now()
return mapRecentItems(played), nil
}

View File

@@ -0,0 +1,52 @@
package graph
// This file will be automatically regenerated based on the schema, any resolver
// implementations
// will be copied through when generating and any unknown code will be moved to the end.
// Code generated by github.com/99designs/gqlgen version v0.17.88
import (
"context"
"time"
"adam-french.co.uk/backend/graph/model"
"adam-french.co.uk/backend/services"
)
// SteamStatus is the resolver for the steamStatus field.
func (r *queryResolver) SteamStatus(ctx context.Context) (*model.SteamStatus, error) {
if r.Store.SteamAPIKey == "" {
return nil, nil
}
if r.Store.SteamFresh() {
return &model.SteamStatus{
Online: r.Store.SteamOnline,
RecentGames: mapSteamGames(r.Store.SteamRecentGames),
}, nil
}
games, err := services.FetchRecentlyPlayedGames(r.Store.SteamAPIKey, r.Store.SteamID)
if err != nil {
return nil, err
}
summary, err := services.FetchPlayerSummary(r.Store.SteamAPIKey, r.Store.SteamID)
if err != nil {
return nil, err
}
online := false
if summary != nil {
online = summary.PersonaState > 0
}
r.Store.SteamRecentGames = games
r.Store.SteamOnline = online
r.Store.SteamFetchedAt = time.Now()
return &model.SteamStatus{
Online: online,
RecentGames: mapSteamGames(games),
}, nil
}

View File

@@ -7,10 +7,106 @@ package graph
import ( import (
"context" "context"
"fmt"
"adam-french.co.uk/backend/graph/model"
"adam-french.co.uk/backend/models" "adam-french.co.uk/backend/models"
"golang.org/x/crypto/bcrypt"
) )
// CreateUser is the resolver for the createUser field.
func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*models.User, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
user := models.User{Username: input.Username, Password: hashedPassword}
if err := r.Store.DB.Create(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// DeleteUser is the resolver for the deleteUser field.
func (r *mutationResolver) DeleteUser(ctx context.Context, id int) (*models.User, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
var user models.User
if err := r.Store.DB.First(&user, id).Error; err != nil {
return nil, fmt.Errorf("user not found")
}
if err := r.Store.DB.Delete(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// SetUserAdmin is the resolver for the setUserAdmin field.
func (r *mutationResolver) SetUserAdmin(ctx context.Context, id int, admin bool) (*models.User, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
callerID, ok := UserIDFromCtx(ctx)
if !ok {
return nil, fmt.Errorf("unauthorized")
}
if uint(id) == callerID {
return nil, fmt.Errorf("cannot change your own admin status")
}
var user models.User
if err := r.Store.DB.First(&user, id).Error; err != nil {
return nil, fmt.Errorf("user not found")
}
user.Admin = admin
if err := r.Store.DB.Save(&user).Error; err != nil {
return nil, err
}
return &user, nil
}
// Users is the resolver for the users field.
func (r *queryResolver) Users(ctx context.Context) ([]*models.User, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
var users []models.User
if err := r.Store.DB.Find(&users).Error; err != nil {
return nil, err
}
result := make([]*models.User, len(users))
for i := range users {
result[i] = &users[i]
}
return result, nil
}
// User is the resolver for the user field.
func (r *queryResolver) User(ctx context.Context, id int) (*models.User, error) {
if !IsAdminFromCtx(ctx) {
return nil, fmt.Errorf("admin access required")
}
var user models.User
if err := r.Store.DB.First(&user, id).Error; err != nil {
return nil, fmt.Errorf("user not found")
}
return &user, nil
}
// ID is the resolver for the id field. // ID is the resolver for the id field.
func (r *userResolver) ID(ctx context.Context, obj *models.User) (int, error) { func (r *userResolver) ID(ctx context.Context, obj *models.User) (int, error) {
return int(obj.ID), nil return int(obj.ID), nil

View File

@@ -1,43 +0,0 @@
package handlers
import (
"log"
"net/http"
"adam-french.co.uk/backend/models"
"github.com/gin-gonic/gin"
)
type CreateActivityInput struct {
Type string `json:"type" binding:"required"`
Name string `json:"name" binding:"required"`
Link *string `json:"link"`
}
func (store *Store) GetActivity(ctx *gin.Context) {
var activitys []models.Activity
if err := store.DB.Order("Created_At DESC").Find(&activitys).Error; err != nil {
log.Println(err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
ctx.JSON(http.StatusOK, activitys)
}
func (store *Store) CreateActivity(ctx *gin.Context) {
var input CreateActivityInput
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
activity := models.Activity{Type: input.Type, Name: input.Name, Link: input.Link}
tx := store.DB.Create(&activity)
if tx.Error != nil {
log.Println(tx.Error)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
ctx.JSON(http.StatusCreated, activity)
}

View File

@@ -10,6 +10,11 @@ import (
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
type UserCredentials struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
func (store *Store) AuthMiddlewear(ctx *gin.Context) { func (store *Store) AuthMiddlewear(ctx *gin.Context) {
access_token, err := ctx.Cookie("access_token") access_token, err := ctx.Cookie("access_token")
if err != nil { if err != nil {
@@ -50,6 +55,87 @@ func (store *Store) AdminMiddleware(ctx *gin.Context) {
ctx.Next() ctx.Next()
} }
func (store *Store) ValidateAdmin(ctx *gin.Context) {
accessToken, err := ctx.Cookie("access_token")
if err != nil {
// No access token — try refreshing
if !store.tryRefreshAndValidateAdmin(ctx) {
ctx.Status(http.StatusUnauthorized)
}
return
}
claims, err := store.Auth.VerifyJWT(accessToken)
if err != nil {
// Expired/invalid access token — try refreshing
if !store.tryRefreshAndValidateAdmin(ctx) {
ctx.Status(http.StatusUnauthorized)
}
return
}
admin, ok := (*claims)["admin"].(bool)
if !ok || !admin {
ctx.Status(http.StatusForbidden)
return
}
ctx.Status(http.StatusOK)
}
func (store *Store) tryRefreshAndValidateAdmin(ctx *gin.Context) bool {
refreshToken, err := ctx.Cookie("refresh_token")
if err != nil {
return false
}
claims, err := store.Auth.VerifyJWT(refreshToken)
if err != nil {
return false
}
userIDF, ok := (*claims)["id"].(float64)
if !ok {
return false
}
user := models.User{ID: uint(userIDF)}
if err := store.DB.First(&user).Error; err != nil {
return false
}
if !user.Admin {
ctx.Status(http.StatusForbidden)
return true
}
tokens, err := store.Auth.GenerateJWT(&user)
if err != nil {
return false
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(
"access_token",
tokens.AccessToken,
int(store.Auth.Config.AccessTokenLifetime.Seconds()),
"/",
store.Auth.Config.Domain,
true, true,
)
ctx.SetCookie(
"refresh_token",
tokens.RefreshToken,
int(store.Auth.Config.RefreshTokenLifetime.Seconds()),
"/",
store.Auth.Config.Domain,
true, true,
)
ctx.Status(http.StatusOK)
return true
}
func (store *Store) CheckToken(ctx *gin.Context) { func (store *Store) CheckToken(ctx *gin.Context) {
access_token, err := ctx.Cookie("access_token") access_token, err := ctx.Cookie("access_token")
if err != nil { if err != nil {
@@ -123,7 +209,7 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
"access_token", "access_token",
tokens.AccessToken, tokens.AccessToken,
int(store.Auth.Config.AccessTokenLifetime.Seconds()), int(store.Auth.Config.AccessTokenLifetime.Seconds()),
store.Auth.Config.Endpoint, "/",
store.Auth.Config.Domain, store.Auth.Config.Domain,
true, true, true, true,
) )
@@ -131,7 +217,7 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
"refresh_token", "refresh_token",
tokens.RefreshToken, tokens.RefreshToken,
int(store.Auth.Config.RefreshTokenLifetime.Seconds()), int(store.Auth.Config.RefreshTokenLifetime.Seconds()),
store.Auth.Config.Endpoint, "/",
store.Auth.Config.Domain, store.Auth.Config.Domain,
true, true, true, true,
) )
@@ -140,6 +226,11 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
} }
func (store *Store) Login(ctx *gin.Context) { func (store *Store) Login(ctx *gin.Context) {
if !store.LoginLimiter.Allow(ctx.ClientIP()) {
ctx.JSON(http.StatusTooManyRequests, gin.H{"error": "too many login attempts, please try again later"})
return
}
var input UserCredentials var input UserCredentials
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
@@ -169,7 +260,7 @@ func (store *Store) Login(ctx *gin.Context) {
"access_token", "access_token",
tokens.AccessToken, tokens.AccessToken,
int(store.Auth.Config.AccessTokenLifetime.Seconds()), int(store.Auth.Config.AccessTokenLifetime.Seconds()),
store.Auth.Config.Endpoint, "/",
store.Auth.Config.Domain, store.Auth.Config.Domain,
true, true, true, true,
) )
@@ -177,7 +268,7 @@ func (store *Store) Login(ctx *gin.Context) {
"refresh_token", "refresh_token",
tokens.RefreshToken, tokens.RefreshToken,
int(store.Auth.Config.RefreshTokenLifetime.Seconds()), int(store.Auth.Config.RefreshTokenLifetime.Seconds()),
store.Auth.Config.Endpoint, "/",
store.Auth.Config.Domain, store.Auth.Config.Domain,
true, true, true, true,
) )
@@ -197,7 +288,7 @@ func (store *Store) removeCookies(ctx *gin.Context) {
"access_token", "access_token",
"", "",
-1, -1,
store.Auth.Config.Endpoint, "/",
store.Auth.Config.Domain, store.Auth.Config.Domain,
true, true, true, true,
) )
@@ -205,7 +296,7 @@ func (store *Store) removeCookies(ctx *gin.Context) {
"refresh_token", "refresh_token",
"", "",
-1, -1,
store.Auth.Config.Endpoint, "/",
store.Auth.Config.Domain, store.Auth.Config.Domain,
true, true, true, true,
) )

View File

@@ -0,0 +1,45 @@
package handlers
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
)
func (store *Store) TriggerEmailSync(ctx *gin.Context) {
if store.EmailSync == nil || store.EmailSync.HTTPClient == nil {
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "email sync not configured or not authenticated"})
return
}
err := store.EmailSync.SyncEmails(ctx.Request.Context())
if err != nil {
log.Printf("[EmailSync] Manual sync error: %v", err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "sync failed", "details": err.Error()})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "sync completed"})
}
func (store *Store) CompleteEmailAuth(ctx *gin.Context) {
if store.EmailSync == nil {
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "email sync not configured"})
return
}
code := ctx.Query("code")
if code == "" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "missing authorization code"})
return
}
if err := store.EmailSync.CompleteAuth(ctx.Request.Context(), code); err != nil {
log.Printf("[EmailSync] Auth completion error: %v", err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "authentication failed"})
return
}
ctx.JSON(http.StatusOK, gin.H{"message": "email authentication successful"})
}

View File

@@ -1,43 +0,0 @@
package handlers
import (
"log"
"net/http"
"adam-french.co.uk/backend/models"
"github.com/gin-gonic/gin"
)
type CreateFavoriteInput struct {
Type string `json:"type" binding:"required"`
Name string `json:"name" binding:"required"`
Link *string `json:"link"`
}
func (store *Store) GetFavorites(ctx *gin.Context) {
var favorites []models.Favorite
if err := store.DB.Order("Created_At DESC").Find(&favorites).Error; err != nil {
log.Println(err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
ctx.JSON(http.StatusOK, favorites)
}
func (store *Store) CreateFavorite(ctx *gin.Context) {
var input CreateFavoriteInput
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
favorite := models.Favorite{Type: input.Type, Name: input.Name, Link: input.Link}
tx := store.DB.Create(&favorite)
if tx.Error != nil {
log.Println(tx.Error)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
ctx.JSON(http.StatusCreated, favorite)
}

View File

@@ -1,171 +0,0 @@
package handlers
import (
"log"
"net/http"
"strconv"
"adam-french.co.uk/backend/models"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
type CreatePostInput struct {
Title string `json:"title" binding:"required"`
Content string `json:"content" binding:"required"`
}
func (store *Store) GetPosts(ctx *gin.Context) {
var posts []models.Post
if err := store.DB.Preload("Author").Order("Created_At DESC").Find(&posts).Error; err != nil {
log.Println(err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
ctx.JSON(http.StatusOK, posts)
}
func (store *Store) GetPost(ctx *gin.Context) {
postIDStr := ctx.Param("id")
postID, err := strconv.ParseUint(postIDStr, 10, 64)
if err != nil {
ctx.JSON(http.StatusBadRequest, "invalid id")
return
}
post := models.Post{ID: uint(postID)}
if err := store.DB.First(&post).Error; err != nil {
log.Println(err)
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
ctx.JSON(http.StatusOK, post)
}
func (store *Store) CreatePost(ctx *gin.Context) {
var input CreatePostInput
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
claimsVal, ok := ctx.Get("userClaims")
if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
claims, ok := claimsVal.(*jwt.MapClaims)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
userIDF, ok := (*claims)["id"].(float64)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
userID := uint(userIDF)
// Create post
post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID}
tx := store.DB.Create(&post)
if tx.Error != nil {
log.Println(tx.Error)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
ctx.JSON(http.StatusCreated, post)
}
func (store *Store) UpdatePost(ctx *gin.Context) {
postID := ctx.Param("id")
var post models.Post
if err := store.DB.First(&post, postID).Error; err != nil {
log.Println(err)
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
claimsVal, ok := ctx.Get("userClaims")
if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
claims, ok := claimsVal.(*jwt.MapClaims)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
userIDF, ok := (*claims)["id"].(float64)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
userID := uint(userIDF)
if !(userID == post.AuthorID) {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
var input CreatePostInput
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
post.Title = input.Title
post.Content = input.Content
tx := store.DB.Save(&post)
if tx.Error != nil {
log.Println(tx.Error)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
ctx.JSON(http.StatusOK, post)
}
func (store *Store) DeletePost(ctx *gin.Context) {
postID := ctx.Param("id")
var post models.Post
if err := store.DB.First(&post, postID).Error; err != nil {
log.Println(err)
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
claimsVal, ok := ctx.Get("userClaims")
if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
claims, ok := claimsVal.(*jwt.MapClaims)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
userIDF, ok := (*claims)["id"].(float64)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
userID := uint(userIDF)
if !(userID == post.AuthorID) {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
store.DB.Delete(&post)
ctx.JSON(http.StatusOK, post)
}

View File

@@ -37,6 +37,13 @@ func (store *Store) UploadRadioSong(ctx *gin.Context) {
filename := filepath.Base(file.Filename) filename := filepath.Base(file.Filename)
dest := filepath.Join(fallbackMusicDir, filename) dest := filepath.Join(fallbackMusicDir, filename)
// Verify the resolved path stays within the music directory
absDest, err := filepath.Abs(dest)
if err != nil || !strings.HasPrefix(absDest, fallbackMusicDir+string(os.PathSeparator)) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
return
}
if _, err := os.Stat(dest); err == nil { if _, err := os.Stat(dest); err == nil {
ctx.JSON(http.StatusConflict, gin.H{"error": "file already exists"}) ctx.JSON(http.StatusConflict, gin.H{"error": "file already exists"})
return return

View File

@@ -1,190 +0,0 @@
package handlers
import (
"net/http"
"adam-french.co.uk/backend/models"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
type UserCredentials struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type SetAdminInput struct {
Admin *bool `json:"admin" binding:"required"`
}
func (store *Store) CreateUser(ctx *gin.Context) {
var input UserCredentials
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, err.Error())
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
ctx.JSON(http.StatusInternalServerError, err.Error())
return
}
user := models.User{Username: input.Username, Password: hashedPassword}
tx := store.DB.Create(&user)
if tx.Error != nil {
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
return
}
ctx.JSON(http.StatusOK, user)
}
func (store *Store) GetUser(ctx *gin.Context) {
userID := ctx.Param("id")
var user models.User
if err := store.DB.First(&user, userID).Error; err != nil {
ctx.JSON(http.StatusNotFound, err.Error())
return
}
ctx.JSON(http.StatusOK, user)
}
func (store *Store) GetUsers(ctx *gin.Context) {
var users []models.User
if err := store.DB.Find(&users).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, err.Error())
return
}
ctx.JSON(http.StatusOK, users)
}
func (store *Store) UpdateUser(ctx *gin.Context) {
claimsVal, ok := ctx.Get("userClaims")
if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"})
return
}
claims, ok := claimsVal.(*jwt.MapClaims)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
return
}
userIDF, ok := (*claims)["id"].(float64)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"})
return
}
userID := uint(userIDF)
var user models.User
if err := store.DB.First(&user, userID).Error; err != nil {
ctx.JSON(http.StatusNotFound, err.Error())
return
}
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "will be implemented"})
}
func (store *Store) SetUserAdmin(ctx *gin.Context) {
claimsVal, ok := ctx.Get("userClaims")
if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"})
return
}
claims, ok := claimsVal.(*jwt.MapClaims)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
return
}
callerIDF, ok := (*claims)["id"].(float64)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"})
return
}
callerID := uint(callerIDF)
targetID := ctx.Param("id")
var input SetAdminInput
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user models.User
if err := store.DB.First(&user, targetID).Error; err != nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if user.ID == callerID {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot change your own admin status"})
return
}
user.Admin = *input.Admin
if err := store.DB.Save(&user).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, user)
}
func (store *Store) DeleteUser(ctx *gin.Context) {
claimsVal, ok := ctx.Get("userClaims")
if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"})
return
}
claims, ok := claimsVal.(*jwt.MapClaims)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
return
}
userIDF, ok := (*claims)["id"].(float64)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"})
return
}
userID := uint(userIDF)
var user models.User
if err := store.DB.First(&user, userID).Error; err != nil {
ctx.JSON(http.StatusNotFound, err.Error())
return
}
tx := store.DB.Delete(&user)
if tx.Error != nil {
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
return
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(
"access_token",
"",
-1,
store.Auth.Config.Endpoint,
store.Auth.Config.Domain,
true, true,
)
ctx.SetCookie(
"refresh_token",
"",
-1,
store.Auth.Config.Endpoint,
store.Auth.Config.Domain,
true, true,
)
ctx.JSON(http.StatusOK, user)
}

View File

@@ -17,6 +17,8 @@ type Store struct {
ClaudeClient *anthropic.Client ClaudeClient *anthropic.Client
Auth *services.Auth Auth *services.Auth
Notes *services.Notes Notes *services.Notes
LoginLimiter *services.RateLimiter
EmailSync *services.EmailSyncService
RecentSongs *[]spotify.RecentlyPlayedItem RecentSongs *[]spotify.RecentlyPlayedItem
RecentSongsFetchedAt time.Time RecentSongsFetchedAt time.Time

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"log" "log"
@@ -28,6 +29,10 @@ func main() {
} }
gin.DefaultWriter = io.MultiWriter(os.Stdout, logFile) gin.DefaultWriter = io.MultiWriter(os.Stdout, logFile)
if os.Getenv("DEV_MODE") != "true" {
gin.SetMode(gin.ReleaseMode)
}
r := gin.Default() r := gin.Default()
err = r.SetTrustedProxies([]string{"172.28.0.0/16"}) err = r.SetTrustedProxies([]string{"172.28.0.0/16"})
@@ -66,7 +71,7 @@ func main() {
authSecret := os.Getenv("BACKEND_SECRET") authSecret := os.Getenv("BACKEND_SECRET")
backendEndpoint := os.Getenv("BACKEND_ENDPOINT") backendEndpoint := os.Getenv("BACKEND_ENDPOINT")
accessTokenLifetime := 24 * time.Hour accessTokenLifetime := 7 * 24 * time.Hour
refreshTokenLifetime := 365 * 24 * time.Hour refreshTokenLifetime := 365 * 24 * time.Hour
authConfig := services.AuthConfig{Secret: []byte(authSecret), Domain: domainName, RefreshTokenLifetime: refreshTokenLifetime, AccessTokenLifetime: accessTokenLifetime, Endpoint: backendEndpoint} authConfig := services.AuthConfig{Secret: []byte(authSecret), Domain: domainName, RefreshTokenLifetime: refreshTokenLifetime, AccessTokenLifetime: accessTokenLifetime, Endpoint: backendEndpoint}
auth := services.InitAuth(&authConfig) auth := services.InitAuth(&authConfig)
@@ -81,43 +86,51 @@ func main() {
steamAPIKey := os.Getenv("STEAM_API_KEY") steamAPIKey := os.Getenv("STEAM_API_KEY")
steamID := os.Getenv("STEAM_ID") steamID := os.Getenv("STEAM_ID")
store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes, GiteaHost: giteaHost, GiteaPort: giteaPort, SteamAPIKey: steamAPIKey, SteamID: steamID} // EMAIL SYNC
emailSyncInterval := 30 * time.Minute
if interval := os.Getenv("EMAIL_SYNC_INTERVAL"); interval != "" {
if parsed, err := time.ParseDuration(interval); err == nil {
emailSyncInterval = parsed
}
}
emailSyncConfig := services.EmailSyncConfig{
Backend: os.Getenv("EMAIL_BACKEND"),
ClientID: os.Getenv("MSGRAPH_CLIENT_ID"),
ClientSecret: os.Getenv("MSGRAPH_CLIENT_SECRET"),
TenantID: os.Getenv("MSGRAPH_TENANT_ID"),
RedirectURI: os.Getenv("MSGRAPH_REDIRECT_URI"),
IMAP: &services.IMAPConfig{
Host: os.Getenv("IMAP_HOST"),
Port: os.Getenv("IMAP_PORT"),
Email: os.Getenv("IMAP_EMAIL"),
Password: os.Getenv("IMAP_PASSWORD"),
},
SyncInterval: emailSyncInterval,
Enabled: os.Getenv("EMAIL_SYNC_ENABLED") == "true",
}
emailSync := services.InitEmailSync(&emailSyncConfig, db, claudeClient)
loginLimiter := services.NewRateLimiter(5, time.Minute)
store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes, LoginLimiter: loginLimiter, EmailSync: emailSync, GiteaHost: giteaHost, GiteaPort: giteaPort, SteamAPIKey: steamAPIKey, SteamID: steamID}
protected := r.Group("/", store.AuthMiddlewear) protected := r.Group("/", store.AuthMiddlewear)
admin := r.Group("/", store.AuthMiddlewear, store.AdminMiddleware) admin := r.Group("/", store.AuthMiddlewear, store.AdminMiddleware)
// FAVORITES
r.GET("/favorites", store.GetFavorites)
admin.POST("/favorites", store.CreateFavorite)
// ROWING // ROWING
r.GET("/rowing", store.GetRowing) r.GET("/rowing", store.GetRowing)
admin.POST("/rowing", store.CreateRowing) admin.POST("/rowing", store.CreateRowing)
// ACTIVITIES
r.GET("/activity", store.GetActivity)
admin.POST("/activity", store.CreateActivity)
// POSTS
r.GET("/posts", store.GetPosts)
admin.POST("/posts", store.CreatePost)
r.GET("/posts/:id", store.GetPost)
admin.PUT("/posts/:id", store.UpdatePost)
admin.DELETE("/posts/:id", store.DeletePost)
// USERS
r.GET("/user/:id", store.GetUser)
admin.PUT("/user/:id", store.UpdateUser)
admin.DELETE("/user/:id", store.DeleteUser)
r.GET("/user", store.GetUsers)
admin.POST("/user", store.CreateUser)
admin.PATCH("/user/:id/admin", store.SetUserAdmin)
// AUTH // AUTH
r.POST("/auth/login", store.Login) r.POST("/auth/login", store.Login)
r.POST("/auth/refresh", store.RefreshToken) r.POST("/auth/refresh", store.RefreshToken)
r.GET("/auth/check", store.CheckToken) r.GET("/auth/check", store.CheckToken)
r.POST("/auth/logout", store.Logout) r.POST("/auth/logout", store.Logout)
r.GET("/auth/validate-admin", store.ValidateAdmin)
// EMAIL SYNC
r.GET("/email/callback", store.CompleteEmailAuth)
admin.POST("/email/sync", store.TriggerEmailSync)
// SPOTIFY // SPOTIFY
r.GET("/spotify/callback", store.CompleteSpotifyAuth) r.GET("/spotify/callback", store.CompleteSpotifyAuth)
@@ -137,7 +150,7 @@ func main() {
protected.POST("/messages/upload", store.UploadMessageFile) protected.POST("/messages/upload", store.UploadMessageFile)
// NOTES // NOTES
r.GET("/notes/*path", store.GetNoteFile) admin.GET("/notes/*path", store.GetNoteFile)
// GRAPHQL // GRAPHQL
gqlSrv := handler.New(graph.NewExecutableSchema(graph.Config{ gqlSrv := handler.New(graph.NewExecutableSchema(graph.Config{
@@ -145,18 +158,18 @@ func main() {
})) }))
gqlSrv.AddTransport(transport.Websocket{KeepAlivePingInterval: 10 * time.Second}) gqlSrv.AddTransport(transport.Websocket{KeepAlivePingInterval: 10 * time.Second})
gqlSrv.AddTransport(transport.Options{}) gqlSrv.AddTransport(transport.Options{})
gqlSrv.AddTransport(transport.GET{})
gqlSrv.AddTransport(transport.POST{}) gqlSrv.AddTransport(transport.POST{})
gqlSrv.AddTransport(transport.MultipartForm{}) gqlSrv.AddTransport(transport.MultipartForm{})
gqlSrv.SetQueryCache(lru.New[*ast.QueryDocument](1000)) gqlSrv.SetQueryCache(lru.New[*ast.QueryDocument](1000))
gqlSrv.Use(extension.FixedComplexityLimit(200)) gqlSrv.Use(extension.FixedComplexityLimit(200))
if os.Getenv("GQL_INTROSPECTION") == "true" { devMode := os.Getenv("DEV_MODE") == "true"
if devMode && os.Getenv("GQL_INTROSPECTION") == "true" {
gqlSrv.Use(extension.Introspection{}) gqlSrv.Use(extension.Introspection{})
} }
r.POST("/graphql", graph.AuthContextMiddleware(auth), func(c *gin.Context) { r.POST("/graphql", graph.AuthContextMiddleware(auth), func(c *gin.Context) {
gqlSrv.ServeHTTP(c.Writer, c.Request) gqlSrv.ServeHTTP(c.Writer, c.Request)
}) })
if os.Getenv("GQL_PLAYGROUND") == "true" { if devMode && os.Getenv("GQL_PLAYGROUND") == "true" {
r.GET("/graphql", func(c *gin.Context) { r.GET("/graphql", func(c *gin.Context) {
playground.Handler("GraphQL Playground", "/graphql").ServeHTTP(c.Writer, c.Request) playground.Handler("GraphQL Playground", "/graphql").ServeHTTP(c.Writer, c.Request)
}) })
@@ -167,6 +180,11 @@ func main() {
c.JSON(200, gin.H{"message": "Hello World"}) c.JSON(200, gin.H{"message": "Hello World"})
}) })
// Launch email sync scheduler
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go store.EmailSync.StartScheduler(ctx)
port := os.Getenv("BACKEND_PORT") port := os.Getenv("BACKEND_PORT")
r.Run(fmt.Sprintf(":%s", port)) r.Run(fmt.Sprintf(":%s", port))
} }

View File

@@ -66,3 +66,47 @@ type Rowing struct {
TimePer500m float64 `json:"timePer500m"` TimePer500m float64 `json:"timePer500m"`
Calories float64 `json:"calories"` Calories float64 `json:"calories"`
} }
type Bookmark struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
Category string `gorm:"not null" json:"category"`
Name string `gorm:"not null" json:"name"`
Link string `gorm:"not null" json:"link"`
}
type JobAppReference struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
Category string `gorm:"not null" json:"category"`
Label string `gorm:"not null" json:"label"`
Value string `gorm:"not null" json:"value"`
SortOrder int `gorm:"default:0" json:"sortOrder"`
}
type ProcessedEmail struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
GraphMessageID string `gorm:"uniqueIndex;not null" json:"graphMessageId"`
Subject string `gorm:"not null" json:"subject"`
Action string `gorm:"not null" json:"action"`
JobAppID *uint `json:"jobAppId"`
}
type JobApplication struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
JobTitle string `gorm:"not null" json:"jobTitle"`
Company string `gorm:"not null" json:"company"`
Location *string `json:"location"`
URL *string `json:"url"`
Status string `gorm:"not null" json:"status"`
Notes *string `json:"notes"`
AppliedAt *time.Time `json:"appliedAt"`
}

View File

@@ -38,6 +38,10 @@ func migrateDatabase(db *gorm.DB) error {
&models.Favorite{}, &models.Favorite{},
&models.Rowing{}, &models.Rowing{},
&models.Message{}, &models.Message{},
&models.JobApplication{},
&models.JobAppReference{},
&models.Bookmark{},
&models.ProcessedEmail{},
) )
if err != nil { if err != nil {
return err return err

View File

@@ -0,0 +1,337 @@
package services
import (
"bufio"
"crypto/tls"
"fmt"
"io"
"log"
"mime"
"mime/multipart"
"net"
"net/mail"
"strings"
"sync/atomic"
"time"
)
type IMAPConfig struct {
Host string
Port string
Email string
Password string
}
// imapClient is a minimal, lenient IMAP client that tolerates
// Outlook's non-standard response formatting.
type imapClient struct {
conn net.Conn
reader *bufio.Reader
tag atomic.Int64
}
func imapDial(addr string) (*imapClient, error) {
conn, err := tls.Dial("tcp", addr, nil)
if err != nil {
return nil, err
}
c := &imapClient{
conn: conn,
reader: bufio.NewReader(conn),
}
// Read server greeting
if _, err := c.readLine(); err != nil {
conn.Close()
return nil, fmt.Errorf("reading greeting: %w", err)
}
return c, nil
}
func (c *imapClient) Close() error {
return c.conn.Close()
}
func (c *imapClient) nextTag() string {
return fmt.Sprintf("A%04d", c.tag.Add(1))
}
func (c *imapClient) readLine() (string, error) {
line, err := c.reader.ReadString('\n')
return strings.TrimRight(line, "\r\n"), err
}
// sendCommand sends a tagged command and reads lines until the tagged response.
// Returns all untagged response lines and the final tagged status line.
func (c *imapClient) sendCommand(cmd string) (untagged []string, status string, err error) {
tag := c.nextTag()
_, err = fmt.Fprintf(c.conn, "%s %s\r\n", tag, cmd)
if err != nil {
return nil, "", err
}
for {
line, err := c.readLine()
if err != nil {
return untagged, "", err
}
if strings.HasPrefix(line, tag+" ") {
return untagged, line, nil
}
untagged = append(untagged, line)
}
}
// sendCommandOK sends a command and returns an error if the response is not OK.
func (c *imapClient) sendCommandOK(cmd string) ([]string, error) {
untagged, status, err := c.sendCommand(cmd)
if err != nil {
return untagged, err
}
// Status line is like: A0001 OK ... or A0001 NO ...
parts := strings.SplitN(status, " ", 3)
if len(parts) < 2 || parts[1] != "OK" {
return untagged, fmt.Errorf("command %q failed: %s", cmd, status)
}
return untagged, nil
}
// fetchLiteral reads an IMAP literal {N}\r\n followed by N bytes.
func (c *imapClient) readLiteral(size int) (string, error) {
buf := make([]byte, size)
_, err := io.ReadFull(c.reader, buf)
if err != nil {
return "", err
}
return string(buf), nil
}
// sendFetch sends a FETCH command and collects the full response including literals.
// Returns raw response lines (with literals inlined after their header lines).
func (c *imapClient) sendFetch(cmd string) ([]string, error) {
tag := c.nextTag()
_, err := fmt.Fprintf(c.conn, "%s %s\r\n", tag, cmd)
if err != nil {
return nil, err
}
var lines []string
for {
line, err := c.readLine()
if err != nil {
return lines, err
}
// Check for literal marker {N} at end of line
if idx := strings.LastIndex(line, "{"); idx >= 0 && strings.HasSuffix(line, "}") {
sizeStr := line[idx+1 : len(line)-1]
var size int
if _, err := fmt.Sscanf(sizeStr, "%d", &size); err == nil && size > 0 {
literal, err := c.readLiteral(size)
if err != nil {
return lines, fmt.Errorf("reading literal of %d bytes: %w", size, err)
}
lines = append(lines, line)
lines = append(lines, literal)
continue
}
}
if strings.HasPrefix(line, tag+" ") {
// Check for OK
parts := strings.SplitN(line, " ", 3)
if len(parts) >= 2 && parts[1] != "OK" {
return lines, fmt.Errorf("FETCH failed: %s", line)
}
return lines, nil
}
lines = append(lines, line)
}
}
// fetchEmailsIMAP connects to an IMAP server and retrieves emails since the given time.
func (s *EmailSyncService) fetchEmailsIMAP(since time.Time) ([]graphMessage, error) {
addr := fmt.Sprintf("%s:%s", s.Config.IMAP.Host, s.Config.IMAP.Port)
c, err := imapDial(addr)
if err != nil {
return nil, fmt.Errorf("IMAP connect: %w", err)
}
defer c.Close()
// Quote the password to handle special characters
quotedPass := fmt.Sprintf("%q", s.Config.IMAP.Password)
if _, err := c.sendCommandOK(fmt.Sprintf("LOGIN %s %s", s.Config.IMAP.Email, quotedPass)); err != nil {
return nil, fmt.Errorf("IMAP login: %w", err)
}
if _, err := c.sendCommandOK("SELECT INBOX"); err != nil {
return nil, fmt.Errorf("IMAP select INBOX: %w", err)
}
// SEARCH SINCE uses date only (no time), per IMAP spec
dateStr := since.UTC().Format("02-Jan-2006")
untagged, err := c.sendCommandOK(fmt.Sprintf("SEARCH SINCE %s", dateStr))
if err != nil {
return nil, fmt.Errorf("IMAP search: %w", err)
}
// Parse sequence numbers from "* SEARCH 1 2 3 ..."
var seqNums []string
for _, line := range untagged {
if strings.HasPrefix(line, "* SEARCH") {
parts := strings.Fields(line)
if len(parts) > 2 {
seqNums = append(seqNums, parts[2:]...)
}
}
}
if len(seqNums) == 0 {
log.Printf("[EmailSync/IMAP] No messages found since %s", dateStr)
c.sendCommand("LOGOUT")
return nil, nil
}
log.Printf("[EmailSync/IMAP] Found %d messages since %s", len(seqNums), dateStr)
seqSet := strings.Join(seqNums, ",")
fetchLines, err := c.sendFetch(fmt.Sprintf("FETCH %s (BODY[])", seqSet))
if err != nil {
return nil, fmt.Errorf("IMAP fetch: %w", err)
}
// Parse fetched messages - literals contain the raw RFC822 message
var messages []graphMessage
for i := 0; i < len(fetchLines); i++ {
line := fetchLines[i]
// Look for a literal following this line
if strings.Contains(line, "BODY[]") && strings.HasSuffix(line, "}") {
if i+1 < len(fetchLines) {
raw := fetchLines[i+1]
i++ // skip the literal
gm, err := parseRawEmail(raw)
if err != nil {
log.Printf("[EmailSync/IMAP] Error parsing email: %v", err)
continue
}
messages = append(messages, gm)
}
}
}
c.sendCommand("LOGOUT")
return messages, nil
}
// parseRawEmail parses an RFC822 email into a graphMessage.
func parseRawEmail(raw string) (graphMessage, error) {
msg, err := mail.ReadMessage(strings.NewReader(raw))
if err != nil {
return graphMessage{}, fmt.Errorf("parsing message: %w", err)
}
header := msg.Header
// Parse From
var fromName, fromAddr string
if fromList, err := header.AddressList("From"); err == nil && len(fromList) > 0 {
fromName = fromList[0].Name
fromAddr = fromList[0].Address
}
// Parse Date
dateStr := header.Get("Date")
parsedDate, err := mail.ParseDate(dateStr)
if err != nil {
parsedDate = time.Now()
}
// Extract body text
bodyContent := extractTextBody(msg.Header, msg.Body)
return graphMessage{
ID: header.Get("Message-ID"),
Subject: decodeHeader(header.Get("Subject")),
ReceivedDateTime: parsedDate.Format(time.RFC3339),
From: graphFrom{
EmailAddress: graphEmailAddress{
Name: fromName,
Address: fromAddr,
},
},
Body: graphBody{
ContentType: "text",
Content: bodyContent,
},
}, nil
}
// extractTextBody pulls the text/plain or text/html content from a message body.
func extractTextBody(header mail.Header, body io.Reader) string {
contentType := header.Get("Content-Type")
if contentType == "" {
contentType = "text/plain"
}
mediaType, params, err := mime.ParseMediaType(contentType)
if err != nil {
// Try reading as plain text
b, _ := io.ReadAll(body)
return string(b)
}
if strings.HasPrefix(mediaType, "multipart/") {
boundary := params["boundary"]
if boundary == "" {
b, _ := io.ReadAll(body)
return string(b)
}
mr := multipart.NewReader(body, boundary)
var textContent, htmlContent string
for {
part, err := mr.NextPart()
if err != nil {
break
}
partType := part.Header.Get("Content-Type")
partMedia, _, _ := mime.ParseMediaType(partType)
b, err := io.ReadAll(part)
if err != nil {
continue
}
switch partMedia {
case "text/plain":
textContent = string(b)
case "text/html":
htmlContent = string(b)
case "multipart/alternative", "multipart/related", "multipart/mixed":
// Recursively handle nested multipart
nested := extractTextBody(
mail.Header{"Content-Type": {partType}},
strings.NewReader(string(b)),
)
if nested != "" && textContent == "" {
textContent = nested
}
}
}
if textContent != "" {
return textContent
}
return htmlContent
}
b, _ := io.ReadAll(body)
return string(b)
}
// decodeHeader decodes RFC 2047 encoded header values.
func decodeHeader(s string) string {
dec := new(mime.WordDecoder)
decoded, err := dec.DecodeHeader(s)
if err != nil {
return s
}
return decoded
}

View File

@@ -0,0 +1,680 @@
package services
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"sync"
"time"
"adam-french.co.uk/backend/models"
"github.com/anthropics/anthropic-sdk-go"
"golang.org/x/oauth2"
"gorm.io/gorm"
)
const MSGRAPH_TOKEN_JSON_PATH = "/backend/token/msgraph_token.json"
type EmailSyncConfig struct {
Backend string // "graph" or "imap"
ClientID string
ClientSecret string
TenantID string
RedirectURI string
IMAP *IMAPConfig
SyncInterval time.Duration
Enabled bool
}
type EmailSyncService struct {
Config *EmailSyncConfig
OAuthConfig *oauth2.Config
HTTPClient *http.Client
DB *gorm.DB
ClaudeClient *anthropic.Client
mu sync.Mutex
}
// Microsoft Graph API response types
type graphMessagesResponse struct {
Value []graphMessage `json:"value"`
NextLink string `json:"@odata.nextLink"`
}
type graphMessage struct {
ID string `json:"id"`
Subject string `json:"subject"`
ReceivedDateTime string `json:"receivedDateTime"`
From graphFrom `json:"from"`
Body graphBody `json:"body"`
BodyPreview string `json:"bodyPreview"`
}
type graphFrom struct {
EmailAddress graphEmailAddress `json:"emailAddress"`
}
type graphEmailAddress struct {
Name string `json:"name"`
Address string `json:"address"`
}
type graphBody struct {
ContentType string `json:"contentType"`
Content string `json:"content"`
}
// Claude response type
type EmailAnalysis struct {
IsJobEmail bool `json:"isJobEmail"`
Action string `json:"action"`
Company string `json:"company"`
JobTitle string `json:"jobTitle"`
Status string `json:"status"`
Location *string `json:"location"`
URL *string `json:"url"`
AppliedAt *string `json:"appliedAt"`
Notes *string `json:"notes"`
}
// Email filtering config
var subjectKeywords = []string{
"application", "interview", "offer", "rejected", "assessment",
"applied", "candidate", "position", "role", "hiring",
"thank you for applying", "we regret", "move forward",
"next steps", "coding challenge", "take-home",
}
var senderDomains = []string{
"greenhouse.io", "lever.co", "workday.com", "myworkday.com",
"ashbyhq.com", "smartrecruiters.com", "icims.com",
"jobvite.com", "taleo.net", "breezy.hr", "recruitee.com",
"applytojob.com", "jazz.co", "dover.com",
}
// Status progression order
var statusOrder = map[string]int{
"applied": 0,
"screening": 1,
"assessment": 2,
"interviewing": 3,
"offer": 4,
"rejected": 5,
"withdrawn": 6,
}
// Token persistence
func SaveMSGraphToken(path string, tok *oauth2.Token) error {
data := struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
Expiry time.Time `json:"expiry"`
}{
AccessToken: tok.AccessToken,
RefreshToken: tok.RefreshToken,
TokenType: tok.TokenType,
Expiry: tok.Expiry,
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating token directory: %w", err)
}
jsonBytes, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, jsonBytes, 0600)
}
func LoadMSGraphToken(path string) (*oauth2.Token, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var saved struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
Expiry time.Time `json:"expiry"`
}
if err := json.Unmarshal(data, &saved); err != nil {
return nil, err
}
return &oauth2.Token{
AccessToken: saved.AccessToken,
RefreshToken: saved.RefreshToken,
TokenType: saved.TokenType,
Expiry: saved.Expiry,
}, nil
}
// persistingTokenSource wraps an oauth2.TokenSource to save refreshed tokens to disk.
type persistingTokenSource struct {
base oauth2.TokenSource
path string
lastToken *oauth2.Token
}
func (p *persistingTokenSource) Token() (*oauth2.Token, error) {
tok, err := p.base.Token()
if err != nil {
return nil, err
}
// Save only when the token actually changed (i.e. was refreshed)
if p.lastToken == nil || tok.AccessToken != p.lastToken.AccessToken {
p.lastToken = tok
if saveErr := SaveMSGraphToken(p.path, tok); saveErr != nil {
log.Printf("[EmailSync] Warning: failed to persist refreshed token: %v", saveErr)
}
}
return tok, nil
}
func InitEmailSync(config *EmailSyncConfig, db *gorm.DB, claudeClient *anthropic.Client) *EmailSyncService {
svc := &EmailSyncService{
Config: config,
DB: db,
ClaudeClient: claudeClient,
}
switch config.Backend {
case "imap":
if config.IMAP == nil || config.IMAP.Email == "" {
log.Println("[EmailSync] IMAP backend selected but not configured")
return svc
}
// IMAP connects per-sync, no persistent client needed.
// Mark as ready by setting a non-nil HTTPClient (used as readiness flag).
svc.HTTPClient = http.DefaultClient
log.Printf("[EmailSync] Configured with IMAP backend (%s)", config.IMAP.Host)
case "graph":
oauthConfig := &oauth2.Config{
ClientID: config.ClientID,
ClientSecret: config.ClientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/authorize", config.TenantID),
TokenURL: fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", config.TenantID),
},
RedirectURL: config.RedirectURI,
Scopes: []string{"https://graph.microsoft.com/Mail.Read", "offline_access"},
}
svc.OAuthConfig = oauthConfig
token, err := LoadMSGraphToken(MSGRAPH_TOKEN_JSON_PATH)
if err != nil || token == nil {
authURL := oauthConfig.AuthCodeURL("state", oauth2.AccessTypeOffline)
log.Println("[EmailSync] No token saved. Authenticate Microsoft Graph with:")
log.Println(authURL)
return svc
}
baseSource := oauthConfig.TokenSource(context.Background(), token)
persistSource := &persistingTokenSource{base: baseSource, path: MSGRAPH_TOKEN_JSON_PATH, lastToken: token}
svc.HTTPClient = oauth2.NewClient(context.Background(), persistSource)
log.Println("[EmailSync] Authenticated with Microsoft Graph")
default:
log.Printf("[EmailSync] Unknown backend %q, defaulting to disabled", config.Backend)
}
return svc
}
// AuthURL returns the OAuth2 authorization URL for initial setup.
func (s *EmailSyncService) AuthURL() string {
return s.OAuthConfig.AuthCodeURL("state", oauth2.AccessTypeOffline)
}
// CompleteAuth exchanges an authorization code for a token and initializes the HTTP client.
func (s *EmailSyncService) CompleteAuth(ctx context.Context, code string) error {
token, err := s.OAuthConfig.Exchange(ctx, code)
if err != nil {
return fmt.Errorf("token exchange failed: %w", err)
}
if err := SaveMSGraphToken(MSGRAPH_TOKEN_JSON_PATH, token); err != nil {
return fmt.Errorf("saving token: %w", err)
}
baseSource := s.OAuthConfig.TokenSource(ctx, token)
persistSource := &persistingTokenSource{base: baseSource, path: MSGRAPH_TOKEN_JSON_PATH, lastToken: token}
s.HTTPClient = oauth2.NewClient(ctx, persistSource)
log.Println("[EmailSync] Authentication completed successfully")
return nil
}
// StartScheduler runs SyncEmails on a recurring interval.
func (s *EmailSyncService) StartScheduler(ctx context.Context) {
if !s.Config.Enabled {
log.Println("[EmailSync] Disabled, skipping scheduler")
return
}
if s.HTTPClient == nil {
log.Println("[EmailSync] Not authenticated, skipping scheduler")
return
}
log.Printf("[EmailSync] Starting scheduler, interval: %s", s.Config.SyncInterval)
// Run once immediately on startup
if err := s.SyncEmails(ctx); err != nil {
log.Printf("[EmailSync] Initial sync error: %v", err)
}
ticker := time.NewTicker(s.Config.SyncInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
log.Println("[EmailSync] Scheduler stopped")
return
case <-ticker.C:
if err := s.SyncEmails(ctx); err != nil {
log.Printf("[EmailSync] Sync error: %v", err)
}
}
}
}
// SyncEmails is the main pipeline: fetch, filter, dedup, classify, create/update.
func (s *EmailSyncService) SyncEmails(ctx context.Context) error {
if !s.mu.TryLock() {
return fmt.Errorf("sync already in progress")
}
defer s.mu.Unlock()
defer func() {
if r := recover(); r != nil {
log.Printf("[EmailSync] PANIC recovered: %v", r)
}
}()
log.Println("[EmailSync] Starting sync...")
// Determine the time window: use the most recent ProcessedEmail timestamp, or default to 24h ago
since := time.Now().Add(-24 * time.Hour)
var latest models.ProcessedEmail
if err := s.DB.Order("created_at DESC").First(&latest).Error; err == nil {
since = latest.CreatedAt
}
// Fetch emails from Microsoft Graph
emails, err := s.fetchEmails(ctx, since)
if err != nil {
return fmt.Errorf("fetching emails: %w", err)
}
log.Printf("[EmailSync] Fetched %d emails since %s", len(emails), since.Format(time.RFC3339))
// Filter by subject/sender patterns
filtered := s.filterEmails(emails)
log.Printf("[EmailSync] %d emails passed filters", len(filtered))
var created, updated, skipped, errored int
for _, email := range filtered {
// Dedup check
var existing models.ProcessedEmail
if err := s.DB.Where("graph_message_id = ?", email.ID).First(&existing).Error; err == nil {
skipped++
continue
}
// Process this email
action, jobAppID, processErr := s.processEmail(ctx, email)
if processErr != nil {
log.Printf("[EmailSync] Error processing email %q: %v", email.Subject, processErr)
s.DB.Create(&models.ProcessedEmail{
GraphMessageID: email.ID,
Subject: truncate(email.Subject, 255),
Action: "error",
})
errored++
continue
}
// Record as processed
s.DB.Create(&models.ProcessedEmail{
GraphMessageID: email.ID,
Subject: truncate(email.Subject, 255),
Action: action,
JobAppID: jobAppID,
})
switch action {
case "created":
created++
case "updated":
updated++
default:
skipped++
}
}
log.Printf("[EmailSync] Sync complete: %d fetched, %d filtered, %d created, %d updated, %d skipped, %d errors",
len(emails), len(filtered), created, updated, skipped, errored)
return nil
}
// fetchEmails retrieves emails since the given time using the configured backend.
func (s *EmailSyncService) fetchEmails(ctx context.Context, since time.Time) ([]graphMessage, error) {
if s.Config.Backend == "imap" {
return s.fetchEmailsIMAP(since)
}
return s.fetchEmailsGraph(ctx, since)
}
// fetchEmailsGraph retrieves emails from the Microsoft Graph API since the given time.
func (s *EmailSyncService) fetchEmailsGraph(ctx context.Context, since time.Time) ([]graphMessage, error) {
var all []graphMessage
sinceStr := since.UTC().Format("2006-01-02T15:04:05Z")
params := url.Values{
"$filter": {fmt.Sprintf("receivedDateTime ge %s", sinceStr)},
"$select": {"id,subject,from,receivedDateTime,body,bodyPreview"},
"$top": {"50"},
"$orderby": {"receivedDateTime asc"},
}
nextURL := fmt.Sprintf("https://graph.microsoft.com/v1.0/me/messages?%s", params.Encode())
for nextURL != "" {
req, err := http.NewRequestWithContext(ctx, "GET", nextURL, nil)
if err != nil {
return nil, err
}
resp, err := s.HTTPClient.Do(req)
if err != nil {
return nil, err
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("Graph API returned %d: %s", resp.StatusCode, string(body))
}
if err != nil {
return nil, err
}
var result graphMessagesResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("parsing response: %w", err)
}
all = append(all, result.Value...)
nextURL = result.NextLink
}
return all, nil
}
// filterEmails returns only emails that match subject keywords or sender domains.
func (s *EmailSyncService) filterEmails(emails []graphMessage) []graphMessage {
var matched []graphMessage
for _, email := range emails {
if s.matchesFilter(email) {
matched = append(matched, email)
}
}
return matched
}
func (s *EmailSyncService) matchesFilter(email graphMessage) bool {
subjectLower := strings.ToLower(email.Subject)
for _, kw := range subjectKeywords {
if strings.Contains(subjectLower, kw) {
return true
}
}
senderAddr := strings.ToLower(email.From.EmailAddress.Address)
for _, domain := range senderDomains {
if strings.HasSuffix(senderAddr, "@"+domain) || strings.HasSuffix(senderAddr, "."+domain) {
return true
}
}
return false
}
// processEmail sends a single email to Claude and creates/updates a JobApplication.
// Returns the action taken ("created", "updated", "skipped") and the job application ID if applicable.
func (s *EmailSyncService) processEmail(ctx context.Context, email graphMessage) (string, *uint, error) {
cleaned := cleanEmailBody(email.Body.Content, email.Body.ContentType)
userMsg := fmt.Sprintf("Subject: %s\nFrom: %s <%s>\nDate: %s\n\n%s",
email.Subject,
email.From.EmailAddress.Name,
email.From.EmailAddress.Address,
email.ReceivedDateTime,
cleaned,
)
message, err := s.ClaudeClient.Messages.New(ctx, anthropic.MessageNewParams{
Model: anthropic.ModelClaudeHaiku4_5,
MaxTokens: 512,
System: []anthropic.TextBlockParam{
{Text: emailClassificationPrompt},
},
Messages: []anthropic.MessageParam{
{
Role: "user",
Content: []anthropic.ContentBlockParamUnion{anthropic.NewTextBlock(userMsg)},
},
},
})
if err != nil {
return "", nil, fmt.Errorf("Claude API error: %w", err)
}
if len(message.Content) == 0 {
return "", nil, fmt.Errorf("empty response from Claude")
}
raw := message.Content[0].Text
raw = strings.TrimSpace(raw)
raw = strings.TrimPrefix(raw, "```json")
raw = strings.TrimPrefix(raw, "```")
raw = strings.TrimSuffix(raw, "```")
raw = strings.TrimSpace(raw)
var analysis EmailAnalysis
if err := json.Unmarshal([]byte(raw), &analysis); err != nil {
return "", nil, fmt.Errorf("parsing Claude response: %w (raw: %s)", err, raw)
}
if !analysis.IsJobEmail {
return "skipped", nil, nil
}
if analysis.Company == "" || analysis.JobTitle == "" {
return "skipped", nil, nil
}
// Try to find an existing job application
var existing models.JobApplication
found := s.DB.Where("LOWER(company) = LOWER(?) AND LOWER(job_title) = LOWER(?)",
analysis.Company, analysis.JobTitle).
Order("created_at DESC").
First(&existing).Error == nil
if found && analysis.Action == "update" {
return s.updateJobApplication(&existing, &analysis)
}
if !found {
return s.createJobApplication(&analysis)
}
// Found but action is "create" — the record already exists, skip
return "skipped", &existing.ID, nil
}
func (s *EmailSyncService) createJobApplication(analysis *EmailAnalysis) (string, *uint, error) {
app := models.JobApplication{
Company: analysis.Company,
JobTitle: analysis.JobTitle,
Status: analysis.Status,
Location: analysis.Location,
URL: analysis.URL,
Notes: analysis.Notes,
}
if analysis.AppliedAt != nil {
if t, err := time.Parse(time.RFC3339, *analysis.AppliedAt); err == nil {
app.AppliedAt = &t
} else if t, err := time.Parse("2006-01-02", *analysis.AppliedAt); err == nil {
app.AppliedAt = &t
}
}
if app.Status == "" {
app.Status = "applied"
}
if err := s.DB.Create(&app).Error; err != nil {
return "", nil, fmt.Errorf("creating job application: %w", err)
}
log.Printf("[EmailSync] Created job application: %s at %s (status: %s)", app.JobTitle, app.Company, app.Status)
return "created", &app.ID, nil
}
func (s *EmailSyncService) updateJobApplication(existing *models.JobApplication, analysis *EmailAnalysis) (string, *uint, error) {
newOrder, newExists := statusOrder[analysis.Status]
currentOrder, currentExists := statusOrder[existing.Status]
// Only update if the new status represents progression
if !newExists || (currentExists && newOrder <= currentOrder) {
return "skipped", &existing.ID, nil
}
existing.Status = analysis.Status
// Append to notes if Claude provided any
if analysis.Notes != nil && *analysis.Notes != "" {
if existing.Notes != nil && *existing.Notes != "" {
combined := *existing.Notes + "\n" + *analysis.Notes
existing.Notes = &combined
} else {
existing.Notes = analysis.Notes
}
}
if err := s.DB.Save(existing).Error; err != nil {
return "", nil, fmt.Errorf("updating job application: %w", err)
}
log.Printf("[EmailSync] Updated job application: %s at %s (status: %s)", existing.JobTitle, existing.Company, existing.Status)
return "updated", &existing.ID, nil
}
// cleanEmailBody strips HTML and truncates the email body for Claude.
func cleanEmailBody(content string, contentType string) string {
text := content
if strings.EqualFold(contentType, "html") {
// Strip HTML tags
tagRegex := regexp.MustCompile(`<[^>]*>`)
text = tagRegex.ReplaceAllString(text, " ")
// Decode common HTML entities
replacer := strings.NewReplacer(
"&nbsp;", " ",
"&amp;", "&",
"&lt;", "<",
"&gt;", ">",
"&quot;", `"`,
"&#39;", "'",
"&apos;", "'",
)
text = replacer.Replace(text)
}
// Collapse whitespace
spaceRegex := regexp.MustCompile(`[ \t]+`)
text = spaceRegex.ReplaceAllString(text, " ")
nlRegex := regexp.MustCompile(`\n{3,}`)
text = nlRegex.ReplaceAllString(text, "\n\n")
text = strings.TrimSpace(text)
// Remove common signatures
sigPatterns := []string{
"\n-- \n",
"\nSent from my iPhone",
"\nSent from my iPad",
"\nGet Outlook for",
}
for _, pattern := range sigPatterns {
if idx := strings.Index(text, pattern); idx > 0 {
text = text[:idx]
}
}
// Remove forwarded email chains
lines := strings.Split(text, "\n")
var cleaned []string
for _, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), ">") {
continue
}
cleaned = append(cleaned, line)
}
text = strings.Join(cleaned, "\n")
// Truncate to ~4000 characters
if len(text) > 4000 {
text = text[:4000]
}
return strings.TrimSpace(text)
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen]
}
const emailClassificationPrompt = `You are an email classifier for job applications. You will receive the subject line, sender, and body of an email. Determine if this email is related to a job application, and if so, extract structured data.
Return ONLY a JSON object with these exact keys:
- "isJobEmail": boolean - true if this email is about a job application
- "action": "create" | "update" | "none" - whether this represents a new application confirmation or an update to an existing one
- "company": string - the company name
- "jobTitle": string - the job title/position
- "status": string - one of: "applied", "screening", "interviewing", "assessment", "offer", "rejected", "withdrawn"
- "location": string or null - job location if mentioned
- "url": string or null - any application portal URL
- "appliedAt": string or null - ISO 8601 date if an application date is mentioned (YYYY-MM-DD format)
- "notes": string or null - brief summary of what this email communicates (e.g. "Interview scheduled for March 15", "Application confirmed via Greenhouse")
If isJobEmail is false, only include that field. No text, no markdown, no explanation. Just the JSON object.`

View File

@@ -0,0 +1,45 @@
package services
import (
"sync"
"time"
)
type RateLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
max int
window time.Duration
}
func NewRateLimiter(max int, window time.Duration) *RateLimiter {
return &RateLimiter{
attempts: make(map[string][]time.Time),
max: max,
window: window,
}
}
func (rl *RateLimiter) Allow(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
cutoff := now.Add(-rl.window)
// Remove expired entries
valid := rl.attempts[key][:0]
for _, t := range rl.attempts[key] {
if t.After(cutoff) {
valid = append(valid, t)
}
}
if len(valid) >= rl.max {
rl.attempts[key] = valid
return false
}
rl.attempts[key] = append(valid, now)
return true
}

View File

@@ -2,7 +2,7 @@ package services
import ( import (
"net/http" "net/http"
"strings" "net/url"
"sync" "sync"
"time" "time"
@@ -24,11 +24,12 @@ var Upgrader = websocket.Upgrader{
if origin == "" { if origin == "" {
return false return false
} }
origin = strings.TrimPrefix(origin, "https://") u, err := url.Parse(origin)
origin = strings.TrimPrefix(origin, "http://") if err != nil {
// Strip port for localhost comparisons (e.g. "localhost:80") return false
host := strings.Split(origin, ":")[0] }
return origin == allowedDomain || origin == "www."+allowedDomain || host == "localhost" host := u.Hostname()
return host == allowedDomain || host == "www."+allowedDomain || host == "localhost"
}, },
} }

View File

@@ -1,7 +1,8 @@
#!/bin/sh #!/bin/sh
if [ ! -d /etc/letsencrypt/live/${DOMAIN} ]; then certbot certonly --webroot -w /var/www/certbot \
certbot certonly --webroot -w /var/www/certbot --email ${EMAIL} -d ${DOMAIN} -d www.${DOMAIN} --agree-tos --non-interactive; --email ${EMAIL} \
fi; -d ${DOMAIN} -d www.${DOMAIN} -d chat.${DOMAIN} \
--agree-tos --non-interactive --expand;
trap exit TERM; trap exit TERM;

View File

@@ -4,6 +4,7 @@ services:
volumes: volumes:
- ./vue:/app - ./vue:/app
- /app/node_modules - /app/node_modules
- /app/src/wasm
environment: environment:
- NODE_ENV=development - NODE_ENV=development
backend: backend:
@@ -11,10 +12,11 @@ services:
- SPOTIFY_REDIRECT_URI=https://localhost/api/spotify/callback - SPOTIFY_REDIRECT_URI=https://localhost/api/spotify/callback
- GQL_PLAYGROUND=true - GQL_PLAYGROUND=true
- GQL_INTROSPECTION=true - GQL_INTROSPECTION=true
- DEV_MODE=true
- SEED_DB=true
nginx: nginx:
environment: environment:
- DEV_MODE=true - DEV_MODE=true
- SEED_DB=true
ports: ports:
- 80:80 - 80:80
- 443:443 - 443:443

View File

@@ -1,13 +1,21 @@
networks: networks:
app-network: app-network:
driver: bridge driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16
volumes: volumes:
# Postgres database
dbdata: dbdata:
# File upload
uploads: uploads:
# Vue build
vue_dist: vue_dist:
# Searxng data
searxng_data: searxng_data:
# Open-WebUI data
openwebui_data:
services: services:
vue: vue:
@@ -35,6 +43,7 @@ services:
- hasura - hasura
- quartz - quartz
- searxng - searxng
- open-webui
networks: networks:
- app-network - app-network
ports: ports:
@@ -77,6 +86,23 @@ services:
- ./logs:/backend/logs - ./logs:/backend/logs
- uploads:/backend/uploads - uploads:/backend/uploads
- ./icecast2/fallback_music:/backend/fallback_music - ./icecast2/fallback_music:/backend/fallback_music
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
autoheal:
image: willfarrell/autoheal:latest
container_name: autoheal
restart: always
environment:
- AUTOHEAL_CONTAINER_LABEL=all
- AUTOHEAL_INTERVAL=30
- AUTOHEAL_START_PERIOD=60
volumes:
- /var/run/docker.sock:/var/run/docker.sock
db: db:
image: postgres:16 image: postgres:16
@@ -132,7 +158,6 @@ services:
volumes: volumes:
- ${OBSIDIAN_DIR}:/quartz/content:ro - ${OBSIDIAN_DIR}:/quartz/content:ro
searxng: searxng:
build: build:
context: ./searxng context: ./searxng
@@ -148,6 +173,20 @@ services:
volumes: volumes:
- searxng_data:/etc/searxng - searxng_data:/etc/searxng
open-webui:
image: ghcr.io/open-webui/open-webui:main
container_name: "${OPENWEBUI_HOST}"
restart: always
networks:
- app-network
env_file:
- ./.env
environment:
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL}
- WEBUI_AUTH=False
- WEBUI_URL=https://chat.${DOMAIN}
volumes:
- openwebui_data:/app/backend/data
gitea: gitea:
image: docker.gitea.com/gitea:1.25.4-rootless image: docker.gitea.com/gitea:1.25.4-rootless

View File

View File

@@ -1,7 +1,7 @@
#!/bin/sh #!/bin/sh
set -e set -e
# Check if dev mode, certificate exists, or setup mode # Check if DEV_MODE
if [ "$DEV_MODE" = "true" ]; then if [ "$DEV_MODE" = "true" ]; then
echo "Dev mode. Generating self-signed certificate for HTTPS." echo "Dev mode. Generating self-signed certificate for HTTPS."
CERT_DIR="/etc/letsencrypt/live/localhost" CERT_DIR="/etc/letsencrypt/live/localhost"
@@ -12,16 +12,19 @@ if [ "$DEV_MODE" = "true" ]; then
-out "$CERT_DIR/fullchain.pem" \ -out "$CERT_DIR/fullchain.pem" \
-subj "/CN=localhost" 2>/dev/null -subj "/CN=localhost" 2>/dev/null
fi fi
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT} ${HASURA_HOST} ${HASURA_PORT} ${QUARTZ_HOST} ${QUARTZ_PORT} ${UPTIMEKUMA_HOST} ${UPTIMEKUMA_PORT} ${SEARXNG_HOST} ${SEARXNG_PORT} ${WALLABAG_HOST} ${WALLABAG_PORT}' \ # In dev mode, so use nginx_dev.conf.template
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT} ${HASURA_HOST} ${HASURA_PORT} ${QUARTZ_HOST} ${QUARTZ_PORT} ${UPTIMEKUMA_HOST} ${UPTIMEKUMA_PORT} ${SEARXNG_HOST} ${SEARXNG_PORT} ${WALLABAG_HOST} ${WALLABAG_PORT} ${OPENWEBUI_HOST} ${OPENWEBUI_PORT}' \
</etc/nginx/nginx_dev.conf.template \ </etc/nginx/nginx_dev.conf.template \
>/etc/nginx/nginx.conf >/etc/nginx/nginx.conf
elif [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then elif [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then
echo "Certificates found. Using production nginx config." echo "Certificates found. Using production nginx config."
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT} ${HASURA_HOST} ${HASURA_PORT} ${QUARTZ_HOST} ${QUARTZ_PORT} ${UPTIMEKUMA_HOST} ${UPTIMEKUMA_PORT} ${SEARXNG_HOST} ${SEARXNG_PORT} ${WALLABAG_HOST} ${WALLABAG_PORT}' \ # In production with certificates already existing, so use nginx.conf.template
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT} ${HASURA_HOST} ${HASURA_PORT} ${QUARTZ_HOST} ${QUARTZ_PORT} ${UPTIMEKUMA_HOST} ${UPTIMEKUMA_PORT} ${SEARXNG_HOST} ${SEARXNG_PORT} ${WALLABAG_HOST} ${WALLABAG_PORT} ${OPENWEBUI_HOST} ${OPENWEBUI_PORT}' \
</etc/nginx/nginx.conf.template \ </etc/nginx/nginx.conf.template \
>/etc/nginx/nginx.conf >/etc/nginx/nginx.conf
else else
echo "Certificates NOT found. Using setup nginx config." echo "Certificates NOT found. Using setup nginx config."
# In production with no certificates, so use nginx_setup.conf.template and will need restart after generation
envsubst '${DOMAIN}' </etc/nginx/nginx_setup.conf.template >/etc/nginx/nginx.conf envsubst '${DOMAIN}' </etc/nginx/nginx_setup.conf.template >/etc/nginx/nginx.conf
fi fi

View File

@@ -9,10 +9,13 @@ http {
server_tokens off; server_tokens off;
charset utf-8; charset utf-8;
resolver 127.0.0.11 valid=10s;
client_max_body_size 50M; client_max_body_size 50M;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m; limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s; limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
limit_req_zone $binary_remote_addr zone=graphql:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m; limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
log_format compact log_format compact
@@ -70,6 +73,12 @@ http {
http2 on; http2 on;
server_name www.$DOMAIN; server_name www.$DOMAIN;
set $upstream_backend http://$BACKEND_HOST:$BACKEND_PORT;
set $upstream_icecast http://$ICECAST_HOST:$ICECAST_PORT;
set $upstream_gitea http://$GITEA_HOST:$GITEA_PORT;
set $upstream_hasura http://$HASURA_HOST:$HASURA_PORT;
set $upstream_quartz http://$QUARTZ_HOST:$QUARTZ_PORT;
set $upstream_searxng http://$SEARXNG_HOST:$SEARXNG_PORT;
root /etc/nginx/html; root /etc/nginx/html;
index index.html; index index.html;
@@ -138,7 +147,7 @@ http {
location $BACKEND_ENDPOINT/ws { location $BACKEND_ENDPOINT/ws {
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; proxy_pass $upstream_backend;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
@@ -151,7 +160,7 @@ http {
location $BACKEND_ENDPOINT/auth/login { location $BACKEND_ENDPOINT/auth/login {
limit_req zone=login burst=3 nodelay; limit_req zone=login burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; proxy_pass $upstream_backend;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -161,7 +170,17 @@ http {
location $BACKEND_ENDPOINT/messages/upload { location $BACKEND_ENDPOINT/messages/upload {
limit_req zone=upload burst=3 nodelay; limit_req zone=upload burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; proxy_pass $upstream_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location $BACKEND_ENDPOINT/graphql {
limit_req zone=graphql burst=10 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass $upstream_backend;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -171,7 +190,7 @@ http {
location $BACKEND_ENDPOINT/ { location $BACKEND_ENDPOINT/ {
limit_req zone=api burst=20 nodelay; limit_req zone=api burst=20 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; proxy_pass $upstream_backend;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -183,7 +202,8 @@ http {
} }
location /radio/ { location /radio/ {
proxy_pass http://$ICECAST_HOST:$ICECAST_PORT/; rewrite ^/radio/(.*)$ /$1 break;
proxy_pass $upstream_icecast;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -195,7 +215,8 @@ http {
} }
location /gitea/ { location /gitea/ {
proxy_pass http://$GITEA_HOST:$GITEA_PORT/; rewrite ^/gitea/(.*)$ /$1 break;
proxy_pass $upstream_gitea;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -207,7 +228,10 @@ http {
} }
location /hasura/ { location /hasura/ {
proxy_pass http://$HASURA_HOST:$HASURA_PORT/; auth_request /internal/auth/admin-validate;
error_page 401 403 = @auth_denied;
rewrite ^/hasura/(.*)$ /$1 break;
proxy_pass $upstream_hasura;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -222,7 +246,10 @@ http {
} }
location /notes/ { location /notes/ {
proxy_pass http://$QUARTZ_HOST:$QUARTZ_PORT/; auth_request /internal/auth/admin-validate;
error_page 401 403 = @auth_denied;
rewrite ^/notes/(.*)$ /$1 break;
proxy_pass $upstream_quartz;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
@@ -233,12 +260,28 @@ http {
} }
location = /internal/auth/admin-validate {
internal;
rewrite ^ /auth/validate-admin break;
proxy_pass $upstream_backend;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Cookie $http_cookie;
}
location @auth_denied {
return 302 /admin/login?redirect=$request_uri;
}
location /searxng { location /searxng {
return 301 /searxng/; return 301 /searxng/;
} }
location /searxng/ { location /searxng/ {
proxy_pass http://$SEARXNG_HOST:$SEARXNG_PORT/; auth_request /internal/auth/admin-validate;
error_page 401 403 = @auth_denied;
rewrite ^/searxng/(.*)$ /$1 break;
proxy_pass $upstream_searxng;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -246,6 +289,67 @@ http {
} }
}
server {
listen 80;
server_name chat.$DOMAIN;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://chat.$DOMAIN$request_uri;
}
}
server {
listen 443 ssl;
http2 on;
server_name chat.$DOMAIN;
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
set $upstream_openwebui http://$OPENWEBUI_HOST:$OPENWEBUI_PORT;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
client_max_body_size 50M;
location = /internal/auth/admin-validate {
internal;
rewrite ^ /auth/validate-admin break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Cookie $http_cookie;
}
location @auth_denied {
return 302 https://www.$DOMAIN/admin/login?redirect=https://chat.$DOMAIN$request_uri;
}
location / {
auth_request /internal/auth/admin-validate;
error_page 401 403 = @auth_denied;
proxy_pass $upstream_openwebui;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
} }
} }

View File

@@ -9,6 +9,8 @@ http {
server_tokens off; server_tokens off;
charset utf-8; charset utf-8;
resolver 127.0.0.11 valid=10s;
client_max_body_size 50M; client_max_body_size 50M;
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m; limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
@@ -44,6 +46,13 @@ http {
listen 80; listen 80;
server_name $DOMAIN www.$DOMAIN; server_name $DOMAIN www.$DOMAIN;
set $upstream_backend http://$BACKEND_HOST:$BACKEND_PORT;
set $upstream_icecast http://$ICECAST_HOST:$ICECAST_PORT;
set $upstream_gitea http://$GITEA_HOST:$GITEA_PORT;
set $upstream_hasura http://$HASURA_HOST:$HASURA_PORT;
set $upstream_quartz http://$QUARTZ_HOST:$QUARTZ_PORT;
set $upstream_searxng http://$SEARXNG_HOST:$SEARXNG_PORT;
location /uploads/ { location /uploads/ {
alias /uploads/; alias /uploads/;
add_header X-Content-Type-Options nosniff always; add_header X-Content-Type-Options nosniff always;
@@ -65,7 +74,7 @@ http {
location $BACKEND_ENDPOINT/ws { location $BACKEND_ENDPOINT/ws {
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; proxy_pass $upstream_backend;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
@@ -78,7 +87,7 @@ http {
location $BACKEND_ENDPOINT/auth/login { location $BACKEND_ENDPOINT/auth/login {
limit_req zone=login burst=3 nodelay; limit_req zone=login burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; proxy_pass $upstream_backend;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -88,7 +97,7 @@ http {
location $BACKEND_ENDPOINT/messages/upload { location $BACKEND_ENDPOINT/messages/upload {
limit_req zone=upload burst=3 nodelay; limit_req zone=upload burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; proxy_pass $upstream_backend;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -98,7 +107,7 @@ http {
location $BACKEND_ENDPOINT/ { location $BACKEND_ENDPOINT/ {
limit_req zone=api burst=20 nodelay; limit_req zone=api burst=20 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; proxy_pass $upstream_backend;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -110,7 +119,8 @@ http {
} }
location /radio/ { location /radio/ {
proxy_pass http://$ICECAST_HOST:$ICECAST_PORT/; rewrite ^/radio/(.*)$ /$1 break;
proxy_pass $upstream_icecast;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -122,7 +132,8 @@ http {
} }
location /gitea/ { location /gitea/ {
proxy_pass http://$GITEA_HOST:$GITEA_PORT/; rewrite ^/gitea/(.*)$ /$1 break;
proxy_pass $upstream_gitea;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -134,7 +145,10 @@ http {
} }
location /hasura/ { location /hasura/ {
proxy_pass http://$HASURA_HOST:$HASURA_PORT/; auth_request /internal/auth/admin-validate;
error_page 401 403 = @auth_denied;
rewrite ^/hasura/(.*)$ /$1 break;
proxy_pass $upstream_hasura;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -149,7 +163,10 @@ http {
} }
location /notes/ { location /notes/ {
proxy_pass http://$QUARTZ_HOST:$QUARTZ_PORT/; auth_request /internal/auth/admin-validate;
error_page 401 403 = @auth_denied;
rewrite ^/notes/(.*)$ /$1 break;
proxy_pass $upstream_quartz;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
@@ -160,19 +177,34 @@ http {
} }
location = /internal/auth/admin-validate {
internal;
rewrite ^ /auth/validate-admin break;
proxy_pass $upstream_backend;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Cookie $http_cookie;
}
location @auth_denied {
return 302 /admin/login?redirect=$request_uri;
}
location /searxng { location /searxng {
return 301 /searxng/; return 301 /searxng/;
} }
location /searxng/ { location /searxng/ {
proxy_pass http://$SEARXNG_HOST:$SEARXNG_PORT/; auth_request /internal/auth/admin-validate;
error_page 401 403 = @auth_denied;
rewrite ^/searxng/(.*)$ /$1 break;
proxy_pass $upstream_searxng;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
} }
server { server {
@@ -182,6 +214,13 @@ http {
ssl_certificate /etc/letsencrypt/live/localhost/fullchain.pem; ssl_certificate /etc/letsencrypt/live/localhost/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/localhost/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/localhost/privkey.pem;
set $upstream_backend http://$BACKEND_HOST:$BACKEND_PORT;
set $upstream_icecast http://$ICECAST_HOST:$ICECAST_PORT;
set $upstream_gitea http://$GITEA_HOST:$GITEA_PORT;
set $upstream_hasura http://$HASURA_HOST:$HASURA_PORT;
set $upstream_quartz http://$QUARTZ_HOST:$QUARTZ_PORT;
set $upstream_searxng http://$SEARXNG_HOST:$SEARXNG_PORT;
location /uploads/ { location /uploads/ {
alias /uploads/; alias /uploads/;
add_header X-Content-Type-Options nosniff always; add_header X-Content-Type-Options nosniff always;
@@ -203,7 +242,7 @@ http {
location $BACKEND_ENDPOINT/ws { location $BACKEND_ENDPOINT/ws {
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; proxy_pass $upstream_backend;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
@@ -216,7 +255,7 @@ http {
location $BACKEND_ENDPOINT/auth/login { location $BACKEND_ENDPOINT/auth/login {
limit_req zone=login burst=3 nodelay; limit_req zone=login burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; proxy_pass $upstream_backend;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -226,7 +265,7 @@ http {
location $BACKEND_ENDPOINT/messages/upload { location $BACKEND_ENDPOINT/messages/upload {
limit_req zone=upload burst=3 nodelay; limit_req zone=upload burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; proxy_pass $upstream_backend;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -236,7 +275,7 @@ http {
location $BACKEND_ENDPOINT/ { location $BACKEND_ENDPOINT/ {
limit_req zone=api burst=20 nodelay; limit_req zone=api burst=20 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break; rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/; proxy_pass $upstream_backend;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -248,7 +287,8 @@ http {
} }
location /radio/ { location /radio/ {
proxy_pass http://$ICECAST_HOST:$ICECAST_PORT/; rewrite ^/radio/(.*)$ /$1 break;
proxy_pass $upstream_icecast;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -260,7 +300,8 @@ http {
} }
location /gitea/ { location /gitea/ {
proxy_pass http://$GITEA_HOST:$GITEA_PORT/; rewrite ^/gitea/(.*)$ /$1 break;
proxy_pass $upstream_gitea;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -272,7 +313,10 @@ http {
} }
location /hasura/ { location /hasura/ {
proxy_pass http://$HASURA_HOST:$HASURA_PORT/; auth_request /internal/auth/admin-validate;
error_page 401 403 = @auth_denied;
rewrite ^/hasura/(.*)$ /$1 break;
proxy_pass $upstream_hasura;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -287,7 +331,10 @@ http {
} }
location /notes/ { location /notes/ {
proxy_pass http://$QUARTZ_HOST:$QUARTZ_PORT/; auth_request /internal/auth/admin-validate;
error_page 401 403 = @auth_denied;
rewrite ^/notes/(.*)$ /$1 break;
proxy_pass $upstream_quartz;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
@@ -298,19 +345,34 @@ http {
} }
location = /internal/auth/admin-validate {
internal;
rewrite ^ /auth/validate-admin break;
proxy_pass $upstream_backend;
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header Cookie $http_cookie;
}
location @auth_denied {
return 302 /admin/login?redirect=$request_uri;
}
location /searxng { location /searxng {
return 301 /searxng/; return 301 /searxng/;
} }
location /searxng/ { location /searxng/ {
proxy_pass http://$SEARXNG_HOST:$SEARXNG_PORT/; auth_request /internal/auth/admin-validate;
error_page 401 403 = @auth_denied;
rewrite ^/searxng/(.*)$ /$1 break;
proxy_pass $upstream_searxng;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
} }
} }
} }

143
readme.md
View File

@@ -1,143 +0,0 @@
# My Web
## Important TODO
- Get a new background
![screenshot](vue/public/img/screenshot.png)
Welcome to the source code for my website! Please contact me if you would like to collaborate and thank you for visiting.
This website is currently self hosted on my Raspberry Pi. Any interference and the killswitch will activate and stop the UK national grid power system so please don't tamper with my domain :).
## Architecture
All services run in Docker containers orchestrated by Docker Compose:
```
vue ── Frontend build (outputs dist to shared volume)
nginx (80, 443) ── Frontend SPA + Reverse Proxy
backend (8080) ── Go API
db (5432) ── PostgreSQL 16
icecast2 (8000) ── Audio Streaming
gitea (3000) ── Self-Hosted Git
certbot ── SSL Certificate Management
```
## Tech Stack
**Frontend** - Vue 3, Vite, Tailwind CSS v4, Pinia, Vue Router, markdown-it (wikilinks + KaTeX), Rust/WASM
**Backend** - Go (Gin), gqlgen (GraphQL), GORM, PostgreSQL, JWT auth, WebSockets
**Integrations** - Spotify API, Steam API, Anthropic Claude API, Icecast2
**Infrastructure** - Docker Compose, Nginx, Let's Encrypt (Certbot), Gitea + Act Runner
## Features
- Spotify integration (currently playing, recently played)
- Steam integration (online status, recent games)
- Obsidian note viewer with wikilink and LaTeX support
- Live radio streaming via Icecast2
- Real-time chat over WebSockets with image/video uploads
- Blog with admin panel (CRUD)
- Activity and rowing session tracking
- Fan shrines (GTO, Evangelion, Demoman, Skip Skip Benben)
- Self-hosted Git (Gitea) with CI/CD and commit feed on homepage
- Claude AI integration
- Printable CV with role-specific sections
- Landing page with animated stamps section
- Route transitions (slide/fade) and performance optimizations (gzip, WOFF2 fonts, lazy loading)
## Pages
| Route | Description |
| -------------- | ------------------------------------- |
| `/` | Landing page |
| `/stp` | Home dashboard with grid layout |
| `/admin` | Admin panel (authenticated) |
| `/cv` | Curriculum Vitae (printable) |
| `/bookmarks` | Bookmarks |
| `/notes/:path` | Obsidian note viewer |
| `/shrines` | Fan shrine index + individual shrines |
## API
The primary API is **GraphQL** at `POST /api/graphql` (with a playground at `GET /api/graphql`). Queries cover posts, users, favorites, activities, rowing sessions, Spotify (currently playing, recently played), Gitea feed, and messages.
REST endpoints handle auth (`/auth/*`), Spotify OAuth (`/spotify/*`), file uploads (`/messages/upload`), note serving (`/notes/*`), and WebSocket chat (`/api/ws`). Steam data (online status, recent games) is also available via the GraphQL API.
Protected endpoints require JWT authentication via `/auth/login` (tokens set as HTTP-only cookies).
## Local Testing (Dev Mode)
Run the full stack over plain HTTP without SSL certificates:
```
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
```
This uses an HTTP-only nginx config with all routing (SPA, backend proxy, radio, gitea) and disables certbot. Visit `http://localhost` to test.
## Future Ideas
- More Rust to WASM
- ML for chatboards
- Cache requests
- Design more webpages
- Calendar to show radio times
- Nice smooth function background and transitions
- Design shrines
- Redis (not really but practical experience)
## .env
These environment variables are found in the `.env` file. The use of environment variables can be found by reading the code so the security of the variable names are not significant.
```
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_DB=
POSTGRES_PORT=
POSTGRES_HOST=
GITEA_HOST=
GITEA_PORT=
POSTGRES_GITEA_DB=
BACKEND_PORT=
BACKEND_HOST=
BACKEND_SECRET=
BACKEND_ENDPOINT=
CLAUDE_API_KEY=
SEED_DB=
OBSIDIAN_DIR=
SPOTIFY_CLIENT_ID=
SPOTIFY_CLIENT_SECRET=
SPOTIFY_REDIRECT_URI=
SPOTIFY_AUTH_STATE=
STEAM_API_KEY=
STEAM_ID=
ICECAST_SOURCE_PASSWORD=
ICECAST_RELAY_PASSWORD=
ICECAST_ADMIN_USER=
ICECAST_ADMIN_PASSWORD=
ICECAST_HOST=
ICECAST_PORT=
ICECAST_MOUNT=
DOMAIN=
EMAIL=
GITEA_LFS_JWT_SECRET=
GITEA_INTERNAL_TOKEN=
GITEA_OAUTH2_JWT_SECRET=
```

View File

@@ -1,7 +1,17 @@
# Stage 1: Build WASM from Rust
FROM rust:slim AS wasm-builder
RUN rustup target add wasm32-unknown-unknown \
&& cargo install wasm-pack
WORKDIR /wasm
COPY crates/stp_wasm/ crates/stp_wasm/
RUN wasm-pack build crates/stp_wasm --target web --out-dir ../../src/wasm
# Stage 2: Build Vue frontend
FROM node:22-slim FROM node:22-slim
RUN apt-get update && apt-get install -y make git && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y make git && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
CMD ["sh", "-c", "npm run build -- --outDir /output --emptyOutDir"] COPY --from=wasm-builder /wasm/src/wasm/ src/wasm/
CMD ["sh", "-c", "npx vite build --outDir /output --emptyOutDir"]

View File

@@ -3,6 +3,9 @@ name = "stp_wasm"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies] [dependencies]
js-sys = "0.3.85" js-sys = "0.3.85"
wasm-bindgen = "0.2.108" wasm-bindgen = "0.2.108"
@@ -10,6 +13,13 @@ web-sys = { version = "0.3.85", features = [
"console", "console",
"Document", "Document",
"Element", "Element",
"HtmlElement",
"Window", "Window",
"Animation", "Animation",
"CssStyleDeclaration",
"ResizeObserver",
"ResizeObserverEntry",
"ResizeObserverSize",
"EventTarget",
"MouseEvent",
] } ] }

View File

@@ -0,0 +1,259 @@
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use web_sys::HtmlElement;
const SPEED: f64 = 0.0005; // % per frame
const PAUSE: i32 = 2000; // ms at top/bottom
struct Inner {
el: HtmlElement,
pos: f64,
direction: f64, // 1.0 = down, -1.0 = up
hovered: bool,
cached_scroll_height: f64,
raf_id: Option<i32>,
pause_timeout_id: Option<i32>,
resize_observer: Option<web_sys::ResizeObserver>,
// closures kept alive
tick_closure: Option<Closure<dyn FnMut()>>,
resize_closure: Option<Closure<dyn FnMut(js_sys::Array)>>,
mouseenter_closure: Option<Closure<dyn FnMut()>>,
mouseleave_closure: Option<Closure<dyn FnMut()>>,
start_after_pause_closure: Option<Closure<dyn FnMut()>>,
}
#[wasm_bindgen]
pub struct AutoScroller {
inner: Rc<RefCell<Inner>>,
}
impl Inner {
fn measure_scroll_height(&mut self) {
self.cached_scroll_height = self.el.scroll_height() as f64;
}
fn stop_loop(&mut self) {
let window = web_sys::window().unwrap();
if let Some(id) = self.raf_id.take() {
window.cancel_animation_frame(id).ok();
}
if let Some(id) = self.pause_timeout_id.take() {
window.clear_timeout_with_handle(id);
}
}
}
fn start_loop(inner: &Rc<RefCell<Inner>>) {
{
let mut s = inner.borrow_mut();
s.stop_loop();
}
schedule_tick(inner);
}
fn schedule_tick(inner: &Rc<RefCell<Inner>>) {
let inner_clone = Rc::clone(inner);
// Create a fresh tick closure each frame
let closure = Closure::once(move || {
tick(&inner_clone);
});
let mut s = inner.borrow_mut();
let window = web_sys::window().unwrap();
let id = window
.request_animation_frame(closure.as_ref().unchecked_ref())
.unwrap();
s.raf_id = Some(id);
// Store the closure to keep it alive until the frame fires
s.tick_closure = Some(closure);
}
fn tick(inner: &Rc<RefCell<Inner>>) {
let should_continue;
{
let mut s = inner.borrow_mut();
s.raf_id = None;
if s.hovered {
return;
}
if s.cached_scroll_height == 0.0 {
drop(s);
schedule_tick(inner);
return;
}
let reached_bottom = s.pos >= 1.0;
let reached_top = s.pos <= 0.0;
if reached_bottom {
s.pos = 0.999;
s.direction = -1.0;
drop(s);
schedule_pause(inner);
return;
} else if reached_top && s.direction == -1.0 {
s.pos = 0.001;
s.direction = 1.0;
drop(s);
schedule_pause(inner);
return;
}
s.pos += s.direction * SPEED;
s.el.set_scroll_top((s.pos * s.cached_scroll_height) as i32);
should_continue = true;
}
if should_continue {
schedule_tick(inner);
}
}
fn schedule_pause(inner: &Rc<RefCell<Inner>>) {
{
let mut s = inner.borrow_mut();
s.stop_loop();
}
let inner_clone = Rc::clone(inner);
let closure = Closure::once(move || {
start_loop(&inner_clone);
});
let mut s = inner.borrow_mut();
let window = web_sys::window().unwrap();
let id = window
.set_timeout_with_callback_and_timeout_and_arguments_0(
closure.as_ref().unchecked_ref(),
PAUSE,
)
.unwrap();
s.pause_timeout_id = Some(id);
s.start_after_pause_closure = Some(closure);
}
#[wasm_bindgen]
impl AutoScroller {
#[wasm_bindgen(constructor)]
pub fn new(el: HtmlElement) -> AutoScroller {
let inner = Rc::new(RefCell::new(Inner {
el,
pos: 0.0,
direction: 1.0,
hovered: false,
cached_scroll_height: 0.0,
raf_id: None,
pause_timeout_id: None,
resize_observer: None,
tick_closure: None,
resize_closure: None,
mouseenter_closure: None,
mouseleave_closure: None,
start_after_pause_closure: None,
}));
// Set up mouseenter listener
{
let inner_clone = Rc::clone(&inner);
let closure = Closure::wrap(Box::new(move || {
let mut s = inner_clone.borrow_mut();
s.hovered = true;
s.stop_loop();
}) as Box<dyn FnMut()>);
let s = inner.borrow();
s.el
.add_event_listener_with_callback("mouseenter", closure.as_ref().unchecked_ref())
.unwrap();
drop(s);
inner.borrow_mut().mouseenter_closure = Some(closure);
}
// Set up mouseleave listener
{
let inner_clone = Rc::clone(&inner);
let closure = Closure::wrap(Box::new(move || {
{
let mut s = inner_clone.borrow_mut();
s.hovered = false;
if s.cached_scroll_height > 0.0 {
s.pos = s.el.scroll_top() as f64 / s.cached_scroll_height;
}
}
start_loop(&inner_clone);
}) as Box<dyn FnMut()>);
let s = inner.borrow();
s.el
.add_event_listener_with_callback("mouseleave", closure.as_ref().unchecked_ref())
.unwrap();
drop(s);
inner.borrow_mut().mouseleave_closure = Some(closure);
}
AutoScroller { inner }
}
pub fn start(&self) {
// Measure initial scroll height
self.inner.borrow_mut().measure_scroll_height();
// Set up resize observer
let inner_clone = Rc::clone(&self.inner);
let resize_closure = Closure::wrap(Box::new(move |_entries: js_sys::Array| {
inner_clone.borrow_mut().measure_scroll_height();
}) as Box<dyn FnMut(js_sys::Array)>);
let observer =
web_sys::ResizeObserver::new(resize_closure.as_ref().unchecked_ref()).unwrap();
// Clone the element ref and drop the borrow before calling observe(),
// because observe() can fire the resize callback synchronously,
// which would conflict with an active borrow.
let el_clone = self.inner.borrow().el.clone();
observer.observe(&el_clone);
{
let mut s = self.inner.borrow_mut();
s.resize_observer = Some(observer);
s.resize_closure = Some(resize_closure);
}
// Start with a pause then begin scrolling
schedule_pause(&self.inner);
}
pub fn destroy(&self) {
let mut s = self.inner.borrow_mut();
s.stop_loop();
if let Some(observer) = s.resize_observer.take() {
observer.disconnect();
}
if let Some(ref closure) = s.mouseenter_closure {
s.el
.remove_event_listener_with_callback(
"mouseenter",
closure.as_ref().unchecked_ref(),
)
.ok();
}
if let Some(ref closure) = s.mouseleave_closure {
s.el
.remove_event_listener_with_callback(
"mouseleave",
closure.as_ref().unchecked_ref(),
)
.ok();
}
s.mouseenter_closure = None;
s.mouseleave_closure = None;
s.resize_closure = None;
s.tick_closure = None;
s.start_after_pause_closure = None;
}
}

View File

@@ -0,0 +1,135 @@
use std::cell::RefCell;
use std::rc::Rc;
use wasm_bindgen::prelude::*;
use web_sys::HtmlElement;
const SPEED: f64 = 0.5; // pixels per frame
struct Inner {
container: HtmlElement,
item1: HtmlElement,
offset: f64,
cached_width: f64,
raf_id: Option<i32>,
resize_observer: Option<web_sys::ResizeObserver>,
// closures kept alive
animate_closure: Option<Closure<dyn FnMut()>>,
resize_closure: Option<Closure<dyn FnMut(js_sys::Array)>>,
}
#[wasm_bindgen]
pub struct HeadlineScroller {
inner: Rc<RefCell<Inner>>,
}
impl Inner {
fn measure_width(&mut self) {
let container_width = self.container.offset_width() as f64;
let item_width = self.item1.scroll_width() as f64;
self.cached_width = container_width.max(item_width);
}
}
fn schedule_frame(inner: &Rc<RefCell<Inner>>) {
let inner_clone = Rc::clone(inner);
let closure = Closure::once(move || {
animate(&inner_clone);
});
let mut s = inner.borrow_mut();
let window = web_sys::window().unwrap();
let id = window
.request_animation_frame(closure.as_ref().unchecked_ref())
.unwrap();
s.raf_id = Some(id);
s.animate_closure = Some(closure);
}
fn animate(inner: &Rc<RefCell<Inner>>) {
{
let mut s = inner.borrow_mut();
s.raf_id = None;
if s.cached_width == 0.0 {
drop(s);
schedule_frame(inner);
return;
}
s.offset -= SPEED;
if s.offset <= -s.cached_width {
s.offset += s.cached_width;
}
let transform = format!("translateX({}px)", s.offset);
s.container.style().set_property("transform", &transform).ok();
}
schedule_frame(inner);
}
#[wasm_bindgen]
impl HeadlineScroller {
#[wasm_bindgen(constructor)]
pub fn new(container: HtmlElement, item1: HtmlElement) -> HeadlineScroller {
let inner = Rc::new(RefCell::new(Inner {
container,
item1,
offset: 0.0,
cached_width: 0.0,
raf_id: None,
resize_observer: None,
animate_closure: None,
resize_closure: None,
}));
HeadlineScroller { inner }
}
pub fn start(&self) {
// Measure initial width
self.inner.borrow_mut().measure_width();
// Set up resize observer
let inner_clone = Rc::clone(&self.inner);
let resize_closure = Closure::wrap(Box::new(move |_entries: js_sys::Array| {
inner_clone.borrow_mut().measure_width();
}) as Box<dyn FnMut(js_sys::Array)>);
let observer =
web_sys::ResizeObserver::new(resize_closure.as_ref().unchecked_ref()).unwrap();
// Clone the element ref and drop the borrow before calling observe(),
// because observe() can fire the resize callback synchronously,
// which would conflict with an active borrow.
let container_clone = self.inner.borrow().container.clone();
observer.observe(&container_clone);
{
let mut s = self.inner.borrow_mut();
s.resize_observer = Some(observer);
s.resize_closure = Some(resize_closure);
}
// Start animation loop
schedule_frame(&self.inner);
}
pub fn destroy(&self) {
let mut s = self.inner.borrow_mut();
let window = web_sys::window().unwrap();
if let Some(id) = s.raf_id.take() {
window.cancel_animation_frame(id).ok();
}
if let Some(observer) = s.resize_observer.take() {
observer.disconnect();
}
s.animate_closure = None;
s.resize_closure = None;
}
}

View File

@@ -1,6 +1,5 @@
use wasm_bindgen::prelude::*; mod auto_scroll;
mod headline;
#[wasm_bindgen] pub use auto_scroll::AutoScroller;
pub struct BadApplePlayer { pub use headline::HeadlineScroller;
is_playing: bool,
}

View File

@@ -1,31 +1,31 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Adam French's personal website"> <meta name="description" content="Adam French's personal website" />
<title>AF</title> <title>AF</title>
<link rel="preconnect" href="https://i.scdn.co" crossorigin> <link rel="preconnect" href="https://i.scdn.co" crossorigin />
<link <link
rel="preconnect" rel="preconnect"
href="https://cdn.akamai.steamstatic.com" href="https://cdn.akamai.steamstatic.com"
crossorigin crossorigin
> />
<link rel="icon" type="/img/x-icon" href="/img/favicon.ico"> <link rel="icon" type="/img/x-icon" href="/img/favicon.ico" />
<link <link
rel="preload" rel="preload"
href="/fonts/big_noodle_titling.woff2" href="/fonts/big_noodle_titling.woff2"
as="font" as="font"
type="font/woff2" type="font/woff2"
crossorigin crossorigin
> />
<link <link
rel="preload" rel="preload"
href="/fonts/CreatoDisplay-Bold.woff2" href="/fonts/CreatoDisplay-Bold.woff2"
as="font" as="font"
type="font/woff2" type="font/woff2"
crossorigin crossorigin
> />
</head> </head>
<body id="app"> <body id="app">
<script type="module" src="/src/main.js"></script> <script type="module" src="/src/main.js"></script>

357
vue/package-lock.json generated
View File

@@ -24,8 +24,10 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.1.11", "vite": "^7.3.2",
"vite-plugin-vue-devtools": "^8.0.3" "vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-vue-devtools": "^8.0.3",
"vite-plugin-wasm": "^3.6.0"
}, },
"engines": { "engines": {
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
@@ -1024,6 +1026,24 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/plugin-virtual": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@rollup/plugin-virtual/-/plugin-virtual-3.0.2.tgz",
"integrity": "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.59.0", "version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
@@ -1349,6 +1369,293 @@
"win32" "win32"
] ]
}, },
"node_modules/@swc/core": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.24.tgz",
"integrity": "sha512-5Hj8aNasue7yusUt8LGCUe/AjM7RMAce8ZoyDyiFwx7Al+GbYKL+yE7g4sJk8vEr1dKIkTRARkNIJENc4CjkBQ==",
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3",
"@swc/types": "^0.1.26"
},
"engines": {
"node": ">=10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/swc"
},
"optionalDependencies": {
"@swc/core-darwin-arm64": "1.15.24",
"@swc/core-darwin-x64": "1.15.24",
"@swc/core-linux-arm-gnueabihf": "1.15.24",
"@swc/core-linux-arm64-gnu": "1.15.24",
"@swc/core-linux-arm64-musl": "1.15.24",
"@swc/core-linux-ppc64-gnu": "1.15.24",
"@swc/core-linux-s390x-gnu": "1.15.24",
"@swc/core-linux-x64-gnu": "1.15.24",
"@swc/core-linux-x64-musl": "1.15.24",
"@swc/core-win32-arm64-msvc": "1.15.24",
"@swc/core-win32-ia32-msvc": "1.15.24",
"@swc/core-win32-x64-msvc": "1.15.24"
},
"peerDependencies": {
"@swc/helpers": ">=0.5.17"
},
"peerDependenciesMeta": {
"@swc/helpers": {
"optional": true
}
}
},
"node_modules/@swc/core-darwin-arm64": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.24.tgz",
"integrity": "sha512-uM5ZGfFXjtvtJ+fe448PVBEbn/CSxS3UAyLj3O9xOqKIWy3S6hPTXSPbszxkSsGDYKi+YFhzAsR4r/eXLxEQ0g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-darwin-x64": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.24.tgz",
"integrity": "sha512-fMIb/Zfn929pw25VMBhV7Ji2Dl+lCWtUPNdYJQYOke+00E5fcQ9ynxtP8+qhUo/HZc+mYQb1gJxwHM9vty+lXg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm-gnueabihf": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.24.tgz",
"integrity": "sha512-vOkjsyjjxnoYx3hMEWcGxQrMgnNrRm6WAegBXrN8foHtDAR+zpdhpGF5a4lj1bNPgXAvmysjui8cM1ov/Clkaw==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-gnu": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.24.tgz",
"integrity": "sha512-h/oNu+upkXJ6Cicnq7YGVj9PkdfarLCdQa8l/FlHYvfv8CEiMaeeTnpLU7gSBH/rGxosM6Qkfa/J9mThGF9CLA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-arm64-musl": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.24.tgz",
"integrity": "sha512-ZpF/pRe1guk6sKzQI9D1jAORtjTdNlyeXn9GDz8ophof/w2WhojRblvSDJaGe7rJjcPN8AaOkhwdRUh7q8oYIg==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-ppc64-gnu": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.24.tgz",
"integrity": "sha512-QZEsZfisHTSJlmyChgDFNmKPb3W6Lhbfo/O76HhIngfEdnQNmukS38/VSe1feho+xkV5A5hETyCbx3sALBZKAQ==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-s390x-gnu": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.24.tgz",
"integrity": "sha512-DLdJKVsJgglqQrJBuoUYNmzm3leI7kUZhLbZGHv42onfKsGf6JDS3+bzCUQfte/XOqDjh/tmmn1DR/CF/tCJFw==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-gnu": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.24.tgz",
"integrity": "sha512-IpLYfposPA/XLxYOKpRfeccl1p5dDa3+okZDHHTchBkXEaVCnq5MADPmIWwIYj1tudt7hORsEHccG5no6IUQRw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-linux-x64-musl": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.24.tgz",
"integrity": "sha512-JHy3fMSc0t/EPWgo74+OK5TGr51aElnzqfUPaiRf2qJ/BfX5CUCfMiWVBuhI7qmVMBnk1jTRnL/xZnOSHDPLYg==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-arm64-msvc": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.24.tgz",
"integrity": "sha512-Txj+qUH1z2bUd1P3JvwByfjKFti3cptlAxhWgmunBUUxy/IW3CXLZ6l6Gk4liANadKkU71nIU1X30Z5vpMT3BA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-ia32-msvc": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.24.tgz",
"integrity": "sha512-15D/nl3XwrhFpMv+MADFOiVwv3FvH9j8c6Rf8EXBT3Q5LoMh8YnDnSgPYqw1JzPnksvsBX6QPXLiPqmcR/Z4qQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/core-win32-x64-msvc": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.24.tgz",
"integrity": "sha512-PR0PlTlPra2JbaDphrOAzm6s0v9rA0F17YzB+XbWD95B4g2cWcZY9LAeTa4xll70VLw9Jr7xBrlohqlQmelMFQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
"integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@swc/types": {
"version": "0.1.26",
"resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz",
"integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@swc/counter": "^0.1.3"
}
},
"node_modules/@swc/wasm": {
"version": "1.15.24",
"resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.24.tgz",
"integrity": "sha512-vFjzOE8dhJcfeTbM4+HO9Qy58IINV0ysqStAgw81uds+KqCeUDM9huN+SZ5lWZ6U+5nf8VcZoEw5N81xMtAidg==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.1.18", "version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
@@ -3518,10 +3825,24 @@
"integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==", "integrity": "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==",
"license": "(WTFPL OR MIT)" "license": "(WTFPL OR MIT)"
}, },
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
@@ -3661,6 +3982,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/vite-plugin-top-level-await": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/vite-plugin-top-level-await/-/vite-plugin-top-level-await-1.6.0.tgz",
"integrity": "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/plugin-virtual": "^3.0.2",
"@swc/core": "^1.12.14",
"@swc/wasm": "^1.12.14",
"uuid": "10.0.0"
},
"peerDependencies": {
"vite": ">=2.8"
}
},
"node_modules/vite-plugin-vue-devtools": { "node_modules/vite-plugin-vue-devtools": {
"version": "8.0.5", "version": "8.0.5",
"resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-8.0.5.tgz", "resolved": "https://registry.npmjs.org/vite-plugin-vue-devtools/-/vite-plugin-vue-devtools-8.0.5.tgz",
@@ -3736,6 +4073,16 @@
"vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0" "vite": "^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0"
} }
}, },
"node_modules/vite-plugin-wasm": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.6.0.tgz",
"integrity": "sha512-mL/QPziiIA4RAA6DkaZZzOstdwbW5jO4Vz7Zenj0wieKWBlNvIvX5L5ljum9lcUX0ShNfBgCNLKTjNkRVVqcsw==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8"
}
},
"node_modules/vue": { "node_modules/vue": {
"version": "3.5.26", "version": "3.5.26",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",

View File

@@ -7,8 +7,9 @@
"node": "^20.19.0 || >=22.12.0" "node": "^20.19.0 || >=22.12.0"
}, },
"scripts": { "scripts": {
"build:wasm": "wasm-pack build crates/stp_wasm --target web --out-dir ../../src/wasm",
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "npm run build:wasm && vite build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -28,7 +29,9 @@
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
"vite": "^7.1.11", "vite": "^7.3.2",
"vite-plugin-vue-devtools": "^8.0.3" "vite-plugin-top-level-await": "^1.6.0",
"vite-plugin-vue-devtools": "^8.0.3",
"vite-plugin-wasm": "^3.6.0"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

View File

@@ -1,32 +1,7 @@
<script setup> <script setup>
import { RouterView } from "vue-router"; import { RouterView } from "vue-router";
import Navbar from "@/components/Navbar.vue";
import Footer from "@/components/Footer.vue";
</script> </script>
<template> <template>
<div class="app-layout halftone"> <RouterView />
<Navbar class="no-print sticky top-0 z-50" />
<main class="app-content">
<RouterView v-slot="{ Component }">
<Transition name="slide" mode="out-in">
<component :is="Component" :key="$route.path" />
</Transition>
</RouterView>
</main>
<Footer class="no-print sticky bottom-0 z-50" />
</div>
</template> </template>
<style scoped>
.app-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.app-content {
flex: 1;
overflow-y: auto;
}
</style>

View File

@@ -101,8 +101,30 @@
/* ELEMENTS */ /* ELEMENTS */
body { body {
margin: 0 auto; margin: 0 auto;
width: 100vw; width: 100%;
height: 100vh; height: 100vh;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--primary) var(--bg_secondary);
}
/* Chrome/Edge scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--bg_secondary);
}
::-webkit-scrollbar-thumb {
background: var(--quaternary);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--primary);
} }
input { input {
@@ -139,17 +161,14 @@ h4 {
h3, h3,
h4 { h4 {
font-size: 1.125rem; font-size: 1.125rem;
line-height: 1.75rem;
} }
h1 { h1 {
font-size: 1.5rem; font-size: 1.5rem;
line-height: 2rem;
} }
h2 { h2 {
font-size: 1.25rem; font-size: 1.25rem;
line-height: 1.75rem;
} }
p { p {

View File

@@ -17,13 +17,13 @@ function toggle() {
<template> <template>
<button <button
@click="toggle" @click="toggle"
class="box-content border-2 border-primary w-20 h-fit rounded-full cursor-pointer" class="box-content border-2 border-primary w-10 h-fit rounded-full cursor-pointer"
:class="[props.modelValue ? 'bg-bg_secondary' : 'bg-bg_primary']" :class="[props.modelValue ? 'bg-bg_secondary' : 'bg-bg_primary']"
> >
<svg <svg
viewBox="0 0 40 40" viewBox="0 0 40 40"
class="w-10 h-10 transition-all duration-300 ease-in-out" class="w-5 h-5 transition-all duration-300 ease-in-out"
:class="[props.modelValue ? 'ml-10' : 'ml-0']" :class="[props.modelValue ? 'ml-5' : 'ml-0']"
> >
<circle class="fill-primary" cx="20" cy="20" r="20" /> <circle class="fill-primary" cx="20" cy="20" r="20" />
</svg> </svg>

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="w-full border-b border-primary pb-2 mb-4"> <div class="w-full border-b border-primary pb-0.5">
<h1 class="p-0 m-0"> <h1 class="p-0 m-0">
<slot /> <slot />
</h1> </h1>

View File

@@ -19,7 +19,13 @@ const computedRel = computed(() => {
<RouterLink v-if="to" :to="to" class="inline-link"> <RouterLink v-if="to" :to="to" class="inline-link">
<slot /> <slot />
</RouterLink> </RouterLink>
<a v-else :href="href" :target="target" :rel="computedRel" class="inline-link"> <a
v-else
:href="href"
:target="target"
:rel="computedRel"
class="inline-link"
>
<slot /> <slot />
</a> </a>
</template> </template>

View File

@@ -20,7 +20,13 @@ const computedRel = computed(() => {
<RouterLink v-if="to" :to="to" :class="{ link: !bare }"> <RouterLink v-if="to" :to="to" :class="{ link: !bare }">
<slot /> <slot />
</RouterLink> </RouterLink>
<a v-else :href="href" :target="target" :rel="computedRel" :class="{ link: !bare }"> <a
v-else
:href="href"
:target="target"
:rel="computedRel"
:class="{ link: !bare }"
>
<slot /> <slot />
</a> </a>
</template> </template>

View File

@@ -24,13 +24,14 @@ const handleClick = () => {
class="w-full border-b border-primary cursor-pointer" class="w-full border-b border-primary cursor-pointer"
@click="handleClick" @click="handleClick"
> >
<h1 class="pl-2 m-0"> <h3 class="pl-2 m-0">
<slot /> <slot />
</h1> </h3>
<ToggleButton <ToggleButton
class="pointer-events-none" class="pointer-events-none"
:model-value="props.modelValue" :model-value="props.modelValue"
@update:model-value="updateValue" @update:model-value="updateValue"
@click.stop
ref="toggleButtonRef" ref="toggleButtonRef"
/> />
</div> </div>

View File

@@ -1,5 +1,10 @@
<template> <template>
<div ref="container" @mouseenter="onMouseEnter" @mouseleave="onMouseLeave" class="overflow-y-auto"> <div
ref="container"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
class="overflow-y-auto"
>
<slot /> <slot />
</div> </div>
</template> </template>

View File

@@ -20,8 +20,7 @@ let resizeObserver = null;
function scrollToBottom() { function scrollToBottom() {
if (messagesContainer.value) { if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
messagesContainer.value.scrollHeight;
} }
} }
@@ -146,9 +145,7 @@ onUnmounted(() => {
> >
<span class="text-tertiary">{{ message.authorId }}:</span> <span class="text-tertiary">{{ message.authorId }}:</span>
<template <template
v-for="(part, i) in parseMessageParts( v-for="(part, i) in parseMessageParts(message.text || '')"
message.text || '',
)"
:key="i" :key="i"
> >
<Link <Link
@@ -161,9 +158,7 @@ onUnmounted(() => {
> >
<span v-else>{{ part.value }}</span> <span v-else>{{ part.value }}</span>
</template> </template>
<template <template v-if="message.fileUrl && isSafeFileUrl(message.fileUrl)">
v-if="message.fileUrl && isSafeFileUrl(message.fileUrl)"
>
<img <img
v-if="isImageUrl(message.fileUrl)" v-if="isImageUrl(message.fileUrl)"
:src="message.fileUrl" :src="message.fileUrl"

View File

@@ -21,7 +21,12 @@ const { gitFeed: feed, loaded } = storeToRefs(homeData);
class="flex-1 flex flex-col items-center overflow-y-auto overflow-x-hidden" class="flex-1 flex flex-col items-center overflow-y-auto overflow-x-hidden"
> >
<h3>Last git activity</h3> <h3>Last git activity</h3>
<img :src="feed.avatarUrl" alt="User avatar" class="avatar" loading="lazy" /> <img
:src="feed.avatarUrl"
alt="User avatar"
class="avatar"
loading="lazy"
/>
<Link :href="feed.repoUrl"> <Link :href="feed.repoUrl">
<h3>repo: {{ feed.repoName }}</h3> <h3>repo: {{ feed.repoName }}</h3>
</Link> </Link>

View File

@@ -23,7 +23,7 @@ const show = ref(false);
<template> <template>
<div v-if="title" class="h-fit w-full"> <div v-if="title" class="h-fit w-full">
<ToggleHeader v-model="show" class="justify-between flex"> <ToggleHeader v-model="show" class="justify-between flex items-center">
{{ title }} {{ title }}
</ToggleHeader> </ToggleHeader>
<template v-if="show"> <template v-if="show">
@@ -50,11 +50,7 @@ const show = ref(false);
</div> </div>
<template v-else> <template v-else>
<template v-if="variant === 'list'"> <template v-if="variant === 'list'">
<Link <Link v-for="(item, i) in items" :key="i" :href="item.link">
v-for="(item, i) in items"
:key="i"
:href="item.link"
>
<p class="bdr-2 bg-bg_tertiary">{{ item.name }}</p> <p class="bdr-2 bg-bg_tertiary">{{ item.name }}</p>
</Link> </Link>
</template> </template>

View File

@@ -1,13 +1,12 @@
<script setup> <script setup>
import Button from "@/components/input/Button.vue"; import Button from "@/components/input/Button.vue";
</script> </script>
<template> <template>
<audio /> <audio />
<div class="musicPlayerGrid"> <div class="musicPlayerGrid">
<div class="album_cover"> <div class="album_cover">
<img src="/img/Untitled.png" alt=""></img> <img src="/img/Untitled.png" alt="" />
</div> </div>
<div class="controls"> <div class="controls">
<div class="sliders"> <div class="sliders">

View File

@@ -0,0 +1,26 @@
<template>
<div ref="container" class="overflow-y-auto">
<slot />
</div>
</template>
<script setup>
import { useTemplateRef, onMounted, onBeforeUnmount } from "vue";
import { AutoScroller } from "@/wasm/stp_wasm.js";
const container = useTemplateRef("container");
let scroller = null;
onMounted(() => {
if (!container.value) return;
scroller = new AutoScroller(container.value);
scroller.start();
});
onBeforeUnmount(() => {
scroller?.destroy();
scroller?.free();
scroller = null;
});
</script>

View File

@@ -2,6 +2,7 @@ import axios from "axios";
export async function gql(query, variables = {}) { export async function gql(query, variables = {}) {
const res = await axios.post("/api/graphql", { query, variables }); const res = await axios.post("/api/graphql", { query, variables });
if (res.data.errors && !res.data.data) throw new Error(res.data.errors[0].message); if (res.data.errors && !res.data.data)
throw new Error(res.data.errors[0].message);
return res.data.data; return res.data.data;
} }

View File

@@ -0,0 +1,71 @@
<script setup>
import { RouterView } from "vue-router";
</script>
<template>
<div class="cv-layout">
<RouterView />
</div>
</template>
<style scoped>
.cv-layout {
min-height: 100vh;
background: white;
color: #111;
}
</style>
<style>
.cv-layout h1,
.cv-layout h2,
.cv-layout h3,
.cv-layout h4,
.cv-layout p,
.cv-layout small,
.cv-layout code,
.cv-layout ul,
.cv-layout li,
.cv-layout td,
.cv-layout tr,
.cv-layout table {
color: #111;
}
.cv-layout h1,
.cv-layout h2,
.cv-layout h3,
.cv-layout h4 {
margin: 0;
}
.cv-layout a {
color: #111;
background-color: transparent !important;
letter-spacing: normal;
}
.cv-layout input,
.cv-layout textarea {
color: #111;
background-color: white;
border: 1px solid #ccc;
padding: 0;
width: auto;
}
.cv-layout input::placeholder,
.cv-layout textarea::placeholder {
color: #999;
opacity: 1;
}
.cv-layout table {
border: 0 solid transparent;
}
.cv-layout tr {
border-color: transparent;
}
.cv-layout th {
border: none;
padding: 0;
}
.cv-layout td {
padding: 0;
}
</style>

View File

@@ -0,0 +1,32 @@
<script setup>
import { RouterView } from "vue-router";
import Navbar from "@/components/Navbar.vue";
import Footer from "@/components/Footer.vue";
</script>
<template>
<div class="default-layout halftone">
<Navbar class="no-print sticky top-0 z-50" />
<main class="default-content">
<RouterView v-slot="{ Component }">
<Transition name="slide" mode="out-in">
<component :is="Component" :key="$route.path" />
</Transition>
</RouterView>
</main>
<Footer class="no-print sticky bottom-0 z-50" />
</div>
</template>
<style scoped>
.default-layout {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.default-content {
flex: 1;
overflow-y: auto;
}
</style>

View File

@@ -3,6 +3,9 @@ import { createPinia } from "pinia";
import App from "./App.vue"; import App from "./App.vue";
import router from "./router"; import router from "./router";
import "./assets/styles.css"; import "./assets/styles.css";
import init from "@/wasm/stp_wasm.js";
await init();
const app = createApp(App); const app = createApp(App);

View File

@@ -1,70 +1,116 @@
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
import Landing from "@/views/Landing.vue"; import { watch } from "vue";
import DefaultLayout from "@/layouts/DefaultLayout.vue";
import CVLayout from "@/layouts/CVLayout.vue";
import Landing from "@/views/landing/Landing.vue";
import { useHomeDataStore } from "@/stores/homeData";
import { useAuthStore } from "@/stores/auth";
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
routes: [ routes: [
{ {
path: "/", path: "/",
component: DefaultLayout,
children: [
{
path: "",
name: "landing", name: "landing",
component: Landing, component: Landing,
}, },
{ {
path: "/stp", path: "stp",
name: "home", name: "home",
component: () => import("@/views/home/Home.vue"), component: () => import("@/views/home/Home.vue"),
}, },
{ {
path: "/cv", path: "admin/login",
name: "cv", name: "admin-login",
component: () => import("../views/CV/CV.vue"), component: () => import("@/views/admin/Login.vue"),
}, },
{ {
path: "/admin", path: "admin",
name: "admin", name: "admin",
component: () => import("../views/admin/Admin.vue"), component: () => import("@/views/admin/Admin.vue"),
meta: { requiresAdmin: true },
}, },
{ {
path: "/bookmarks", path: "shrines",
name: "bookmarks",
component: () => import("../views/Bookmarks.vue"),
},
{
path: "/notes/:path(.*)*",
name: "notes",
component: () => import("../views/Notes.vue"),
},
{
path: "/shrines",
name: "shrine links", name: "shrine links",
component: () => import("../views/Shrines.vue"), component: () => import("@/views/home/shrines/Shrines.vue"),
}, },
{ {
path: "/shrines/gto", path: "shrines/gto",
name: "gto shrine", name: "gto shrine",
component: () => import("../views/shrines/GTO.vue"), component: () => import("@/views/home/shrines/GTO.vue"),
}, },
{ {
path: "/shrines/skipskipbenben", path: "shrines/skipskipbenben",
name: "skipskipbenben shrine", name: "skipskipbenben shrine",
component: () => import("../views/shrines/Skipskipbenben.vue"), component: () =>
import("@/views/home/shrines/Skipskipbenben.vue"),
}, },
{ {
path: "/shrines/evangelion", path: "shrines/evangelion",
name: "evangelion shrine", name: "evangelion shrine",
component: () => import("../views/shrines/Evangelion.vue"), component: () =>
import("@/views/home/shrines/Evangelion.vue"),
}, },
{ {
path: "/shrines/demoman", path: "shrines/demoman",
name: "demoman shrine", name: "demoman shrine",
component: () => import("../views/shrines/Demoman.vue"), component: () => import("@/views/home/shrines/Demoman.vue"),
}, },
{ {
path: "/:pathMatch(.*)*", path: ":pathMatch(.*)*",
name: "404", name: "404",
component: () => import("../views/404.vue"), component: () => import("@/views/404/404.vue"),
},
],
},
{
path: "/stp2",
name: "home2",
component: () => import("@/views/home2/Home2.vue"),
},
{
path: "/cv",
component: CVLayout,
children: [
{
path: "",
name: "cv",
component: () => import("@/views/CV/CV.vue"),
},
{
path: "jobs",
name: "job-applications",
component: () => import("@/views/CV/JobApplications.vue"),
meta: { requiresAdmin: true },
},
],
}, },
], ],
}); });
router.beforeEach(async (to) => {
if (!to.meta.requiresAdmin) return;
const homeData = useHomeDataStore();
if (!homeData.loaded) {
await new Promise((resolve) => {
const stop = watch(
() => homeData.loaded,
(val) => {
if (val) {
stop();
resolve();
}
},
);
});
}
if (!useAuthStore().user.admin)
return { path: "/admin/login", query: { redirect: to.fullPath } };
});
export default router; export default router;

View File

@@ -15,6 +15,7 @@ export const useHomeDataStore = defineStore("homeData", () => {
const rowingSessions = ref([]); const rowingSessions = ref([]);
const gitFeed = ref(null); const gitFeed = ref(null);
const steamStatus = ref(null); const steamStatus = ref(null);
const bookmarks = ref([]);
const radioLive = ref(false); const radioLive = ref(false);
async function fetchAll() { async function fetchAll() {
@@ -27,6 +28,7 @@ export const useHomeDataStore = defineStore("homeData", () => {
activities { id type name link createdAt } activities { id type name link createdAt }
spotifyRecent { track { name album { name images { url } } artists { name } } playedAt } spotifyRecent { track { name album { name images { url } } artists { name } } playedAt }
rowingSessions { id date time distance timePer500m calories } rowingSessions { id date time distance timePer500m calories }
bookmarks { id category name link }
giteaFeed { avatarUrl repoUrl repoName opType commitMessage createdAt } giteaFeed { avatarUrl repoUrl repoName opType commitMessage createdAt }
steamStatus { online recentGames { appId name playtime2Weeks playtimeForever headerImageUrl } } steamStatus { online recentGames { appId name playtime2Weeks playtimeForever headerImageUrl } }
me { id username admin } me { id username admin }
@@ -38,6 +40,7 @@ export const useHomeDataStore = defineStore("homeData", () => {
favorites.value = data.favorites; favorites.value = data.favorites;
activities.value = data.activities; activities.value = data.activities;
spotifyRecent.value = data.spotifyRecent || []; spotifyRecent.value = data.spotifyRecent || [];
bookmarks.value = data.bookmarks || [];
rowingSessions.value = data.rowingSessions; rowingSessions.value = data.rowingSessions;
gitFeed.value = data.giteaFeed || null; gitFeed.value = data.giteaFeed || null;
steamStatus.value = data.steamStatus || null; steamStatus.value = data.steamStatus || null;
@@ -64,6 +67,7 @@ export const useHomeDataStore = defineStore("homeData", () => {
loaded, loaded,
error, error,
me, me,
bookmarks,
posts, posts,
favorites, favorites,
activities, activities,

View File

@@ -1,13 +0,0 @@
<template>
<main class="flex flex-col items-center">
<div
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
>
<h1>404</h1>
<RouterLink to="/" class="bdr-2">
<img src="/img/memes/epic.jpeg" loading="lazy" />
</RouterLink>
<h1>Click her, she will take you home</h1>
</div>
</main>
</template>

13
vue/src/views/404/404.vue Normal file
View File

@@ -0,0 +1,13 @@
<template>
<main class="flex flex-col items-center">
<div
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
>
<h1>404</h1>
<RouterLink to="/" class="bdr-2">
<img src="/img/memes/epic.jpeg" loading="lazy" />
</RouterLink>
<h1>Click her, she will take you home</h1>
</div>
</main>
</template>

View File

@@ -1,255 +0,0 @@
<script setup>
import LinkTable from "@/components/util/LinkTable.vue";
const links = [
[
"Reading Links",
[
{
name: "Substack",
link: "https://substack.com/",
},
{
name: "Medium",
link: "https://medium.com/",
},
{
name: "4Chan",
link: "https://www.4chan.org/",
},
],
],
[
"Job Links",
[
{
name: "LinkedIn",
link: "https://www.linkedin.com/",
},
{
name: "Jack and Jill",
link: "https://app.jackandjill.ai",
},
{
name: "LinkedIn",
link: "https://www.linkedin.com/",
},
{
name: "Prospects",
link: "https://www.prospects.ac.uk/",
},
{
name: "GOV",
link: "https://findajob.dwp.gov.uk",
},
{
name: "Glassdoor",
link: "https://www.glassdoor.co.uk/",
},
{
name: "Indeed",
link: "https://www.indeed.co.uk/",
},
],
],
[
"Learning Links",
[
{
name: "Leetcode",
link: "https://leetcode.com/",
},
{
name: "ISLP",
link: "https://hastie.su.domains/ISLP/ISLP_website.pdf.download.html",
},
],
],
[
"Social Links",
[
{
name: "Outlook",
link: "https://outlook.live.com/",
},
{
name: "Gmail",
link: "https://mail.google.com/",
},
{
name: "Whatsapp",
link: "https://web.whatsapp.com/",
},
],
],
[
"Radio links",
[
{
name: "Radio Helsinki",
link: "https://www.radiohelsinki.fi/",
},
{
name: "Palanga Street Radio",
link: "https://palanga.live/",
},
{
name: "IDA Radio",
link: "https://idaidaida.net/",
},
{
name: "Tīrkultūra",
link: "https://www.tirkultura.lv/",
},
],
],
[
"Hacking Links",
[
{
name: "pwn.college",
link: "https://pwn.college/",
},
{
name: "OSINT Framework",
link: "https://osintframework.com/",
},
{
name: "OverTheWire",
link: "https://overthewire.org/",
},
{
name: "TryHackMe",
link: "https://tryhackme.com/",
},
],
],
[
"Chinese Links",
[
{
name: "MDBG Chinese Dictionary",
link: "https://www.mdbg.net/chinese/dictionary",
},
{
name: "Stroke Order",
link: "https://www.strokeorder.com/",
},
{
name: "HSK 1 Peking University",
link: "https://youtube.com/playlist?list=PLVWfp7qXLmKVfSUkucXErLncKn-JqgBbK&si=2ytO3inS8-iOAOx2",
},
{
name: "Stroke Order",
link: "https://www.strokeorder.com/",
},
{
name: "Offbeat Mandarin",
link: "https://www.youtube.com/@OffbeatMandarin",
},
],
],
[
"Art links",
[
{
name: "Frida Kahlo",
link: "https://www.fridakahlo.org/",
},
{
name: "Cameron's World",
link: "https://www.cameronsworld.net/",
},
{
name: "Neocities",
link: "https://neocities.org/",
},
],
],
[
"Vue links",
[
{
name: "Vue",
link: "https://vuejs.org/guide/introduction.html",
},
{
name: "Vue Router",
link: "https://router.vuejs.org/introduction.html",
},
{
name: "Pinia",
link: "https://pinia.vuejs.org/introduction.html",
},
],
],
[
"Go links",
[
{
name: "Golang",
link: "https://golang.org/doc/",
},
{
name: "Gin Gonic",
link: "https://gin-gonic.com/en/docs/introduction/",
},
{
name: "GORM",
link: "https://gorm.io/gen/index.html",
},
],
],
[
"Doc links",
[
{
name: "Rust",
link: "https://doc.rust-lang.org/stable/book/index.html",
},
{
name: "Javascript",
link: "https://developer.mozilla.org/en-US/docs/Web/JavaScript",
},
{
name: "Python",
link: "https://docs.python.org/3/",
},
],
],
[
"Article links",
[
{
name: "Go and GORM",
link: "https://medium.com/@chaewonkong/learn-go-understanding-and-implementing-foreign-keys-with-gorm-6d7608e1dbf6",
},
{
name: "JWT Auth in GO",
link: "https://medium.com/monstar-lab-bangladesh-engineering/jwt-auth-in-go-dde432440924",
},
{
name: "Websockets in GO",
link: "https://medium.com/@tanngontn/golang-gin-framework-with-normal-websocket-and-websocket-with-producer-is-rabbitmq-guide-93cad7d290f7",
},
],
],
];
</script>
<template>
<main class="items-center flex flex-col">
<div
class="a4page-portrait bdr-1 flex flex-row flex-wrap overflow-x-auto gap-1"
>
<div class="w-full h-fit">
<LinkTable
class="flex flex-col flex-wrap"
v-for="link in links"
:title="link[0]"
:items="link[1]"
/>
</div>
</div>
</main>
</template>

View File

@@ -1,13 +1,27 @@
<script setup> <script setup>
import { ref, shallowRef, defineAsyncComponent } from "vue"; import { ref, shallowRef } from "vue";
import { RouterLink } from "vue-router";
import CVGeneral from "./CVGeneral.vue"; import CVGeneral from "./CVGeneral.vue";
import CVProgramming from "./CVProgramming.vue";
import CVSecurity from "./CVSecurity.vue";
import CVBackend from "./CVBackend.vue"; import CVBackend from "./CVBackend.vue";
import CVFrontend from "./CVFrontend.vue"; import CVFrontend from "./CVFrontend.vue";
import CVTemp from "./CVTemp.vue"; import CVTemp from "./CVTemp.vue";
import CVElectrical from "./CVElectrical.vue";
import CVHospitality from "./CVHospitality.vue";
import CVAnalyst from "./CVAnalyst.vue";
const CVHospitality = defineAsyncComponent(() => import("./CVHospitality.vue")); const templates = [
{ label: "General", component: CVGeneral },
const templates = [{ label: "General", component: CVGeneral }]; { label: "Programming", component: CVProgramming },
{ label: "Security", component: CVSecurity },
{ label: "Backend", component: CVBackend },
{ label: "Frontend", component: CVFrontend },
{ label: "Temp", component: CVTemp },
{ label: "Electrical", component: CVElectrical },
{ label: "Hospitality", component: CVHospitality },
{ label: "Analyst", component: CVAnalyst },
];
const selected = ref(0); const selected = ref(0);
const currentComponent = shallowRef(templates[0].component); const currentComponent = shallowRef(templates[0].component);
@@ -34,6 +48,7 @@ function print() {
{{ t.label }} {{ t.label }}
</button> </button>
<button class="cv-btn cv-print-btn" @click="print()">Print</button> <button class="cv-btn cv-print-btn" @click="print()">Print</button>
<RouterLink to="/cv/jobs" class="cv-btn cv-jobs-btn">Jobs</RouterLink>
</div> </div>
<Transition name="cv-fade" mode="out-in"> <Transition name="cv-fade" mode="out-in">
<component :is="currentComponent" :key="selected" /> <component :is="currentComponent" :key="selected" />
@@ -85,6 +100,11 @@ function print() {
margin-left: 1rem; margin-left: 1rem;
} }
.cv-jobs-btn {
text-decoration: none;
margin-left: auto;
}
.cv-fade-enter-active, .cv-fade-enter-active,
.cv-fade-leave-active { .cv-fade-leave-active {
transition: transition:
@@ -108,3 +128,5 @@ function print() {
} }
} }
</style> </style>
<style src="./cv-shared.css"></style>

View File

@@ -0,0 +1,262 @@
<script setup>
import Project from "./Project.vue";
</script>
<template>
<main class="cv-template">
<div class="no-print w-full h-20"></div>
<div class="a4page justify-between">
<section>
<div class="flex flex-col sm:flex-row sm:justify-between">
<h1 class="name">Adam French</h1>
<div class="contact-details">
<p>London, United Kingdom</p>
<p>+447563266931</p>
<p>adam.a.french@outlook.com</p>
<p>
<a href="https://www.adam-french.co.uk">
www.adam-french.co.uk
</a>
</p>
</div>
</div>
</section>
<section>
<h2>Profile</h2>
<p contenteditable="true">
Graduate with a First Class Honours degree in Computer Science with
Mathematics from the University of Leeds (81.1%) and a year abroad at
the University of Waterloo. Strong foundations in statistics, machine
learning, and databases, paired with practical experience turning raw
data into clear, actionable insight. Comfortable across the analytics
stack, from SQL queries and Python notebooks to written reports.
Looking to contribute rigorous, well-communicated analysis to a
data-driven team.
</p>
</section>
<section>
<h2>Skills</h2>
<div class="skills-grid">
<div>
<strong>Analysis & Visualisation</strong><br /><small
>Python (Pandas, NumPy, scikit-learn), matplotlib, seaborn,
Wolfram Mathematica</small
>
</div>
<div>
<strong>Statistics & ML</strong><br /><small
>Regression, hypothesis testing, classification, clustering,
A/B testing</small
>
</div>
<div>
<strong>Databases & Tooling</strong><br /><small
>SQL, PostgreSQL, SQLite, Jupyter, Git, Docker</small
>
</div>
</div>
</section>
<section>
<h2>Projects</h2>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a
href="https://community.wolfram.com/groups/-/m/t/3210947"
>
Wolfram Summer School
</a>
</h4>
</template>
<template v-slot:top>
<small>Wolfram Mathematica, Data Visualisation</small>
<small>2024</small>
</template>
<p contenteditable="true">
Research on Mobile Automata: simulated rule-based agents, analysed
long-run behaviour, and built visualisations. Delivered a write-up
and live presentation within a two-week deadline.
</p>
</Project>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a
href="https://www.adam-french.co.uk/gitea/adamf/rowing_stats"
>
rowing_stats.git
</a>
</h4>
</template>
<template v-slot:top>
<small>Python, scikit-learn, Pandas</small>
<small>2024</small>
</template>
<p contenteditable="true">
Extracted workout data from Concept 2 rowing machines and ran
regression and ML analysis on the resulting dataset to model
performance trends over time.
</p>
</Project>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a href="https://www.adam-french.co.uk/gitea/adamf/tour">
tour.git
</a>
</h4>
</template>
<template v-slot:top>
<small>Rust</small>
<small>2026</small>
</template>
<p contenteditable="true">
CLI tool for building and navigating interactive code tutorials,
with Git-inspired version traversal. Demonstrates careful planning
and code structuring.
</p>
</Project>
<Project>
<template v-slot:left>
<h4>
<a
href="https://www.adam-french.co.uk/gitea/adamf/web_server.git"
>
web_server.git
</a>
</h4>
</template>
<template v-slot:top>
<small>Postgres, Go, Python, Docker</small>
<small>2025</small>
</template>
<p contenteditable="true">
Self-hosted personal website with a Postgres-backed API. Wrote
queries and scripts to inspect user activity and content data.
</p>
</Project>
</section>
<div class="w-full flex flex-col sm:flex-row gap-5">
<section class="flex-1">
<h2>
<a href="https://www.adam-french.co.uk/pdf/transcript.pdf">
University of Leeds
</a>
</h2>
<div
class="flex-row flex place-content-between m-auto place-items-center"
>
<small>First Class Honours (81.1%)</small>
<small>Sep 2021 Jun 2025</small>
</div>
<small>BSc Computer Science with Mathematics </small>
<ul class="list-disc list-inside">
<li>Machine Learning</li>
<li>Databases</li>
<li>Probability, Statistics & Linear Algebra</li>
<li>Algorithms & Data Structures I & II</li>
<li>Graph Algorithms & Complexity Theory</li>
</ul>
</section>
<section class="flex-1">
<h2>University of Waterloo</h2>
<div
class="flex-row flex place-content-between m-auto place-items-center"
>
<small>Year abroad</small>
<small>Sep 2023 Apr 2024</small>
</div>
<ul class="list-disc list-inside">
<li>Applied Cryptography</li>
<li>Introduction to Computer Graphics</li>
<li>Introduction to Rings and Fields with Applications</li>
</ul>
</section>
</div>
</div>
<div class="no-print w-full h-20"></div>
<div class="a4page gap-10">
<section>
<h2>Experience</h2>
<Project>
<template #left>
<h4>Hospitality</h4>
</template>
<template #top>
<small>Cashier, Bartender, Waiter</small>
<small>Jan 2018 Dec 2023</small>
</template>
<p contenteditable="true">
Worked at <strong>Belgrave Music Hall</strong>,
<strong>The Crown and Anchor</strong>, and
<strong>BFI Riverfront Kitchen</strong>. Developed clear
communication, composure under pressure, and reliability.
</p>
</Project>
</section>
<section>
<h2>University Academy of Engineering Southbank</h2>
<div
class="flex-row flex place-content-between m-auto place-items-center"
>
<strong>A Levels</strong>
<small>Sep 2019 Jun 2021</small>
</div>
<div class="grades-grid">
<span>Mathematics</span><span>A*</span>
<span>Further Mathematics</span><span>A*</span>
<span>Physics</span><span>A*</span>
</div>
<div
class="flex-row flex place-content-between m-auto place-items-center mt-2"
>
<strong>GCSEs</strong>
<small>2019</small>
</div>
<div class="grades-grid">
<span>English Literature</span><span>9</span>
<span>Mathematics</span><span>9</span>
<span>Physics</span><span>9</span>
<span>English Language</span><span>8</span>
<span>Biology</span><span>8</span>
<span>Chemistry</span><span>8</span>
<span>Computer Science</span><span>7</span>
</div>
</section>
<div class="w-full flex flex-col sm:flex-row gap-5">
<section class="flex-1">
<h2>Soft Skills</h2>
<ul class="list-disc list-inside">
<li>Clear written & verbal communication</li>
<li>Attention to detail</li>
<li>Structured problem solving</li>
<li>Collaboration</li>
<li>Time management under deadlines</li>
</ul>
</section>
<section class="flex-1">
<h2>Interests</h2>
<ul class="list-disc list-inside">
<li>Leetcode & data puzzles</li>
<li>Learning Mandarin</li>
<li>Rhythm Games</li>
<li>Climbing, Gym</li>
<li>Board games, Meetup.com</li>
</ul>
</section>
</div>
</div>
<div class="no-print w-full h-20"></div>
</main>
</template>

View File

@@ -3,20 +3,21 @@ import Project from "./Project.vue";
</script> </script>
<template> <template>
<main> <main class="cv-template">
<div class="no-print w-full h-20"></div> <div class="no-print w-full h-20"></div>
<div class="a4page justify-between"> <div class="a4page justify-between">
<section> <section>
<div class="flex flex-col sm:flex-row sm:justify-between"> <div class="flex flex-col sm:flex-row sm:justify-between">
<h1 class="name">Adam French</h1> <h1 class="name">Adam French</h1>
<div class="contact-details sm:text-right"> <div class="contact-details">
<p>London, United Kingdom</p>
<p>+447563266931</p> <p>+447563266931</p>
<p>adam.a.french@outlook.com</p> <p>adam.a.french@outlook.com</p>
<h4> <p>
<a href="https://www.adam-french.co.uk"> <a href="https://www.adam-french.co.uk">
www.adam-french.co.uk www.adam-french.co.uk
</a> </a>
</h4> </p>
</div> </div>
</div> </div>
</section> </section>
@@ -24,12 +25,11 @@ import Project from "./Project.vue";
<section> <section>
<h2>Profile</h2> <h2>Profile</h2>
<p> <p>
First Class Honours graduate in Computer Science with First Class Honours graduate in Computer Science with Mathematics from
Mathematics from the University of Leeds (81.1%), with a the University of Leeds (81.1%), with a year abroad at the University
year abroad at the University of Waterloo. Strong background of Waterloo. Strong background in systems programming, API design,
in systems programming, API design, database management, and database management, and infrastructure automation. Keen to build
infrastructure automation. Keen to build reliable, reliable, performant backend services in a collaborative engineering
performant backend services in a collaborative engineering
team. team.
</p> </p>
</section> </section>
@@ -39,20 +39,17 @@ import Project from "./Project.vue";
<div class="skills-grid"> <div class="skills-grid">
<div> <div>
<strong>Languages</strong><br /><small <strong>Languages</strong><br /><small
>Go, Rust, Python, SQL, JavaScript / >Go, Rust, Python, SQL, JavaScript / TypeScript</small
TypeScript</small
> >
</div> </div>
<div> <div>
<strong>Backend</strong><br /><small <strong>Backend</strong><br /><small
>REST, GraphQL, gRPC, JWT Auth, WebSockets, >REST, GraphQL, gRPC, JWT Auth, WebSockets, Middleware</small
Middleware</small
> >
</div> </div>
<div> <div>
<strong>Infrastructure</strong><br /><small <strong>Infrastructure</strong><br /><small
>Docker, Nginx, PostgreSQL, SQLite, Git Actions, >Docker, Nginx, PostgreSQL, SQLite, Git Actions, Linux</small
Linux</small
> >
</div> </div>
</div> </div>
@@ -73,25 +70,22 @@ import Project from "./Project.vue";
</template> </template>
<template v-slot:top> <template v-slot:top>
<small> <small>
Go, Gin, GraphQL, PostgreSQL, GORM, Docker, Nginx, Go, Gin, GraphQL, PostgreSQL, GORM, Docker, Nginx, JWT Auth, Git
JWT Auth, Git Actions Actions
</small> </small>
<small>2025</small> <small>2025</small>
</template> </template>
<p> <p>
Self-hosted personal website with a Go backend serving a Self-hosted personal website with a Go backend serving a GraphQL
GraphQL API, JWT authentication, Spotify OAuth API, JWT authentication, Spotify OAuth integration, and WebSocket
integration, and WebSocket messaging. Fully messaging. Fully containerised with Docker Compose and automated
containerised with Docker Compose and automated CI/CD CI/CD via Git Actions.
via Git Actions.
</p> </p>
</Project> </Project>
<Project class="border-b border-dotted"> <Project class="border-b border-dotted">
<template v-slot:left> <template v-slot:left>
<h4> <h4>
<a <a href="https://www.adam-french.co.uk/gitea/adamf/tour.git">
href="https://www.adam-french.co.uk/gitea/adamf/tour.git"
>
tour.git tour.git
</a> </a>
</h4> </h4>
@@ -101,10 +95,10 @@ import Project from "./Project.vue";
<small>2026</small> <small>2026</small>
</template> </template>
<p> <p>
CLI tool for building and navigating interactive code CLI tool for building and navigating interactive code tutorials,
tutorials, with version-traversal semantics inspired by with version-traversal semantics inspired by Git. Designed for
Git. Designed for robustness with comprehensive error robustness with comprehensive error handling and structured file
handling and structured file operations. operations.
</p> </p>
</Project> </Project>
<Project class="border-b border-dotted"> <Project class="border-b border-dotted">
@@ -122,10 +116,9 @@ import Project from "./Project.vue";
<small>2023</small> <small>2023</small>
</template> </template>
<p> <p>
Parallelised recursive ray tracer leveraging Rust's Parallelised recursive ray tracer leveraging Rust's ownership model
ownership model for safe concurrency. Focused on for safe concurrency. Focused on algorithmic efficiency, low-level
algorithmic efficiency, low-level memory management, and memory management, and multi-core utilisation.
multi-core utilisation.
</p> </p>
</Project> </Project>
<Project> <Project>
@@ -144,60 +137,51 @@ import Project from "./Project.vue";
<small>2024</small> <small>2024</small>
</template> </template>
<p> <p>
Research project on Mobile Automata with data Research project on Mobile Automata with data visualisation and
visualisation and academic presentation. Delivered academic presentation. Delivered within a tight deadline in
within a tight deadline in collaboration with academic collaboration with academic mentors.
mentors.
</p> </p>
</Project> </Project>
</section> </section>
<section> <div class="w-full flex flex-col sm:flex-row gap-5">
<h2>Education</h2> <section class="flex-1">
<div class="w-full h-fit flex flex-col sm:flex-row gap-5"> <h2>
<div class="flex-1 sm:border-r border-dotted sm:pr-3"> <a href="https://www.adam-french.co.uk/pdf/transcript.pdf">
<h3>
<a
href="https://www.adam-french.co.uk/pdf/transcript.pdf"
>
University of Leeds University of Leeds
</a> </a>
</h3> </h2>
<div <div
class="flex-row flex place-content-between m-auto place-items-center" class="flex-row flex place-content-between m-auto place-items-center"
> >
<small>First Class Honours (81.1%)</small> <small>First Class Honors (81.1%)</small>
<small>20212025</small> <small>Sep 2021 Jun 2025</small>
</div> </div>
<small>BSc Computer Science with Mathematics </small> <small>BSc Computer Science with Mathematics </small>
<ul class="list-disc list-inside"> <ul class="list-disc list-inside">
<li>Compiler Design and Construction</li>
<li>Graph Algorithms & Complexity Theory</li>
<li>Algorithms & Data Structures I & II</li> <li>Algorithms & Data Structures I & II</li>
<li>Databases · Computer Processors</li> <li>Compiler Design and Construction</li>
<li>Formal Languages & Finite Automata</li>
<li>Graph Algorithms & Complexity Theory</li>
<li>Computer Processors, Databases, Networks</li>
</ul> </ul>
</div> </section>
<div class="flex-1 sm:pl-3"> <section class="flex-1">
<h3>University of Waterloo</h3> <h2>University of Waterloo</h2>
<div <div
class="flex-row flex place-content-between m-auto place-items-center" class="flex-row flex place-content-between m-auto place-items-center"
> >
<small>Year abroad</small> <small>Year abroad</small>
<small>20232024</small> <small>Sep 2023 Apr 2024</small>
</div> </div>
<ul class="list-disc list-inside"> <ul class="list-disc list-inside">
<li>Applied Cryptography</li> <li>Applied Cryptography</li>
<li>Introduction to Rings and Fields with Applications</li>
<li>Introduction to Computer Graphics</li> <li>Introduction to Computer Graphics</li>
<li>
Introduction to Rings and Fields with
Applications
</li>
<li>Formal Languages & Finite Automata</li>
</ul> </ul>
</div>
</div>
</section> </section>
</div> </div>
</div>
<div class="no-print w-full h-20"></div> <div class="no-print w-full h-20"></div>
@@ -214,9 +198,8 @@ import Project from "./Project.vue";
</template> </template>
<p> <p>
Worked at <em>Belgrave Music Hall</em>, Worked at <em>Belgrave Music Hall</em>,
<em>The Crown and Anchor</em>, and <em>The Crown and Anchor</em>, and <em>BFI Riverfront Kitchen</em>.
<em>BFI Riverfront Kitchen</em>. Developed Developed communication, composure under pressure, and reliability
communication, composure under pressure, and reliability
in customer-facing roles. in customer-facing roles.
</p> </p>
</Project> </Project>
@@ -236,175 +219,3 @@ import Project from "./Project.vue";
<div class="no-print w-full h-20"></div> <div class="no-print w-full h-20"></div>
</main> </main>
</template> </template>
<style scoped>
/* Fonts */
@font-face {
font-family: "big_noodle_titling";
src: url("/fonts/big_noodle_titling.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "CreatoDisplay";
src: url("/fonts/CreatoDisplay-Bold.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* Variables */
* {
--primary: black;
--secondary: #0000ff;
--tertiary: #ff0000;
--quaternary: #cccccc;
--background: white;
--font-heading: big_noodle_titling;
--font-text: CreatoDisplay;
--font-size-name: 2.5em;
--font-size-text: 100%;
--font-size-small: 0.9em;
--font-size-heading: 2.1em;
--font-size-subheading: 1.7em;
--font-size-subsubheading: 1.4em;
}
/* A4 Page */
.a4page {
line-height: 1.6;
font-family: var(--font-text);
width: 210mm;
height: 297mm;
padding: 5mm;
box-sizing: border-box;
background-color: var(--background);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
border: 1px solid var(--primary);
overflow: hidden;
margin: auto auto;
display: flex;
flex-direction: column;
}
/* Component Styling */
main {
padding: 0px;
display: flex;
flex-direction: column;
height: fit-content;
background-color: white;
}
span {
height: 2em;
}
h1,
h2,
h3,
h4 {
margin: 0px;
border: none;
color: var(--primary);
font-family: var(--font-heading);
text-transform: capitalize;
}
h1 {
font-size: var(--font-size-heading);
}
h2 {
border-bottom: 1px solid var(--primary);
font-size: var(--font-size-subheading);
}
h3 {
font-size: var(--font-size-subsubheading);
}
a:hover {
color: var(--tertiary);
}
a {
background-color: transparent;
color: var(--secondary);
}
p {
margin-bottom: 0.2em;
color: var(--primary);
font-size: var(--font-size-text);
}
table {
color: var(--secondary);
border-collapse: collapse;
border: 1px solid black;
}
td {
color: var(--secondary);
border-top: 1px solid var(--tertiary);
padding: 1px 10px 1px 10px;
font-size: var(--font-size-text);
text-align: left;
}
th {
color: var(--secondary);
border: 2px solid var(--tertiary);
padding: 1px 0px 1px 7px;
font-family: var(--font-heading);
font-size: var(--font-size-subsubheading);
background-color: var(--quaternary);
text-align: left;
}
@media print {
.no-print {
display: none !important;
}
}
small {
font-size: var(--font-size-small);
color: var(--primary);
}
ul {
font-size: var(--font-size-small);
margin: 0;
padding-left: 1.2em;
}
li {
font-size: var(--font-size-small);
color: var(--primary);
}
.skills-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.3em 1em;
margin-bottom: 0.2em;
}
@media (max-width: 640px) {
.a4page {
width: 100%;
height: auto;
overflow: visible;
box-shadow: none;
border: none;
}
.skills-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,176 @@
<script setup>
import Project from "./Project.vue";
</script>
<template>
<main class="cv-template">
<div class="no-print w-full h-20"></div>
<div class="a4page justify-between">
<section>
<div class="flex flex-col sm:flex-row sm:justify-between">
<h1 class="name">Adam French</h1>
<div class="contact-details">
<p>London, United Kingdom</p>
<p>+447563266931</p>
<p>adam.a.french@outlook.com</p>
<p>
<a href="https://www.adam-french.co.uk">
www.adam-french.co.uk
</a>
</p>
</div>
</div>
</section>
<section>
<h2>Profile</h2>
<p>
Computer Science with Mathematics graduate (First Class, 81.1%)
applying for an electrical apprenticeship. Three cumulative years of
part-time bar and waiting work done alongside my degree. Throughout my
hospitality work I valued working with my hands rather than behind a
desk. I want to earn an NVQ Level 3 in Electrical Installation to
build a long-term career in a skilled trade.
</p>
</section>
<section>
<div class="skills-grid">
<div>
<h2>Technical Skills</h2>
<ul class="list-disc list-inside">
<li>
Strong with calculations and digital tools, developed by my
degree.
</li>
<li>
Comfortable working and planning independently, as well as in
teams.
</li>
</ul>
</div>
<div>
<h2>Practical Skills</h2>
<ul class="list-disc list-inside">
<li>Careful and patient with procedures.</li>
<li>Used to checking own work thoroughly.</li>
</ul>
</div>
<div>
<h2>Additional</h2>
<ul class="list-disc list-inside">
<li>A*A*A* A-Levels.</li>
<li>Full UK driving licence.</li>
<li>Full right to work in the UK.</li>
<li>Eager to complete training to a high standard.</li>
<li>References on request.</li>
</ul>
</div>
</div>
</section>
<section>
<h2>Experience</h2>
<Project class="border-b border-dotted">
<template #left>
<h4>Belgrave Music Hall</h4>
</template>
<template #top>
<small>Bartender & Waiter</small>
<small>20212025</small>
</template>
<p>
Served food and drinks in a high-volume live-music venue in Leeds.
Handled busy weekend shifts, managed multiple tables simultaneously,
and maintained a calm, friendly demeanour during peak hours.
</p>
</Project>
<Project class="border-b border-dotted">
<template #left>
<h4>The Crown and Anchor</h4>
</template>
<template #top>
<small>Bartender & Waiter</small>
<small>20202021</small>
</template>
<p>
Worked front-of-house at a busy pub, pulling pints, taking orders,
and ensuring a welcoming atmosphere. Built rapport with regulars and
adapted quickly to changing priorities during service.
</p>
</Project>
<Project class="border-b border-dotted">
<template #left>
<h4>To The Rise Bakery</h4>
</template>
<template #top>
<small>Barista & Front of House</small>
<small>20202021</small>
</template>
<p>
Worked at a bakery in Eastbourne, preparing freshly baked goods for
display, operating the coffee machine, and keeping bakery equipment
spotless. Built rapport with regulars and provided attentive,
friendly service.
</p>
</Project>
<Project>
<template #left>
<h4>BFI Riverfront Kitchen</h4>
</template>
<template #top>
<small>Cashier & Waiter</small>
<small>20182020</small>
</template>
<p>
Operated the till, served customers, and helped coordinate table
service at a café on London's South Bank. Developed strong
cash-handling accuracy and customer interaction skills in a
fast-paced environment.
</p>
</Project>
</section>
<div class="w-full flex flex-col sm:flex-row gap-5">
<section class="flex-1">
<h2>
<a href="https://www.adam-french.co.uk/pdf/transcript.pdf">
University of Leeds
</a>
</h2>
<div
class="flex-row flex place-content-between m-auto place-items-center"
>
<small>First Class Honours (81.1%)</small>
<small>Sep 2021 Jun 2025</small>
</div>
<small>BSc Computer Science with Mathematics</small>
<ul class="list-disc list-inside">
<li>Algorithms & Data Structures I & II</li>
<li>Compiler Design and Construction</li>
<li>Formal Languages & Finite Automata</li>
<li>Graph Algorithms & Complexity Theory</li>
<li>Machine Learning, Databases, Computer Processors</li>
</ul>
</section>
<section class="flex-1">
<h2>University of Waterloo</h2>
<div
class="flex-row flex place-content-between m-auto place-items-center"
>
<small>Year abroad</small>
<small>Sep 2023 Apr 2024</small>
</div>
<ul class="list-disc list-inside">
<li>Applied Cryptography</li>
<li>Introduction to Computer Graphics</li>
<li>Introduction to Rings and Fields with Applications</li>
</ul>
</section>
</div>
</div>
<div class="no-print w-full h-20"></div>
</main>
</template>

View File

@@ -3,20 +3,21 @@ import Project from "./Project.vue";
</script> </script>
<template> <template>
<main> <main class="cv-template">
<div class="no-print w-full h-20"></div> <div class="no-print w-full h-20"></div>
<div class="a4page justify-between"> <div class="a4page justify-between">
<section> <section>
<div class="flex flex-col sm:flex-row sm:justify-between"> <div class="flex flex-col sm:flex-row sm:justify-between">
<h1 class="name">Adam French</h1> <h1 class="name">Adam French</h1>
<div class="contact-details sm:text-right"> <div class="contact-details">
<p>London, United Kingdom</p>
<p>+447563266931</p> <p>+447563266931</p>
<p>adam.a.french@outlook.com</p> <p>adam.a.french@outlook.com</p>
<h4> <p>
<a href="https://www.adam-french.co.uk"> <a href="https://www.adam-french.co.uk">
www.adam-french.co.uk www.adam-french.co.uk
</a> </a>
</h4> </p>
</div> </div>
</div> </div>
</section> </section>
@@ -24,12 +25,11 @@ import Project from "./Project.vue";
<section> <section>
<h2>Profile</h2> <h2>Profile</h2>
<p> <p>
First Class Honours graduate in Computer Science with First Class Honours graduate in Computer Science with Mathematics from
Mathematics from the University of Leeds (81.1%), with a the University of Leeds (81.1%), with a year abroad at the University
year abroad at the University of Waterloo. Passionate about of Waterloo. Passionate about crafting responsive, accessible, and
crafting responsive, accessible, and performant user performant user interfaces. Experienced across multiple frontend
interfaces. Experienced across multiple frontend frameworks frameworks with a solid understanding of the full stack.
with a solid understanding of the full stack.
</p> </p>
</section> </section>
@@ -38,20 +38,18 @@ import Project from "./Project.vue";
<div class="skills-grid"> <div class="skills-grid">
<div> <div>
<strong>Frontend</strong><br /><small <strong>Frontend</strong><br /><small
>Vue, React / Redux, Svelte, Tailwind CSS, HTML / >Vue, React / Redux, Svelte, Tailwind CSS, HTML / CSS,
CSS, WebAssembly</small WebAssembly</small
> >
</div> </div>
<div> <div>
<strong>Languages</strong><br /><small <strong>Languages</strong><br /><small
>JavaScript / TypeScript, Rust, Go, Python, >JavaScript / TypeScript, Rust, Go, Python, SQL</small
SQL</small
> >
</div> </div>
<div> <div>
<strong>Tooling / Infra</strong><br /><small <strong>Tooling / Infra</strong><br /><small
>Vite, Docker, Nginx, Git Actions, PostgreSQL, >Vite, Docker, Nginx, Git Actions, PostgreSQL, Figma</small
Figma</small
> >
</div> </div>
</div> </div>
@@ -72,25 +70,21 @@ import Project from "./Project.vue";
</template> </template>
<template v-slot:top> <template v-slot:top>
<small> <small>
Vue 3, Tailwind CSS, Vite, Pinia, Responsive Design, Vue 3, Tailwind CSS, Vite, Pinia, Responsive Design, Rust Wasm
Rust Wasm
</small> </small>
<small>2025</small> <small>2025</small>
</template> </template>
<p> <p>
Personal website SPA built with Vue 3, Tailwind CSS, and Personal website SPA built with Vue 3, Tailwind CSS, and Pinia for
Pinia for state management. Features responsive layouts, state management. Features responsive layouts, dark mode,
dark mode, WebAssembly integration, and a custom WebAssembly integration, and a custom component library. Iterated
component library. Iterated through Svelte and through Svelte and React/Redux before settling on Vue.
React/Redux before settling on Vue.
</p> </p>
</Project> </Project>
<Project class="border-b border-dotted"> <Project class="border-b border-dotted">
<template v-slot:left> <template v-slot:left>
<h4> <h4>
<a <a href="https://www.adam-french.co.uk/gitea/adamf/tour.git">
href="https://www.adam-french.co.uk/gitea/adamf/tour.git"
>
tour.git tour.git
</a> </a>
</h4> </h4>
@@ -100,9 +94,8 @@ import Project from "./Project.vue";
<small>2026</small> <small>2026</small>
</template> </template>
<p> <p>
CLI tool for building and navigating interactive code CLI tool for building and navigating interactive code tutorials,
tutorials, with version-traversal semantics inspired by with version-traversal semantics inspired by Git.
Git.
</p> </p>
</Project> </Project>
<Project class="border-b border-dotted"> <Project class="border-b border-dotted">
@@ -120,9 +113,9 @@ import Project from "./Project.vue";
<small>2023</small> <small>2023</small>
</template> </template>
<p> <p>
Parallelised recursive ray tracer for realistic 3D Parallelised recursive ray tracer for realistic 3D rendering.
rendering. Emphasised algorithmic efficiency and Emphasised algorithmic efficiency and low-level memory management in
low-level memory management in Rust. Rust.
</p> </p>
</Project> </Project>
<Project> <Project>
@@ -141,30 +134,25 @@ import Project from "./Project.vue";
<small>2024</small> <small>2024</small>
</template> </template>
<p> <p>
Research project on Mobile Automata with data Research project on Mobile Automata with data visualisation and
visualisation and academic presentation. Delivered academic presentation. Delivered within a tight deadline in
within a tight deadline in collaboration with academic collaboration with academic mentors.
mentors.
</p> </p>
</Project> </Project>
</section> </section>
<section> <div class="w-full flex flex-col sm:flex-row gap-5">
<h2>Education</h2> <section class="flex-1">
<div class="w-full h-fit flex flex-col sm:flex-row gap-5"> <h2>
<div class="flex-1 sm:border-r border-dotted sm:pr-3"> <a href="https://www.adam-french.co.uk/pdf/transcript.pdf">
<h3>
<a
href="https://www.adam-french.co.uk/pdf/transcript.pdf"
>
University of Leeds University of Leeds
</a> </a>
</h3> </h2>
<div <div
class="flex-row flex place-content-between m-auto place-items-center" class="flex-row flex place-content-between m-auto place-items-center"
> >
<small>81.1% First Class Honours</small> <small>First Class Honours (81.1%)</small>
<small>20212025</small> <small>Sep 2021 Jun 2025</small>
</div> </div>
<small>BSc Computer Science with Mathematics </small> <small>BSc Computer Science with Mathematics </small>
<ul class="list-disc list-inside"> <ul class="list-disc list-inside">
@@ -172,32 +160,25 @@ import Project from "./Project.vue";
<li>Compiler Design and Construction</li> <li>Compiler Design and Construction</li>
<li>Formal Languages & Finite Automata</li> <li>Formal Languages & Finite Automata</li>
<li>Graph Algorithms & Complexity Theory</li> <li>Graph Algorithms & Complexity Theory</li>
<li> <li>Machine Learning, Databases, Computer Processors</li>
Machine Learning & Databases & Computer
Processors
</li>
</ul> </ul>
</div> </section>
<div class="flex-1 sm:pl-3"> <section class="flex-1">
<h3>University of Waterloo</h3> <h2>University of Waterloo</h2>
<div <div
class="flex-row flex place-content-between m-auto place-items-center" class="flex-row flex place-content-between m-auto place-items-center"
> >
<small>Year abroad</small> <small>Year abroad</small>
<small>20232024</small> <small>Sep 2023 Apr 2024</small>
</div> </div>
<ul class="list-disc list-inside"> <ul class="list-disc list-inside">
<li>Applied Cryptography</li> <li>Applied Cryptography</li>
<li>Introduction to Computer Graphics</li> <li>Introduction to Computer Graphics</li>
<li> <li>Introduction to Rings and Fields with Applications</li>
Introduction to Rings and Fields with
Applications
</li>
</ul> </ul>
</div>
</div>
</section> </section>
</div> </div>
</div>
<div class="no-print w-full h-20"></div> <div class="no-print w-full h-20"></div>
@@ -214,9 +195,8 @@ import Project from "./Project.vue";
</template> </template>
<p> <p>
Worked at <em>Belgrave Music Hall</em>, Worked at <em>Belgrave Music Hall</em>,
<em>The Crown and Anchor</em>, and <em>The Crown and Anchor</em>, and <em>BFI Riverfront Kitchen</em>.
<em>BFI Riverfront Kitchen</em>. Developed Developed communication, composure under pressure, and reliability
communication, composure under pressure, and reliability
in customer-facing roles. in customer-facing roles.
</p> </p>
</Project> </Project>
@@ -236,175 +216,3 @@ import Project from "./Project.vue";
<div class="no-print w-full h-20"></div> <div class="no-print w-full h-20"></div>
</main> </main>
</template> </template>
<style scoped>
/* Fonts */
@font-face {
font-family: "big_noodle_titling";
src: url("/fonts/big_noodle_titling.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "CreatoDisplay";
src: url("/fonts/CreatoDisplay-Bold.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* Variables */
* {
--primary: black;
--secondary: #0000ff;
--tertiary: #ff0000;
--quaternary: #cccccc;
--background: white;
--font-heading: big_noodle_titling;
--font-text: CreatoDisplay;
--font-size-name: 2.5em;
--font-size-text: 100%;
--font-size-small: 0.9em;
--font-size-heading: 2.1em;
--font-size-subheading: 1.7em;
--font-size-subsubheading: 1.4em;
}
/* A4 Page */
.a4page {
line-height: 1.6;
font-family: var(--font-text);
width: 210mm;
height: 297mm;
padding: 5mm;
box-sizing: border-box;
background-color: var(--background);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
border: 1px solid var(--primary);
overflow: hidden;
margin: auto auto;
display: flex;
flex-direction: column;
}
/* Component Styling */
main {
padding: 0px;
display: flex;
flex-direction: column;
height: fit-content;
background-color: white;
}
span {
height: 2em;
}
h1,
h2,
h3,
h4 {
margin: 0px;
border: none;
color: var(--primary);
font-family: var(--font-heading);
text-transform: capitalize;
}
h1 {
font-size: var(--font-size-heading);
}
h2 {
border-bottom: 1px solid var(--primary);
font-size: var(--font-size-subheading);
}
h3 {
font-size: var(--font-size-subsubheading);
}
a:hover {
color: var(--tertiary);
}
a {
background-color: transparent;
color: var(--secondary);
}
p {
margin-bottom: 0.2em;
color: var(--primary);
font-size: var(--font-size-text);
}
table {
color: var(--secondary);
border-collapse: collapse;
border: 1px solid black;
}
td {
color: var(--secondary);
border-top: 1px solid var(--tertiary);
padding: 1px 10px 1px 10px;
font-size: var(--font-size-text);
text-align: left;
}
th {
color: var(--secondary);
border: 2px solid var(--tertiary);
padding: 1px 0px 1px 7px;
font-family: var(--font-heading);
font-size: var(--font-size-subsubheading);
background-color: var(--quaternary);
text-align: left;
}
@media print {
.no-print {
display: none !important;
}
}
small {
font-size: var(--font-size-small);
color: var(--primary);
}
ul {
font-size: var(--font-size-small);
margin: 0;
padding-left: 1.2em;
}
li {
font-size: var(--font-size-small);
color: var(--primary);
}
.skills-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.3em 1em;
margin-bottom: 0.2em;
}
@media (max-width: 640px) {
.a4page {
width: 100%;
height: auto;
overflow: visible;
box-shadow: none;
border: none;
}
.skills-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -3,12 +3,12 @@ import Project from "./Project.vue";
</script> </script>
<template> <template>
<main> <main class="cv-template">
<div class="no-print w-full h-20"></div> <div class="no-print w-full h-20"></div>
<div class="a4page justify-between"> <div class="a4page place-content-between">
<section> <section>
<div class="flex flex-col sm:flex-row sm:justify-between"> <div class="flex flex-col sm:flex-row sm:justify-between">
<h1 class="flex-1 name">Adam French</h1> <h1 class="name">Adam French</h1>
<div class="contact-details"> <div class="contact-details">
<p>London, United Kingdom</p> <p>London, United Kingdom</p>
<p>+447563266931</p> <p>+447563266931</p>
@@ -24,130 +24,138 @@ import Project from "./Project.vue";
<section> <section>
<h2>Profile</h2> <h2>Profile</h2>
<p contenteditable="true"> <p>
Full Stack Developer with a First Class Honours degree in Computer Science with Mathematics graduate (First Class, 81.1%) from
Computer Science with Mathematics from the University of the University of Leeds, with a year abroad at the University of
Leeds (81.1%) and a year abroad at the University of Waterloo. Three years of part-time hospitality work done alongside my
Waterloo. Proficient in full-stack development, systems degree. I am equally comfortable with practical, hands-on work and
programming, and CI/CD automation. Strong problem-solving tasks that require careful thinking and I prefer roles where I am in
and organisational skills. Eager to contribute to a direct contact with people. I am committed to building a long-term
collaborative engineering team, apply strong academic reliable career.
foundations to real-world problems, and grow through
hands-on experience.
</p> </p>
</section> </section>
<section> <section>
<h2>Skills</h2>
<div class="skills-grid"> <div class="skills-grid">
<div> <div>
<strong>Languages</strong><br /><small <h2>Analytical</h2>
>Go, Rust, Python, JavaScript / TypeScript, <ul>
SQL</small <li>Strong with studying</li>
> <li>Systematic approach to problems</li>
<li>Comfortable reading technical material</li>
</ul>
</div> </div>
<div> <div>
<strong>Frontend / Web Design</strong><br /><small <h2>Practical</h2>
>Vue, React / Redux, Svelte, Tailwind CSS, <ul>
WebAssembly</small <li>Happy with physical, hands-on tasks</li>
> <li>Patient and careful with procedures</li>
<li>Used to checking my own work</li>
</ul>
</div> </div>
<div> <div class="inline-skills">
<strong>Backend / Infra</strong><br /><small <h2>Additional</h2>
>Nginx, Docker, PostgreSQL, SQLite, JWT Auth, Git <ul>
Actions</small <li>Three years of customer-facing work</li>
> <li>Full UK driving licence</li>
<li>Full right to work in the UK</li>
<li>A*A*A* A-Levels</li>
</ul>
</div> </div>
</div> </div>
</section> </section>
<section>
<h2>Experience</h2>
<Project class="border-b border-dotted">
<template #left>
<h4>Belgrave Music Hall</h4>
</template>
<template #top>
<small>Bartender & Front of House</small>
<small>20212025</small>
</template>
<p>
Served food and drinks in a busy live-music venue in Leeds. Quick on
feet, calm and collected through busy evenings. Worked alongside my
degree.
</p>
</Project>
<Project class="border-b border-dotted">
<template #left>
<h4>The Crown and Anchor</h4>
</template>
<template #top>
<small>Bartender & Waiter</small>
<small>20202021</small>
</template>
<p>
Front-of-house at a busy pub. Took orders, pulled pints, and kept
service running smoothly.
</p>
</Project>
<Project class="border-b border-dotted">
<template #left>
<h4>To The Rise Bakery</h4>
</template>
<template #top>
<small>Barista & Front of House</small>
<small>20202021</small>
</template>
<p>
Prepared baked goods, operated the coffee machine, and kept the
workspace clean and tidy.
</p>
</Project>
<Project>
<template #left>
<h4>BFI Riverfront Kitchen</h4>
</template>
<template #top>
<small>Cashier & Waiter</small>
<small>20182020</small>
</template>
<p>
Operated the till and served customers at a café on London's South
Bank.
</p>
</Project>
</section>
<section> <section>
<h2>Projects</h2> <h2>Projects</h2>
<Project class="border-b border-dotted"> <Project class="border-b border-dotted">
<template v-slot:left> <template #left>
<h4> <h4>
<a <a href="https://www.adam-french.co.uk"> Personal Website </a>
href="https://www.adam-french.co.uk/gitea/adamf/web_server.git"
>
web_server.git
</a>
</h4> </h4>
</template> </template>
<template v-slot:top> <template #top>
<small> <small>Self-directed</small>
Nginx, Vue, Postgres, Docker, Go, Python, Rust, <small>20232025</small>
Wasm, Git Actions, JWT Auth
</small>
<small>2025</small>
</template> </template>
<p contenteditable="true"> <p>
Self-hosted personal website with a fully automated Built and maintained a personal website and the server it runs on,
CI/CD pipeline. Iterated across diverse tech stacks entirely independently. Created the initial design through to
including Svelte, React/Redux, SQLite, Rust Actix, and deployment.
Deno.
</p>
</Project>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a
href="https://www.adam-french.co.uk/gitea/adamf/tour.git"
>
tour.git
</a>
</h4>
</template>
<template v-slot:top>
<small>Rust</small>
<small>2026</small>
</template>
<p contenteditable="true">
CLI tool for building and navigating interactive code
tutorials, with version-traversal semantics inspired by
Git.
</p>
</Project>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a
href="https://www.adam-french.co.uk/gitea/adamf/rust-raytracer.git"
>
rust-raytracer.git
</a>
</h4>
</template>
<template v-slot:top>
<small>Rust, Linear Algebra, Multithreading</small>
<small>2023</small>
</template>
<p contenteditable="true">
Parallelised recursive ray tracer for realistic 3D
rendering. Emphasised algorithmic efficiency and
low-level memory management in Rust.
</p> </p>
</Project> </Project>
<Project> <Project>
<template #left> <template #left>
<h4> <h4>
<a <a href="https://community.wolfram.com/groups/-/m/t/3210947">
class="text-center w-full"
href="https://community.wolfram.com/groups/-/m/t/3210947"
>
Wolfram Summer School Wolfram Summer School
</a> </a>
</h4> </h4>
</template> </template>
<template #top> <template #top>
<small>Wolfram Mathematica</small> <small>Academic Research</small>
<small>2024</small> <small>2024</small>
</template> </template>
<p contenteditable="true"> <p>
Research project on Mobile Automata with data Research project completed on a tight deadline in collaboration with
visualisation and academic presentation. Delivered academic mentors. Wrote up findings and presented to a group.
within a tight deadline in collaboration with academic
mentors.
</p> </p>
</Project> </Project>
</section> </section>
@@ -155,9 +163,7 @@ import Project from "./Project.vue";
<div class="w-full flex flex-col sm:flex-row gap-5"> <div class="w-full flex flex-col sm:flex-row gap-5">
<section class="flex-1"> <section class="flex-1">
<h2> <h2>
<a <a href="https://www.adam-french.co.uk/pdf/transcript.pdf">
href="https://www.adam-french.co.uk/pdf/transcript.pdf"
>
University of Leeds University of Leeds
</a> </a>
</h2> </h2>
@@ -168,15 +174,6 @@ import Project from "./Project.vue";
<small>Sep 2021 Jun 2025</small> <small>Sep 2021 Jun 2025</small>
</div> </div>
<small>BSc Computer Science with Mathematics</small> <small>BSc Computer Science with Mathematics</small>
<ul class="list-disc list-inside">
<li>Algorithms & Data Structures I & II</li>
<li>Compiler Design and Construction</li>
<li>Formal Languages & Finite Automata</li>
<li>Graph Algorithms & Complexity Theory</li>
<li>
Machine Learning, Databases, Computer Processors
</li>
</ul>
</section> </section>
<section class="flex-1"> <section class="flex-1">
<h2>University of Waterloo</h2> <h2>University of Waterloo</h2>
@@ -186,259 +183,8 @@ import Project from "./Project.vue";
<small>Year abroad</small> <small>Year abroad</small>
<small>Sep 2023 Apr 2024</small> <small>Sep 2023 Apr 2024</small>
</div> </div>
<ul class="list-disc list-inside">
<li>Applied Cryptography</li>
<li>Introduction to Computer Graphics</li>
<li>
Introduction to Rings and Fields with Applications
</li>
</ul>
</section> </section>
</div> </div>
</div> </div>
<div class="no-print w-full h-20"></div>
<div class="a4page gap-10">
<section>
<h2>Experience</h2>
<Project>
<template #left>
<h4>Hospitality</h4>
</template>
<template #top>
<small>Cashier, Bartender, Waiter</small>
<small>Jan 2018 Dec 2023</small>
</template>
<p contenteditable="true">
Worked at <strong>Belgrave Music Hall</strong>,
<strong>The Crown and Anchor</strong>, and
<strong>BFI Riverfront Kitchen</strong>. Developed
communication, composure under pressure, and reliability
in customer-facing roles.
</p>
</Project>
</section>
<div class="w-full flex flex-col sm:flex-row gap-5">
<section class="flex-1">
<h2>Soft Skills</h2>
<ul class="list-disc list-inside">
<li>Communication & collaboration</li>
<li>Attention to detail</li>
<li>Problem solving</li>
<li>Adaptability</li>
<li>Time management</li>
</ul>
</section>
<section class="flex-1">
<h2>Interests</h2>
<ul class="list-disc list-inside">
<li>Leetcode</li>
<li>Learning Mandarin</li>
<li>Rhythm Games</li>
<li>Climbing, Gym</li>
<li>Board games, Meetup.com</li>
</ul>
</section>
</div>
</div>
<div class="no-print w-full h-20"></div>
</main> </main>
</template> </template>
<style scoped>
/* Fonts */
@font-face {
font-family: "big_noodle_titling";
src: url("/fonts/big_noodle_titling.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "CreatoDisplay";
src: url("/fonts/CreatoDisplay-Bold.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* Variables */
* {
--primary: black;
--secondary: #0000ff;
--tertiary: #ff0000;
--quaternary: #cccccc;
--background: white;
--font-heading: big_noodle_titling;
--font-text: CreatoDisplay;
--font-size-text: 1em;
--font-size-small: 0.9em;
--font-size-heading: 1.5em;
--font-size-subheading: 1.5em;
--font-size-subsubheading: 1.3em;
}
/* A4 Page */
.a4page {
line-height: 1.6;
font-family: var(--font-text);
width: 210mm;
height: 297mm;
padding: 10mm;
box-sizing: border-box;
background-color: var(--background);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
border: 1px solid var(--primary);
overflow: hidden;
margin: auto auto;
display: flex;
flex-direction: column;
}
.name {
font-size: 2em;
font-weight: bold;
justify-content: center;
align-content: center;
}
/* Component Styling */
main {
padding: 0px;
display: flex;
flex-direction: column;
height: fit-content;
background-color: white;
}
strong {
font-weight: 900;
}
span {
height: 2em;
}
h1,
h2,
h3,
h4 {
margin: 0px 0px 0.2em 0px;
border: none;
color: var(--primary);
font-family: var(--font-heading);
text-transform: uppercase;
}
h1 {
font-size: var(--font-size-heading);
}
h2 {
border-bottom: 1px solid var(--primary);
font-size: var(--font-size-subheading);
}
h3,
h4 {
font-size: var(--font-size-subsubheading);
}
a:hover {
color: var(--tertiary);
}
a {
background-color: transparent;
color: var(--secondary);
font-family: inherit;
font-size: var(--font-size-text);
}
p {
margin-bottom: 0.2em;
color: var(--primary);
font-size: var(--font-size-text);
}
table {
color: var(--secondary);
border-collapse: collapse;
border: 1px solid black;
}
td {
color: var(--secondary);
border-top: 1px solid var(--tertiary);
padding: 1px 10px 1px 10px;
font-size: var(--font-size-text);
text-align: left;
}
th {
color: var(--secondary);
border: 2px solid var(--tertiary);
padding: 1px 0px 1px 7px;
font-family: var(--font-heading);
font-size: var(--font-size-subsubheading);
background-color: var(--quaternary);
text-align: left;
}
@media print {
.no-print {
display: none !important;
}
}
small {
font-size: var(--font-size-small);
color: var(--primary);
}
ul {
font-size: var(--font-size-small);
margin: 0;
padding-left: 1.2em;
}
li {
font-size: var(--font-size-small);
color: var(--primary);
}
.skills-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.3em 1em;
margin-bottom: 0.2em;
}
p[contenteditable] {
outline: none;
cursor: text;
}
p[contenteditable]:focus {
outline: none;
background-color: rgba(0, 0, 255, 0.05);
}
@media (max-width: 640px) {
.a4page {
width: 100%;
height: auto;
overflow: visible;
box-shadow: none;
border: none;
}
.skills-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -3,20 +3,21 @@ import Project from "./Project.vue";
</script> </script>
<template> <template>
<main> <main class="cv-template">
<div class="no-print w-full h-20"></div> <div class="no-print w-full h-20"></div>
<div class="a4page justify-between"> <div class="a4page justify-between">
<section> <section>
<div class="flex flex-col sm:flex-row sm:justify-between"> <div class="flex flex-col sm:flex-row sm:justify-between">
<h1 class="name">Adam French</h1> <h1 class="name">Adam French</h1>
<div class="contact-details sm:text-right"> <div class="contact-details">
<p>London, United Kingdom</p>
<p>+447563266931</p> <p>+447563266931</p>
<p>adam.a.french@outlook.com</p> <p>adam.a.french@outlook.com</p>
<h4> <p>
<a href="https://www.adam-french.co.uk"> <a href="https://www.adam-french.co.uk">
www.adam-french.co.uk www.adam-french.co.uk
</a> </a>
</h4> </p>
</div> </div>
</div> </div>
</section> </section>
@@ -24,18 +25,30 @@ import Project from "./Project.vue";
<section> <section>
<h2>Profile</h2> <h2>Profile</h2>
<p> <p>
First Class Honours graduate in Computer Science with First Class Honours graduate in Computer Science with Mathematics from
Mathematics from the University of Leeds (81.1%). Dependable the University of Leeds (81.1%). Dependable and personable team player
and personable team player with five years of hospitality with five years of hospitality experience across busy bars,
experience across busy bars, restaurants, and event venues. restaurants, and event venues. Thrives under pressure, communicates
Thrives under pressure, communicates clearly, and takes clearly, and takes pride in providing excellent customer service.
pride in providing excellent customer service.
</p> </p>
</section> </section>
<section> <section>
<h2>Experience</h2> <h2>Experience</h2>
<Project class="border-b border-dotted">
<template #left>
<h4>Daisy Green Holland Park</h4>
</template>
<template #top>
<small>Barista & Front of House</small>
<small>May 2026 Present</small>
</template>
<p>
Working at an all-day café in Holland Park, preparing coffee and
serving food in a fast-paced environment.
</p>
</Project>
<Project class="border-b border-dotted"> <Project class="border-b border-dotted">
<template #left> <template #left>
<h4>Belgrave Music Hall</h4> <h4>Belgrave Music Hall</h4>
@@ -45,10 +58,9 @@ import Project from "./Project.vue";
<small>20212025</small> <small>20212025</small>
</template> </template>
<p> <p>
Served food and drinks in a high-volume live-music venue Served food and drinks in a high-volume live-music venue in Leeds.
in Leeds. Handled busy weekend shifts, managed multiple Handled busy weekend shifts, managed multiple tables simultaneously,
tables simultaneously, and maintained a calm, friendly and maintained a calm, friendly demeanour during peak hours.
demeanour during peak hours.
</p> </p>
</Project> </Project>
<Project class="border-b border-dotted"> <Project class="border-b border-dotted">
@@ -60,13 +72,27 @@ import Project from "./Project.vue";
<small>20202021</small> <small>20202021</small>
</template> </template>
<p> <p>
Worked front-of-house at a busy pub, pulling pints, Worked front-of-house at a busy pub, pulling pints, taking orders,
taking orders, and ensuring a welcoming atmosphere. and ensuring a welcoming atmosphere. Built rapport with regulars and
Built rapport with regulars and adapted quickly to adapted quickly to changing priorities during service.
changing priorities during service.
</p> </p>
</Project> </Project>
<Project class="border-b border-dotted"> <Project class="border-b border-dotted">
<template #left>
<h4>To The Rise Bakery</h4>
</template>
<template #top>
<small>Barista & Front of House</small>
<small>20202021</small>
</template>
<p>
Worked at a bakery in Eastbourne, preparing freshly baked goods for
display, operating the coffee machine, and keeping bakery equipment
spotless. Built rapport with regulars and provided attentive,
friendly service.
</p>
</Project>
<Project>
<template #left> <template #left>
<h4>BFI Riverfront Kitchen</h4> <h4>BFI Riverfront Kitchen</h4>
</template> </template>
@@ -75,10 +101,10 @@ import Project from "./Project.vue";
<small>20182020</small> <small>20182020</small>
</template> </template>
<p> <p>
Operated the till, served customers, and helped Operated the till, served customers, and helped coordinate table
coordinate table service at a café on London's South service at a café on London's South Bank. Developed strong
Bank. Developed strong cash-handling accuracy and cash-handling accuracy and customer interaction skills in a
customer interaction skills in a fast-paced environment. fast-paced environment.
</p> </p>
</Project> </Project>
</section> </section>
@@ -88,36 +114,31 @@ import Project from "./Project.vue";
<div class="skills-grid"> <div class="skills-grid">
<div> <div>
<strong>Service</strong><br /><small <strong>Service</strong><br /><small
>Bar work, Table service, Cash handling, Till >Bar work, Table service, Cash handling, Till operation, Food
operation, Food hygiene</small hygiene</small
> >
</div> </div>
<div> <div>
<strong>Soft Skills</strong><br /><small <strong>Soft Skills</strong><br /><small
>Communication, Teamwork, Time management, Composure >Communication, Teamwork, Time management, Composure under
under pressure</small pressure</small
> >
</div> </div>
<div> <div>
<strong>Technical</strong><br /><small <strong>Technical</strong><br /><small
>EPOS systems, Stock management, Event >EPOS systems, Stock management, Event coordination</small
coordination</small
> >
</div> </div>
</div> </div>
</section> </section>
<section> <div class="w-full flex flex-col sm:flex-row gap-5">
<h2>Education</h2> <section class="flex-1">
<div class="w-full h-fit flex flex-col sm:flex-row gap-5"> <h2>
<div class="flex-1 sm:border-r border-dotted sm:pr-3"> <a href="https://www.adam-french.co.uk/pdf/transcript.pdf">
<h3>
<a
href="https://www.adam-french.co.uk/pdf/transcript.pdf"
>
University of Leeds University of Leeds
</a> </a>
</h3> </h2>
<div <div
class="flex-row flex place-content-between m-auto place-items-center" class="flex-row flex place-content-between m-auto place-items-center"
> >
@@ -125,27 +146,25 @@ import Project from "./Project.vue";
<small>20212025</small> <small>20212025</small>
</div> </div>
<small>BSc Computer Science with Mathematics </small> <small>BSc Computer Science with Mathematics </small>
</div> </section>
<div class="flex-1 sm:pl-3"> <section class="flex-1">
<h3>University of Waterloo</h3> <h2>University of Waterloo</h2>
<div <div
class="flex-row flex place-content-between m-auto place-items-center" class="flex-row flex place-content-between m-auto place-items-center"
> >
<small>Year abroad</small> <small>Year abroad</small>
<small>20232024</small> <small>20232024</small>
</div> </div>
</div>
</div>
</section> </section>
</div>
<section> <section>
<h2>Interests</h2> <h2>Interests</h2>
<ul class="list-disc list-inside"> <ul class="list-disc list-inside">
<li>Leetcode daily competitive problem solving</li> <li>Leetcode</li>
<li>Learning Mandarin</li>
<li>Rhythm Games</li> <li>Rhythm Games</li>
<li>Climbing · Gym</li> <li>Climbing, Gym</li>
<li>Board games · Meetup.com</li> <li>Board games, Meetup.com</li>
</ul> </ul>
</section> </section>
</div> </div>
@@ -153,176 +172,3 @@ import Project from "./Project.vue";
<div class="no-print w-full h-20"></div> <div class="no-print w-full h-20"></div>
</main> </main>
</template> </template>
<style scoped>
/* Fonts */
@font-face {
font-family: "big_noodle_titling";
src: url("/fonts/big_noodle_titling.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "CreatoDisplay";
src: url("/fonts/CreatoDisplay-Bold.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* Variables */
* {
--primary: black;
--secondary: #0000ff;
--tertiary: #ff0000;
--quaternary: #cccccc;
--background: white;
--font-heading: big_noodle_titling;
--font-text: CreatoDisplay;
--font-size-name: 2.5em;
--font-size-text: 100%;
--font-size-small: 0.9em;
--font-size-heading: 2.1em;
--font-size-subheading: 1.7em;
--font-size-subsubheading: 1.4em;
}
/* A4 Page */
.a4page {
line-height: 1.6;
font-family: var(--font-text);
width: 210mm;
height: 297mm;
padding: 5mm;
box-sizing: border-box;
background-color: var(--background);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
border: 1px solid var(--primary);
overflow: hidden;
margin: auto auto;
display: flex;
flex-direction: column;
justify-content: space-between;
}
/* Component Styling */
main {
padding: 0px;
display: flex;
flex-direction: column;
height: fit-content;
background-color: white;
}
span {
height: 2em;
}
h1,
h2,
h3,
h4 {
margin: 0px;
border: none;
color: var(--primary);
font-family: var(--font-heading);
text-transform: capitalize;
}
h1 {
font-size: var(--font-size-heading);
}
h2 {
border-bottom: 1px solid var(--primary);
font-size: var(--font-size-subheading);
}
h3 {
font-size: var(--font-size-subsubheading);
}
a:hover {
color: var(--tertiary);
}
a {
background-color: transparent;
color: var(--secondary);
}
p {
margin-bottom: 0.2em;
color: var(--primary);
font-size: var(--font-size-text);
}
table {
color: var(--secondary);
border-collapse: collapse;
border: 1px solid black;
}
td {
color: var(--secondary);
border-top: 1px solid var(--tertiary);
padding: 1px 10px 1px 10px;
font-size: var(--font-size-text);
text-align: left;
}
th {
color: var(--secondary);
border: 2px solid var(--tertiary);
padding: 1px 0px 1px 7px;
font-family: var(--font-heading);
font-size: var(--font-size-subsubheading);
background-color: var(--quaternary);
text-align: left;
}
@media print {
.no-print {
display: none !important;
}
}
small {
font-size: var(--font-size-small);
color: var(--primary);
}
ul {
font-size: var(--font-size-small);
margin: 0;
padding-left: 1.2em;
}
li {
font-size: var(--font-size-small);
color: var(--primary);
}
.skills-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.3em 1em;
margin-bottom: 0.2em;
}
@media (max-width: 640px) {
.a4page {
width: 100%;
height: auto;
overflow: visible;
box-shadow: none;
border: none;
}
.skills-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,330 @@
<script setup>
import Project from "./Project.vue";
</script>
<template>
<main class="cv-template">
<div class="no-print w-full h-20"></div>
<div class="a4page justify-between">
<section>
<div class="flex flex-col sm:flex-row sm:justify-between">
<h1 class="name">Adam French</h1>
<div class="contact-details">
<p>London, United Kingdom</p>
<p>+447563266931</p>
<p>adam.a.french@outlook.com</p>
<p>
<a href="https://www.adam-french.co.uk">
www.adam-french.co.uk
</a>
</p>
</div>
</div>
</section>
<section>
<h2>Profile</h2>
<p contenteditable="true">
Full Stack Developer with a First Class Honours degree in Computer
Science with Mathematics from the University of Leeds (81.1%) and a
year abroad at the University of Waterloo. Proficient in full-stack
development, systems programming, and CI/CD automation. Strong
problem-solving and organisational skills. Eager to contribute to a
collaborative engineering team, apply strong academic foundations to
real-world problems, and grow through hands-on experience.
</p>
</section>
<section>
<h2>Skills</h2>
<div class="skills-grid">
<div>
<strong>Languages</strong><br /><small
>Go, Rust, Python, JavaScript / TypeScript, SQL</small
>
</div>
<div>
<strong>Frontend / Web Design</strong><br /><small
>Vue, React / Redux, Svelte, Tailwind CSS, WebAssembly</small
>
</div>
<div>
<strong>Backend / Infra</strong><br /><small
>Nginx, Docker, PostgreSQL, SQLite, JWT Auth, Git Actions</small
>
</div>
</div>
</section>
<section>
<h2>Projects</h2>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a
href="https://www.adam-french.co.uk/gitea/adamf/web_server.git"
>
web_server.git
</a>
</h4>
</template>
<template v-slot:top>
<small>
Nginx, Vue, Postgres, Docker, Go, Python, Rust, Wasm, Git Actions,
JWT Auth
</small>
<small>2025</small>
</template>
<p contenteditable="true">
Self-hosted personal website with a fully automated CI/CD pipeline.
Iterated across diverse tech stacks including Svelte, React/Redux,
SQLite, Rust Actix, and Deno.
</p>
</Project>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a href="https://www.adam-french.co.uk/gitea/adamf/tour.git">
tour.git
</a>
</h4>
</template>
<template v-slot:top>
<small>Rust</small>
<small>2026</small>
</template>
<p contenteditable="true">
CLI tool for building and navigating interactive code tutorials,
with version-traversal semantics inspired by Git.
</p>
</Project>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a
href="https://www.adam-french.co.uk/gitea/adamf/rust-raytracer.git"
>
rust-raytracer.git
</a>
</h4>
</template>
<template v-slot:top>
<small>Rust, Linear Algebra, Multithreading</small>
<small>2023</small>
</template>
<p contenteditable="true">
Parallelised recursive ray tracer for realistic 3D rendering.
Emphasised algorithmic efficiency and low-level memory management in
Rust.
</p>
</Project>
<Project>
<template #left>
<h4>
<a
class="text-center w-full"
href="https://community.wolfram.com/groups/-/m/t/3210947"
>
Wolfram Summer School
</a>
</h4>
</template>
<template #top>
<small>Wolfram Mathematica</small>
<small>2024</small>
</template>
<p contenteditable="true">
Research project on Mobile Automata with data visualisation and
academic presentation. Delivered within a tight deadline in
collaboration with academic mentors.
</p>
</Project>
</section>
<div class="w-full flex flex-col sm:flex-row gap-5">
<section class="flex-1">
<h2>
<a href="https://www.adam-french.co.uk/pdf/transcript.pdf">
University of Leeds
</a>
</h2>
<div
class="flex-row flex place-content-between m-auto place-items-center"
>
<small>First Class Honours (81.1%)</small>
<small>Sep 2021 Jun 2025</small>
</div>
<small>BSc Computer Science with Mathematics </small>
<ul class="list-disc list-inside">
<li>Algorithms & Data Structures I & II</li>
<li>Compiler Design and Construction</li>
<li>Formal Languages & Finite Automata</li>
<li>Graph Algorithms & Complexity Theory</li>
<li>Machine Learning, Databases, Computer Processors</li>
</ul>
</section>
<section class="flex-1">
<h2>University of Waterloo</h2>
<div
class="flex-row flex place-content-between m-auto place-items-center"
>
<small>Year abroad</small>
<small>Sep 2023 Apr 2024</small>
</div>
<ul class="list-disc list-inside">
<li>Applied Cryptography</li>
<li>Introduction to Computer Graphics</li>
<li>Introduction to Rings and Fields with Applications</li>
</ul>
</section>
</div>
</div>
<div class="no-print w-full h-20"></div>
<div class="a4page gap-10">
<section>
<h2>University Academy of Engineering Southbank</h2>
<div
class="flex-row flex place-content-between m-auto place-items-center"
>
<small>Secondary & Sixth Form Education</small>
<small>Sep 2014 Jun 2021</small>
</div>
<div class="w-full flex flex-col sm:flex-row gap-5">
<div class="flex-1">
<strong>A-Levels</strong>
<div
style="
display: grid;
grid-template-columns: 1fr auto;
column-gap: 1em;
"
>
<span>Mathematics</span><span>A*</span>
<span>Further Mathematics</span><span>A*</span>
<span>Physics</span><span>A*</span>
</div>
</div>
<div class="flex-1">
<strong>GCSEs</strong>
<div
style="
display: grid;
grid-template-columns: 1fr auto;
column-gap: 1em;
"
>
<span>Mathematics</span><span>9</span>
<span>English Literature</span><span>9</span> <span>Physics</span
><span>9</span> <span>English Language</span><span>8</span>
<span>Biology</span><span>8</span> <span>Chemistry</span
><span>8</span> <span>Computer Science</span><span>7</span>
</div>
</div>
</div>
</section>
<section>
<h2>Experience</h2>
<Project class="border-b border-dotted">
<template #left>
<h4>Daisy Green Holland Park</h4>
</template>
<template #top>
<small>Barista & Front of House</small>
<small>May 2026 Present</small>
</template>
<p contenteditable="true">
Working at an all-day café in Holland Park, preparing coffee and
serving food in a fast-paced environment.
</p>
</Project>
<Project class="border-b border-dotted">
<template #left>
<h4>Belgrave Music Hall</h4>
</template>
<template #top>
<small>Bartender & Waiter</small>
<small>20212025</small>
</template>
<p contenteditable="true">
Served food and drinks in a high-volume live-music venue in Leeds.
Handled busy weekend shifts, managed multiple tables simultaneously,
and maintained a calm, friendly demeanour during peak hours.
</p>
</Project>
<Project class="border-b border-dotted">
<template #left>
<h4>The Crown and Anchor</h4>
</template>
<template #top>
<small>Bartender & Waiter</small>
<small>20202021</small>
</template>
<p contenteditable="true">
Worked front-of-house at a busy pub, pulling pints, taking orders,
and ensuring a welcoming atmosphere. Built rapport with regulars and
adapted quickly to changing priorities during service.
</p>
</Project>
<Project class="border-b border-dotted">
<template #left>
<h4>To The Rise Bakery</h4>
</template>
<template #top>
<small>Barista & Front of House</small>
<small>20202021</small>
</template>
<p contenteditable="true">
Worked at a bakery in Eastbourne, preparing freshly baked goods for
display, operating the coffee machine, and keeping bakery equipment
spotless. Built rapport with regulars and provided attentive,
friendly service.
</p>
</Project>
<Project>
<template #left>
<h4>BFI Riverfront Kitchen</h4>
</template>
<template #top>
<small>Cashier & Waiter</small>
<small>20182020</small>
</template>
<p contenteditable="true">
Operated the till, served customers, and helped coordinate table
service at a café on London's South Bank. Developed strong
cash-handling accuracy and customer interaction skills in a
fast-paced environment.
</p>
</Project>
</section>
<div class="w-full flex flex-col sm:flex-row gap-5">
<section class="flex-1">
<h2>Soft Skills</h2>
<ul class="list-disc list-inside">
<li>Communication & collaboration</li>
<li>Attention to detail</li>
<li>Problem solving</li>
<li>Adaptability</li>
<li>Time management</li>
</ul>
</section>
<section class="flex-1">
<h2>Interests</h2>
<ul class="list-disc list-inside">
<li>Leetcode</li>
<li>Learning Mandarin</li>
<li>Rhythm Games</li>
<li>Climbing, Gym</li>
<li>Board games, Meetup.com</li>
</ul>
</section>
</div>
</div>
<div class="no-print w-full h-20"></div>
</main>
</template>

View File

@@ -0,0 +1,231 @@
<script setup>
import Project from "./Project.vue";
</script>
<template>
<main class="cv-template">
<div class="no-print w-full h-20"></div>
<div class="a4page justify-between">
<section>
<div class="flex flex-col sm:flex-row sm:justify-between">
<h1 class="flex-1 name">Adam French</h1>
<div class="contact-details">
<p>London, United Kingdom</p>
<p>+447563266931</p>
<p>adam.a.french@outlook.com</p>
<p>
<a href="https://www.adam-french.co.uk">
www.adam-french.co.uk
</a>
</p>
</div>
</div>
</section>
<section>
<h2>Profile</h2>
<p contenteditable="true">
Computer Science with Mathematics graduate (First Class Honors, 81.1%)
from the University of Leeds, with a year abroad at the University of
Waterloo. Hands-on experience self-hosting and hardening web
infrastructure, implementing authentication systems, and writing
memory-safe systems code. Methodical, detail-driven, and enjoy
adversarial problem-solving.
</p>
</section>
<section>
<h2>Skills</h2>
<div class="skills-grid">
<div>
<strong>Security</strong><br /><small
>Applied Cryptography, JWT Auth, TLS / Certbot, Rate-Limiting,
OWASP Top 10</small
>
</div>
<div>
<strong>Languages & Systems</strong><br /><small
>Rust, Go, Python, C, JavaScript / TypeScript, SQL</small
>
</div>
<div>
<strong>Infra & Tooling</strong><br /><small
>Linux, Nginx, Docker, PostgreSQL, Git, CI/CD, WebAssembly</small
>
</div>
</div>
</section>
<section>
<h2>Projects</h2>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a
href="https://www.adam-french.co.uk/gitea/adamf/web_server.git"
>
web_server.git
</a>
</h4>
</template>
<template v-slot:top>
<small>
Nginx, Docker, JWT Auth, TLS, Rate-Limiting, Go, Postgres
</small>
<small>2025</small>
</template>
<p contenteditable="true">
Self-hosted personal site running on a Raspberry Pi. Implemented JWT
auth with HTTP-only cookies, Nginx rate-limiting on login and
uploads, automated TLS via Certbot, and a hardened reverse-proxy
configuration.
</p>
</Project>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a
href="https://www.adam-french.co.uk/gitea/adamf/rust-raytracer.git"
>
rust-raytracer.git
</a>
</h4>
</template>
<template v-slot:top>
<small>Rust, Multithreading, Memory Safety</small>
<small>2023</small>
</template>
<p contenteditable="true">
Parallelised recursive ray tracer written in Rust, emphasising
low-level memory management, safe concurrency, and disciplined
handling of unsafe boundaries.
</p>
</Project>
<Project class="border-b border-dotted">
<template v-slot:left>
<h4>
<a href="https://www.adam-french.co.uk/gitea/adamf/tour.git">
tour.git
</a>
</h4>
</template>
<template v-slot:top>
<small>Rust, CLI, Version Control Internals</small>
<small>2026</small>
</template>
<p contenteditable="true">
CLI tool for building and navigating interactive code tutorials,
with version-traversal semantics inspired by Git.
</p>
</Project>
<Project>
<template #left>
<h4>
<a
class="text-center w-full"
href="https://community.wolfram.com/groups/-/m/t/3210947"
>
Wolfram Summer School
</a>
</h4>
</template>
<template #top>
<small>Wolfram Mathematica</small>
<small>2024</small>
</template>
<p contenteditable="true">
Research project on Mobile Automata with data visualisation and
academic presentation. Delivered within a tight deadline in
collaboration with academic mentors.
</p>
</Project>
</section>
<div class="w-full flex flex-col sm:flex-row gap-5">
<section class="flex-1">
<h2>
<a href="https://www.adam-french.co.uk/pdf/transcript.pdf">
University of Leeds
</a>
</h2>
<div
class="flex-row flex place-content-between m-auto place-items-center"
>
<small>First Class Honors (81.1%)</small>
<small>Sep 2021 Jun 2025</small>
</div>
<small>BSc Computer Science with Mathematics </small>
<ul class="list-disc list-inside">
<li>Algorithms & Data Structures I & II</li>
<li>Compiler Design and Construction</li>
<li>Formal Languages & Finite Automata</li>
<li>Graph Algorithms & Complexity Theory</li>
<li>Computer Processors, Databases, Networks</li>
</ul>
</section>
<section class="flex-1">
<h2>University of Waterloo</h2>
<div
class="flex-row flex place-content-between m-auto place-items-center"
>
<small>Year abroad</small>
<small>Sep 2023 Apr 2024</small>
</div>
<ul class="list-disc list-inside">
<li>Applied Cryptography</li>
<li>Introduction to Rings and Fields with Applications</li>
<li>Introduction to Computer Graphics</li>
</ul>
</section>
</div>
</div>
<div class="no-print w-full h-20"></div>
<div class="a4page gap-10">
<section>
<h2>Experience</h2>
<Project>
<template #left>
<h4>Hospitality</h4>
</template>
<template #top>
<small>Cashier, Bartender, Waiter</small>
<small>Jan 2018 Dec 2023</small>
</template>
<p contenteditable="true">
Worked at <strong>Belgrave Music Hall</strong>,
<strong>The Crown and Anchor</strong>, and
<strong>BFI Riverfront Kitchen</strong>. Developed communication,
composure under pressure, and reliability in customer-facing roles.
</p>
</Project>
</section>
<div class="w-full flex flex-col sm:flex-row gap-5">
<section class="flex-1">
<h2>Soft Skills</h2>
<ul class="list-disc list-inside">
<li>Methodical, evidence-led problem solving</li>
<li>Attention to detail</li>
<li>Clear written communication</li>
<li>Self-directed learning</li>
<li>Composure under pressure</li>
</ul>
</section>
<section class="flex-1">
<h2>Interests</h2>
<ul class="list-disc list-inside">
<li>Capture The Flag (CTF) challenges</li>
<li>Leetcode</li>
<li>Learning Mandarin</li>
<li>Climbing, Gym</li>
<li>Board games, Meetup.com</li>
</ul>
</section>
</div>
</div>
<div class="no-print w-full h-20"></div>
</main>
</template>

View File

@@ -3,33 +3,31 @@ import Project from "./Project.vue";
</script> </script>
<template> <template>
<main> <main class="cv-template">
<div class="no-print w-full h-20"></div> <div class="no-print w-full h-20"></div>
<div class="a4page justify-between"> <div class="a4page justify-between">
<section class="flex flex-col sm:flex-row sm:justify-between"> <section class="flex flex-col sm:flex-row sm:justify-between">
<h1 class="name">Adam French</h1> <h1 class="name">Adam French</h1>
<div class="contact-details sm:text-right"> <div class="contact-details">
<p>London, United Kingdom</p>
<p>+447563266931</p> <p>+447563266931</p>
<p>adam.a.french@outlook.com</p> <p>adam.a.french@outlook.com</p>
<h4> <p>
<a href="https://www.adam-french.co.uk"> <a href="https://www.adam-french.co.uk"> www.adam-french.co.uk </a>
www.adam-french.co.uk </p>
</a>
</h4>
</div> </div>
</section> </section>
<section> <section>
<h2>Profile</h2> <h2>Profile</h2>
<p> <p>
First Class Honours graduate in Computer Science with First Class Honours graduate in Computer Science with Mathematics from
Mathematics from the University of Leeds (81.1%), with a the University of Leeds (81.1%), with a year abroad at the University
year abroad at the University of Waterloo. Passionate about of Waterloo. Passionate about developer productivity, automation
developer productivity, automation infrastructure, and infrastructure, and software testing at scale. Experienced building
software testing at scale. Experienced building CI/CD CI/CD pipelines, automation tooling, and scalable backend services.
pipelines, automation tooling, and scalable backend Eager to apply rigorous engineering discipline within a collaborative
services. Eager to apply rigorous engineering discipline platform team.
within a collaborative platform team.
</p> </p>
</section> </section>
@@ -38,20 +36,19 @@ import Project from "./Project.vue";
<div class="skills-grid"> <div class="skills-grid">
<div> <div>
<strong>Languages</strong><br /><small <strong>Languages</strong><br /><small
>Python, Go, Rust, Swift, JavaScript / TypeScript, >Python, Go, Rust, Swift, JavaScript / TypeScript, SQL</small
SQL</small
> >
</div> </div>
<div> <div>
<strong>Automation &amp; Testing</strong><br /><small <strong>Automation &amp; Testing</strong><br /><small
>CI/CD Pipelines, GitHub Actions, Docker, Unit &amp; >CI/CD Pipelines, GitHub Actions, Docker, Unit &amp; Integration
Integration Testing, WebAssembly</small Testing, WebAssembly</small
> >
</div> </div>
<div> <div>
<strong>Infrastructure</strong><br /><small <strong>Infrastructure</strong><br /><small
>Nginx, PostgreSQL, SQLite, JWT Auth, REST &amp; >Nginx, PostgreSQL, SQLite, JWT Auth, REST &amp; GraphQL
GraphQL APIs</small APIs</small
> >
</div> </div>
</div> </div>
@@ -72,26 +69,23 @@ import Project from "./Project.vue";
</template> </template>
<template v-slot:top> <template v-slot:top>
<small> <small>
GitHub Actions, Docker, Nginx, Go, Python, Rust GitHub Actions, Docker, Nginx, Go, Python, Rust Wasm, Postgres,
Wasm, Postgres, JWT Auth JWT Auth
</small> </small>
<small>2025</small> <small>2025</small>
</template> </template>
<p> <p>
Self-hosted personal website with a fully automated Self-hosted personal website with a fully automated CI/CD pipeline:
CI/CD pipeline: lint, build, test, and deploy on every lint, build, test, and deploy on every push. Designed for
push. Designed for zero-downtime deployments on zero-downtime deployments on constrained Raspberry Pi hardware.
constrained Raspberry Pi hardware. Iterated across Iterated across diverse stacks to evaluate tradeoffs in
diverse stacks to evaluate tradeoffs in infrastructure infrastructure and developer experience.
and developer experience.
</p> </p>
</Project> </Project>
<Project class="border-b border-dotted"> <Project class="border-b border-dotted">
<template v-slot:left> <template v-slot:left>
<h4> <h4>
<a <a href="https://www.adam-french.co.uk/gitea/adamf/tour.git">
href="https://www.adam-french.co.uk/gitea/adamf/tour.git"
>
tour.git tour.git
</a> </a>
</h4> </h4>
@@ -101,11 +95,10 @@ import Project from "./Project.vue";
<small>2026</small> <small>2026</small>
</template> </template>
<p> <p>
Developer productivity CLI for building and navigating Developer productivity CLI for building and navigating interactive
interactive code tutorials with Git-inspired version code tutorials with Git-inspired version traversal. Designed as a
traversal. Designed as a reusable automation library reusable automation library with a clean API surface for embedding
with a clean API surface for embedding in larger in larger toolchains.
toolchains.
</p> </p>
</Project> </Project>
<Project class="border-b border-dotted"> <Project class="border-b border-dotted">
@@ -123,9 +116,9 @@ import Project from "./Project.vue";
<small>2023</small> <small>2023</small>
</template> </template>
<p> <p>
Parallelised recursive ray tracer for realistic 3D Parallelised recursive ray tracer for realistic 3D rendering.
rendering. Emphasised algorithmic efficiency and Emphasised algorithmic efficiency and low-level memory management in
low-level memory management in Rust. Rust.
</p> </p>
</Project> </Project>
<Project> <Project>
@@ -144,10 +137,9 @@ import Project from "./Project.vue";
<small>2024</small> <small>2024</small>
</template> </template>
<p> <p>
Research project on Mobile Automata with data Research project on Mobile Automata with data visualisation and
visualisation and academic presentation. Delivered academic presentation. Delivered within a tight deadline in
within a tight deadline in collaboration with academic collaboration with academic mentors.
mentors.
</p> </p>
</Project> </Project>
</section> </section>
@@ -156,22 +148,18 @@ import Project from "./Project.vue";
<div class="no-print w-full h-20"></div> <div class="no-print w-full h-20"></div>
<div class="a4page gap-10"> <div class="a4page gap-10">
<section> <div class="w-full flex flex-col sm:flex-row gap-5">
<h2>Education</h2> <section class="flex-1">
<div class="w-full h-fit flex flex-col sm:flex-row gap-5"> <h2>
<div class="flex-1 sm:border-r border-dotted sm:pr-3"> <a href="https://www.adam-french.co.uk/pdf/transcript.pdf">
<h3>
<a
href="https://www.adam-french.co.uk/pdf/transcript.pdf"
>
University of Leeds University of Leeds
</a> </a>
</h3> </h2>
<div <div
class="flex-row flex place-content-between m-auto place-items-center" class="flex-row flex place-content-between m-auto place-items-center"
> >
<small>First Class Honours (81.1%)</small> <small>First Class Honours (81.1%)</small>
<small>20212025</small> <small>Sep 2021 Jun 2025</small>
</div> </div>
<small>BSc Computer Science with Mathematics </small> <small>BSc Computer Science with Mathematics </small>
<ul class="list-disc list-inside"> <ul class="list-disc list-inside">
@@ -179,31 +167,24 @@ import Project from "./Project.vue";
<li>Compiler Design and Construction</li> <li>Compiler Design and Construction</li>
<li>Formal Languages & Finite Automata</li> <li>Formal Languages & Finite Automata</li>
<li>Graph Algorithms & Complexity Theory</li> <li>Graph Algorithms & Complexity Theory</li>
<li> <li>Machine Learning, Databases, Computer Processors</li>
Machine Learning · Databases · Computer
Processors
</li>
</ul> </ul>
</div> </section>
<div class="flex-1 sm:pl-3"> <section class="flex-1">
<h3>University of Waterloo</h3> <h2>University of Waterloo</h2>
<div <div
class="flex-row flex place-content-between m-auto place-items-center" class="flex-row flex place-content-between m-auto place-items-center"
> >
<small>Year abroad</small> <small>Year abroad</small>
<small>20232024</small> <small>Sep 2023 Apr 2024</small>
</div> </div>
<ul class="list-disc list-inside"> <ul class="list-disc list-inside">
<li>Applied Cryptography</li> <li>Applied Cryptography</li>
<li>Introduction to Computer Graphics</li> <li>Introduction to Computer Graphics</li>
<li> <li>Introduction to Rings and Fields with Applications</li>
Introduction to Rings and Fields with
Applications
</li>
</ul> </ul>
</div>
</div>
</section> </section>
</div>
<section> <section>
<h2>Experience</h2> <h2>Experience</h2>
<Project> <Project>
@@ -216,9 +197,8 @@ import Project from "./Project.vue";
</template> </template>
<p> <p>
Worked at <em>Belgrave Music Hall</em>, Worked at <em>Belgrave Music Hall</em>,
<em>The Crown and Anchor</em>, and <em>The Crown and Anchor</em>, and <em>BFI Riverfront Kitchen</em>.
<em>BFI Riverfront Kitchen</em>. Developed Developed communication, composure under pressure, and reliability
communication, composure under pressure, and reliability
in customer-facing roles. in customer-facing roles.
</p> </p>
</Project> </Project>
@@ -237,175 +217,3 @@ import Project from "./Project.vue";
<div class="no-print w-full h-20"></div> <div class="no-print w-full h-20"></div>
</main> </main>
</template> </template>
<style scoped>
/* Fonts */
@font-face {
font-family: "big_noodle_titling";
src: url("/fonts/big_noodle_titling.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "CreatoDisplay";
src: url("/fonts/CreatoDisplay-Bold.woff2") format("woff2");
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* Variables */
* {
--primary: black;
--secondary: #0000ff;
--tertiary: #ff0000;
--quaternary: #cccccc;
--background: white;
--font-heading: big_noodle_titling;
--font-text: CreatoDisplay;
--font-size-name: 2.5em;
--font-size-text: 100%;
--font-size-small: 0.9em;
--font-size-heading: 2.1em;
--font-size-subheading: 1.7em;
--font-size-subsubheading: 1.4em;
}
/* A4 Page */
.a4page {
line-height: 1.6;
font-family: var(--font-text);
width: 210mm;
height: 297mm;
padding: 5mm;
box-sizing: border-box;
background-color: var(--background);
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
border: 1px solid var(--primary);
overflow: hidden;
margin: auto auto;
display: flex;
flex-direction: column;
}
/* Component Styling */
main {
padding: 0px;
display: flex;
flex-direction: column;
height: fit-content;
background-color: white;
}
span {
height: 2em;
}
h1,
h2,
h3,
h4 {
border: none;
color: var(--primary);
font-family: var(--font-heading);
text-transform: capitalize;
margin: 0px;
}
h1 {
font-size: var(--font-size-heading);
}
h2 {
border-bottom: 1px solid var(--primary);
font-size: var(--font-size-subheading);
}
h3 {
font-size: var(--font-size-subsubheading);
}
a:hover {
color: var(--tertiary);
}
a {
background-color: transparent;
color: var(--secondary);
}
p {
margin-bottom: 0.2em;
color: var(--primary);
font-size: var(--font-size-text);
}
table {
color: var(--secondary);
border-collapse: collapse;
border: 1px solid black;
}
td {
color: var(--secondary);
border-top: 1px solid var(--tertiary);
padding: 1px 10px 1px 10px;
font-size: var(--font-size-text);
text-align: left;
}
th {
color: var(--secondary);
border: 2px solid var(--tertiary);
padding: 1px 0px 1px 7px;
font-family: var(--font-heading);
font-size: var(--font-size-subsubheading);
background-color: var(--quaternary);
text-align: left;
}
@media print {
.no-print {
display: none !important;
}
}
small {
font-size: var(--font-size-small);
color: var(--primary);
}
ul {
font-size: var(--font-size-small);
margin: 0;
padding-left: 1.2em;
}
li {
font-size: var(--font-size-small);
color: var(--primary);
}
.skills-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.3em 1em;
margin-bottom: 0.2em;
}
@media (max-width: 640px) {
.a4page {
width: 100%;
height: auto;
overflow: visible;
box-shadow: none;
border: none;
}
.skills-grid {
grid-template-columns: 1fr;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More