Compare commits

331 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
7afd1be81b Add Gitea entrypoint to generate app.ini from template on startup
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6s
Since app.ini is gitignored, the container needs to create it at
runtime. The entrypoint copies the template on first start, then
Gitea's env var overrides handle secrets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 14:40:38 +01:00
cdcef5ba96 Gitignore gitea app.ini and remove INTERNAL_TOKEN from template
Runtime config (app.ini) should not be tracked since secrets are
injected via env vars in docker-compose. The template remains as
source of truth. INTERNAL_TOKEN is removed from the template as
it is set exclusively via GITEA__security__INTERNAL_TOKEN env var.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 14:37:30 +01:00
494f61f9c6 Reduce secret syncing to just .env
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 7s
Strip hardcoded secrets from gitea/config/app.ini (already injected
via GITEA__ env vars) and commit it to git. Add download.sh to fetch
the act_runner binary on demand instead of syncing it. Everything else
(searxng settings, certbot certs, runner registration, Spotify tokens)
is generated at runtime.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 14:22:50 +01:00
67771777cd Fix searxng upstream entrypoint path
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 10s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 14:11:39 +01:00
c4c642073d Replace envsubst with sed in searxng entrypoint to avoid extra dependencies
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 9s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 14:10:03 +01:00
8da6364e6e Fix searxng Dockerfile: use xbps-install for Void Linux base image
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 6s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 14:09:19 +01:00
6f2a32aabb Install gettext in searxng container for envsubst support
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 6s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 14:07:57 +01:00
75f4608d34 Fix searxng entrypoint: use sh instead of bash for Alpine compatibility
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 7s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 13:45:56 +01:00
2e3d5f4dbe Use fetch+reset in deploy workflow to avoid merge conflicts
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 48s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 13:40:08 +01:00
258d97757c Add README documenting untracked files and sync-secrets script
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 13:39:00 +01:00
4edc5b1b22 Remove uptime-kuma and wallabag services, fix searxng base URL
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 1s
Remove unused uptime-kuma and wallabag services from docker-compose,
nginx configs, and vite proxy. Use BASE_URL env var in searxng
settings template instead of hardcoded URL.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 13:37:24 +01:00
bfefbb1d2a Skip SSH host key check for localhost in deploy workflow
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 7s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 13:24:15 +01:00
5d3c73d537 Fix deploy: use localhost instead of Docker-internal hostname for git pull
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 13:23:32 +01:00
4c8573767d Secure gitea-runner: remove tracked secrets, use SSH for deploy
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 1s
- Untrack .runner (contains registration token), act_runner binary, and nohup.out
- Add gitea-runner sensitive files to .gitignore
- Auto-register runner from env var if .runner is missing
- Switch deploy workflow git pull from HTTP to SSH

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 13:19:45 +01:00
4d16dd8d17 Use binary gitea runner to avoid stress
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-04-07 13:15:35 +01:00
d04496ad11 Remove gitea-runner service and related files
Some checks are pending
Deploy with Docker Compose / deploy (push) Waiting to run
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 13:14:17 +01:00
6edca785ff Fix proxy_pass path stripping for wallabag and uptime-kuma
Add trailing slash to proxy_pass so nginx strips the subpath prefix
before forwarding requests to the upstream containers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 13:10:41 +01:00
74f606459f Build gitea-runner with docker CLI installed natively
Some checks are pending
Deploy with Docker Compose / deploy (push) Waiting to run
Mounting host docker binary failed due to glibc/musl incompatibility.
Instead, extend the act_runner image and install docker-cli and
docker-cli-compose via apk.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 13:02:06 +01:00
ce1a1ee757 Mount docker CLI and compose plugin into gitea-runner
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 0s
The act_runner container had the Docker socket but not the docker
binary, so deploy workflow steps using docker compose failed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 13:00:50 +01:00
68b9985d99 Use HTTP URL for git pull in deploy workflow
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 0s
The act_runner container lacks SSH, so pull via HTTP using the
Docker network hostname instead of the named SSH remote.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-07 12:59:51 +01:00
a967a249c2 Add base url to new containers
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 0s
2026-04-07 12:57:16 +01:00
8a6e34dd69 Fix gitea-runner compatibility for deploy workflow
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 1s
Mount the deploy directory at the same absolute path in the runner
container so docker compose bind mounts resolve correctly on the host
Docker daemon. Add git safe.directory config to avoid ownership errors
when the runner (root) operates on host-owned files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:45:52 +01:00
f027506c87 Add label to gitea runner
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2s
2026-04-07 12:22:04 +01:00
54ab64c67d Remove CI-CD and use Deploy
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-04-07 12:19:26 +01:00
108f58e527 Add UptimeKuma, Searxng, Wallabag services
Some checks failed
CI/CD / Deploy (push) Has been cancelled
CI/CD / Build Frontend (push) Has been cancelled
CI/CD / Build & Test Backend (push) Has been cancelled
Deploy with Docker Compose / deploy (push) Has been cancelled
- Add uptime-kuma, searxng, and wallabag Docker services with Postgres integration for wallabag
- Add nginx reverse proxy location blocks for /uptime-kuma/, /searxng/, /wallabag/ in both prod and dev templates
- Update entrypoint.sh envsubst to include new HOST/PORT vars
- Add Vite dev proxy entries for all three services
- Update gitea-runner config: add self-hosted label and allow all volumes
- Add Gitea CI/CD workflow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 12:14:56 +01:00
e62424368b Fix Quartz layout export name to match v4.4.0 expected import
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 8s
Rename sharedLayout to sharedPageComponents to fix build errors in
Quartz emitters (contentPage, tagPage, folderPage, 404).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 23:14:10 +01:00
e17a7a9807 Remove graph component from Quartz to fix browser crash
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 10s
Add custom quartz.layout.ts overriding the default layout to remove
Component.Graph(), the D3 force-directed graph known to crash browsers
on large note sets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 23:10:35 +01:00
67f3895a1e Fix quartz causing browser crash
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 10s
2026-04-06 23:05:33 +01:00
058ae3b3f1 Change /notes/ to be a normal link instead of routerlink
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 18s
2026-04-06 22:59:37 +01:00
282454140f Remove max height from image in chat and change breakpoint for mobile
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 14s
2026-04-06 22:55:46 +01:00
6029066a94 Update notes link to quartz
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 22s
2026-04-06 14:04:16 +01:00
01adee7941 Revert quartz changes
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 9s
2026-04-06 13:58:57 +01:00
1f6c540c1c Fix Quartz not reading mounted content directory
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 15s
Pass --directory /content so quartz build reads from the volume mount
instead of the default /quartz/content path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:53:04 +01:00
fa79fe9cdb Fix path
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 9s
2026-04-06 13:46:36 +01:00
83c130b5c3 Fix path
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 10s
2026-04-06 13:42:55 +01:00
b6623de23a Add Quartz service for serving Obsidian notes at /notes/
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m28s
Replaces the custom Go/Vue notes system with Quartz v4, a polished
static site generator for Obsidian vaults. Mounts OBSIDIAN_DIR as the
Quartz content directory and serves it at /notes/ with hot-reload via
`npx quartz build --serve`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 13:27:10 +01:00
7e8e50f80a Remove tailwind css from stylesheet and updated CV general to conform to ATS standards
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 25s
2026-04-01 20:25:58 +01:00
a44011bf0b Add disable/enable toggle for radio fallback songs
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m27s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 16:47:04 +01:00
d215333128 Add admin UI for managing radio fallback music
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m44s
Upload, list, and delete fallback music files from the admin page.
Backend handlers validate file type/size and prevent path traversal.
Nginx max body size increased to 50M to support large audio files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 16:19:10 +01:00
179f52d1d7 Make CV pages responsive for mobile viewports
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 20s
Add flex-wrap, stacked layouts on small screens, and mobile-friendly
a4page/skills-grid styles across all CV variants.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 03:11:42 +01:00
08c29a77a0 Separate Links header from justify-between layout
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 15s
Move site and social link groups into their own flex container
so justify-between spaces them apart without including the header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 03:04:22 +01:00
e69942a7e8 Cap image max-width in CommitHistory and Steam for responsive layout
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 18s
Prevents avatar and game images from stretching too wide when sidebars
expand to 95vw at the 1360px breakpoint.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 03:00:16 +01:00
d268fea4be Fix Listening component preventing grid from shrinking and improve responsive layout
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 14s
Remove fixed width/height attributes from album art image that set a minimum
intrinsic size, add fluid image styles, and improve mobile grid layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 02:57:09 +01:00
d7178ac60a Refactor home grid layout to use grid-template-areas
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 14s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 02:46:28 +01:00
b48a273916 Match sidebar margin and gap to grid on home page
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 14s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 02:43:06 +01:00
24fd4dd00c Improve home page responsive layout and overflow handling
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 19s
Rework media query breakpoints and grid placement for better
tablet/mobile display. Add overflow-auto to Radio, Links, and
Listening components. Add Links header. Simplify Intro2 animation
initial positions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 02:38:21 +01:00
75cede3b1b Fix security vulnerabilities across backend, frontend, and infra
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m44s
- Fix auth bypass in UpdatePost/DeletePost (missing return after auth check)
- Remove Spotify access token from callback response
- Replace internal error messages with generic responses in all handlers
- Harden GraphQL: complexity limit, disable playground/introspection in prod
- Add security headers (X-Frame-Options, HSTS, etc.) to nginx
- Disable Hasura console/dev mode in production
- Add DOMPurify sanitization to Markdown component
- Fix cookie removal to use correct domain/path from auth config
- Fix nil dereference in rowing handler when Claude API errors
- Fix wildcard CORS on stamp endpoint
- Pin nginx and certbot Docker image versions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 23:59:10 +01:00
091bfcaef6 Add Hasura GraphQL Engine container with nginx proxy
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m31s
Adds Hasura v2.44.0 service connected to the existing Postgres database,
proxied through nginx at /hasura/ with WebSocket support for the console.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 20:06:19 +01:00
24bb0195e9 small fixes
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 24s
2026-03-29 19:52:53 +01:00
ce091d3918 Update readme with Steam integration, landing page, and architecture changes
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 10s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 12:40:37 +00:00
461729809e Fixing template by removing margins and spacing nicely
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 16s
2026-03-27 15:16:43 +00:00
89119c1702 Fix halftone acroll whole app
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 24s
2026-03-27 14:30:48 +00:00
0f9695b8aa Increase size of breakpoint on footer
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 14s
2026-03-27 14:26:16 +00:00
7007f8292d Add hide-sm utility class to Footer for responsive module hiding
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 15s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:25:13 +00:00
f7d69f048e Polish CV components: add print button, fade transition, and list styling
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 19s
- Add Print button and fade transition between CV variants in CV.vue
- Fix bullet point styling with list-disc list-inside across all CV variants
- Minor content tweaks: reorder modules, fix date range, reformat text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:21:42 +00:00
31d4b4c268 Refactor CV into separate role-specific components and misc frontend tweaks
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m6s
Split CV.vue into CVGeneral, CVBackend, CVFrontend, and CVHospitality variants.
Also adds halftone body class, reformats index.html, and minor style/layout fixes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 14:03:00 +00:00
7f01b1a296 Finishing touches
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 15s
2026-03-26 11:49:16 +00:00
89d3d8eefb Fix mid screen breakpoint because spotify img would glitch
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 17s
2026-03-26 11:43:22 +00:00
31a8c93c86 Fix home page alignment and add border to main grid container
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 19s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 11:32:43 +00:00
932e257152 Add HTTPS support in dev mode and fix mobile layout issues
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m19s
Generate self-signed certs for local HTTPS, add port 443 and full SSL
server block to dev nginx config, add Spotify redirect URI env var,
improve Spotify token error handling, and fix Chat/Steam mobile sizing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 11:18:32 +00:00
619692687f Hide sidebar images on screens narrower than 1200px
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 23s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 11:17:14 +00:00
f1750a8b3e Remove a5 class from Admin cause it was too small 2026-03-26 10:21:53 +00:00
3c9d19d185 Improve PageSpeed accessibility, SEO, and performance scores
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 34s
Add alt attributes, width/height for CLS, aria-labels, meta description,
preconnect hints, LCP fetchpriority, and sound/stamp cache headers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 02:38:08 +00:00
474f14b1e5 Add performance optimizations: gzip, cache headers, WOFF2 fonts, lazy loading
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 38s
Enable gzip compression in nginx, add cache-control headers for static assets,
convert fonts to WOFF2 with font-display swap, preload fonts, add lazy loading
to below-fold images, and remove unused font files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 02:29:37 +00:00
7798b54391 Fix Steam game transition jumping to top of container
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m28s
Wrap Transition in a relative-positioned game-container div so the
leaving element's absolute positioning is scoped to the game area,
not the full wrapper. Use flex layout so the container fills only
the space below the header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 02:10:21 +00:00
d4a6343d5e Add fade transition and game cycling to Steam component
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m43s
Matches the Listening component pattern with auto-rotating games every
5 seconds, click to advance, and crossfade transitions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 02:00:52 +00:00
264df132df Add Steam integration showing online status and recent games
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
Fetches player summary and recently played games from Steam API with
5-minute server-side caching. Displays in the home sidebar with online
indicator and game artwork.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 01:59:34 +00:00
747563c6c9 Slow down animation on stamps cause it was crazy
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 15s
2026-03-25 21:54:05 +00:00
fabd92bf36 Remove class for main
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 15s
2026-03-25 21:52:41 +00:00
ac5f47fcaa Add Waybar-style footer with sticky navbar/footer layout
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 26s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 21:45:46 +00:00
a8ef10498e Add slide transition for route navigation
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 15s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 21:35:41 +00:00
0f801a864c Remove Probability & stats because its very low on the page looks gross
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 17s
2026-03-25 21:31:22 +00:00
f9a8127714 Fix white background on curved mobile screen edges
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 19s
Set body background-color to --bg_secondary so areas outside
the halftone main element match instead of showing white.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 21:29:29 +00:00
8f57c15c24 Improve stamps bounce animation with explicit position tracking
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 14s
Use dedicated posX/posY variables instead of reading scroll position
directly, preventing drift at boundaries. Add more demo stamps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 21:26:48 +00:00
b2042ffe78 Add bouncing auto-scroll animation to stamps section
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 16s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 21:24:40 +00:00
4e7377d9f0 Improve AutoScroll reliability with mouseenter/mouseleave
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 15s
Replace mouseover (which fires repeatedly on child elements) with
mouseenter/mouseleave so hover cleanly stops scrolling. On mouse leave,
sync scroll position from scrollTop so manual scrolling is respected.
Fix inverted top/bottom boundary checks.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 21:22:58 +00:00
8406582b2b Make miku fixed height so it doesn't messup Chat scrolling
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 24s
2026-03-25 17:17:25 +00:00
283e02657e Update screenshot url
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6s
2026-03-25 17:13:59 +00:00
7a737f6d10 Handle missing Spotify auth gracefully instead of returning errors
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m42s
Return nil/empty results when Spotify client is not authenticated,
preventing GraphQL errors from breaking the home page data query.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 17:02:11 +00:00
29350af2e0 Fix WebSocket 403 in dev mode by allowing localhost origins
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
The CheckOrigin function only accepted the production domain, rejecting
localhost connections in dev. Also removed redundant error response after
a failed upgrade since the upgrader already writes its own HTTP response.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 16:59:13 +00:00
d3d3269d49 Extract Vue frontend into separate container and add stp_wasm crate
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m58s
Move Vue app from nginx/vue/ to top-level vue/ with its own Dockerfile,
update docker-compose configs and nginx proxy to serve from the new
container, and add initial Rust WASM crate (stp_wasm). Also fix .gitignore
to exclude Rust target/ directories.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 16:40:45 +00:00
2b84730126 Fix Slideshow layout shift affecting Chat during image transitions
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 55s
Use CSS grid stacking instead of absolute positioning so both
entering and leaving images occupy the same grid cell, keeping the
container height stable during crossfade transitions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 03:16:28 +00:00
8c2e9ba9a5 Remove horizontal scroll from commit history (sry bad claude naughty)
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m6s
2026-03-25 03:13:56 +00:00
6a6b9536ba Remove horizontal scroll from CommitHistory component
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 59s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 03:10:56 +00:00
d3e948d558 Add lazy loading for images and videos in Chat
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 03:10:18 +00:00
bbb493b544 Improve Chat scroll-to-bottom reliability with ResizeObserver
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m1s
Replace ad-hoc nextTick and media load handlers with a ResizeObserver
on an inner content wrapper, which fires after layout for all content
changes (new messages, image/video loads, window resize). Add scroll
position tracking so auto-scroll only triggers when user is near
bottom, and conditionally show the Bottom button only when scrolled up.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 03:06:27 +00:00
3afcee2011 Limit stamps component height on small screens
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 56s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 02:57:34 +00:00
7d74a2fc07 Fix small-screen layout issues for Home tables and sidebar images
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m1s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 02:54:49 +00:00
570a823426 Improve responsive layout for Home sidebar and utility components
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m50s
Switch sidebar to CSS grid, constrain images on mobile, add max-height to Chat, and improve Radio/Time/Timer compact styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 02:43:37 +00:00
6dddcd4d7a Replace raw anchor tags with Link component across views
Use Link component in Chat, CommitHistory, Stamps, Demoman, and fix Navbar to use span instead of nested anchors. Also updates Navbar inHome check for /stp route.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 02:43:32 +00:00
69e158b871 Add Landing page and move Home to /stp route
New professional landing page at / with bio, about section, and nav links. Previous home page now lives at /stp.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 02:43:27 +00:00
d857cce5dc Consolidate OptionalLinkTable and ToggleLinkTable into LinkTable
LinkTable now supports variant (list/table) and optional title toggle, replacing the need for separate components. Updates all consumers to use the unified API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 02:43:24 +00:00
c2bbd7ad88 Add Link and InlineLink reusable components
Link wraps RouterLink or <a> with consistent styling and automatic rel attributes. InlineLink adds bold italic inline link styling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 02:43:12 +00:00
8627a7945e Fix sidebars and make them not expand
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m7s
2026-03-19 01:16:10 +00:00
08125204c5 Format CommitHistory and fix overflow on chat
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m11s
2026-03-17 01:02:20 +00:00
a0215f7810 Make images and video smaller in chat
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 55s
2026-03-17 00:53:37 +00:00
c1ce3c31ba Update readme with GraphQL API details and tech stack specifics
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:39:25 +00:00
2becda2bd8 Add CLAUDE.md and update frontend README
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 39s
Add Claude Code guidance file with build commands, architecture overview,
and key patterns. Replace default Vite scaffold README with project-specific
documentation including dev proxy config and deployment notes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:37:19 +00:00
7381cda7b8 Move Gitea feed from frontend to backend with cached GraphQL proxy
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m39s
Replaces direct browser-to-Gitea API calls with a backend service that
proxies and caches the feed (1-min TTL), served via the existing GraphQL
HomeData query. Commit message parsing now happens server-side.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 00:14:59 +00:00
5999eccc21 Merge branch 'main' of ssh://adam-french.co.uk:2222/adamf/web_server
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 59s
2026-03-16 16:47:22 +00:00
7155255733 Add rowing to store 2026-03-16 16:44:02 +00:00
6ff30a37f7 Add rowing to store 2026-03-16 16:41:30 +00:00
a4514ad98d Upgrade go version
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 11s
2026-03-16 16:22:11 +00:00
84e18dddfa Update go version to 1.25 2026-03-16 15:45:34 +00:00
b4ddb4d402 Stop tracking app.ini to prevent runtime secrets from blocking git pull
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 1s
Gitea populates secret fields (LFS_JWT_SECRET, SECRET_KEY, etc.) at
startup, causing app.ini to always show as modified. Since secrets are
already passed via environment variables, the tracked file is replaced
with an ignored app.ini and a tracked app.ini.template for reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:35:43 +00:00
0360b1f7f1 Consolidate frontend REST calls with GraphQL
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 1s
Replace 5 separate REST calls on home page load with a single GraphQL
query. Add homeData store that fetches posts, favorites, activities,
spotify, and auth in one request. Convert all admin mutations and
auth flows to use GraphQL. Add album images to Spotify GraphQL schema.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 15:29:22 +00:00
36817277f9 Revert "Don't use internal tokens"
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 0s
This reverts commit a03ce26824.
2026-03-16 09:54:25 +00:00
a03ce26824 Don't use internal tokens
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 1s
2026-03-16 09:39:48 +00:00
a10706506e Make navbar sticky
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m5s
2026-03-13 17:58:52 +00:00
f29e937307 Fix layout so content divs fill remaining space and scroll on overflow
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m47s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:52:27 +00:00
81cb2bc4b5 Move Gitea secrets to environment variables
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 41s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 17:46:18 +00:00
8b5ed9abec Don't make header scroll
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m34s
2026-03-13 17:37:03 +00:00
8cdab593ae Okay this is the fix, please don't judge me for how long this took for me too look at, I think I really should just set a fixed height for the parent container.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m43s
2026-03-13 16:39:12 +00:00
b63cc911a7 Should defo be the fix please god
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m24s
2026-03-13 16:35:02 +00:00
e1fe281586 Should defo be the fix please god
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m42s
2026-03-13 16:31:47 +00:00
887d23af5b Make only messages scroll
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m54s
2026-03-13 16:25:34 +00:00
36aa7ed907 Fix overextending flexbox & abandon ship on flex-1 approach, and probably should just use fixed height for parent container
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m22s
2026-03-13 15:47:43 +00:00
d5065d19e0 Make sidebars take up full space
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m2s
2026-03-13 15:30:34 +00:00
15c721ea56 Make commit scroll
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m47s
2026-03-13 15:12:59 +00:00
b47d1a3df3 Fix checkStream function and useTemplateRef instead of implicit
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m35s
2026-03-10 22:40:15 +00:00
5b3cd267b6 Clear messages on reconnect
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m48s
2026-03-10 22:28:13 +00:00
6033a952af Automatically reconnect to websocket
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m27s
2026-03-10 22:12:42 +00:00
0ad7f4e009 Radio is too damn loud, change frontend instead of server
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m27s
2026-03-10 21:41:45 +00:00
6bf773487a Radio is too damn loud
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 19s
2026-03-10 21:29:44 +00:00
2916afe206 Add fallback music directory to repo
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 6m30s
2026-03-10 14:02:23 +00:00
17deec23ba Remove music from git
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-10 13:59:07 +00:00
ad4d02228d Add prune to deploy
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-10 13:56:57 +00:00
d5fbc0ee74 Hide overflow
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m6s
2026-03-10 13:44:02 +00:00
857f66cb37 Make radio check the stream endpoint
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m22s
2026-03-10 13:40:51 +00:00
5b041d7364 Add fallible
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 19s
2026-03-10 13:33:54 +00:00
4be7e60394 syntax for setting of liquidsoap changed
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 7s
2026-03-10 13:26:19 +00:00
27f74f6c2a /etc/ permissions for radio user
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 34s
2026-03-10 13:23:59 +00:00
5a19f09e17 Add fallback music to repository
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m54s
2026-03-10 12:59:27 +00:00
469a225860 Add fallback music to icecast server
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-10 12:58:53 +00:00
cd1bcc7f39 Make chatbox scroll to bottom once all images have loaded
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m44s
2026-03-10 12:43:38 +00:00
14cacec1f5 Correct styles on admin panels and enter triggers submission
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m34s
2026-03-10 12:41:45 +00:00
7991c80176 Allow admin to create user
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m22s
2026-03-10 12:32:59 +00:00
bad44a6ddd Separate admin protected endpoints from non-admin endpoints 2026-03-10 12:32:47 +00:00
0b256863d6 Merge branch 'main' of ssh://adam-french.co.uk:2222/adamf/web_server
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m57s
2026-03-10 12:20:00 +00:00
cb326ff8bf Add promote / demote user to admin and reintroduce create user dashboard 2026-03-10 12:18:24 +00:00
78d6c3d4f0 Add promote / demote user to admin and reintroduce create user dashboard
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 24s
2026-03-10 12:17:07 +00:00
c7dbf5b778 Include make to dependencies
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m17s
2026-03-10 12:13:42 +00:00
a8d1b879be Changes to docker configuration to decrease build time
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 1m35s
2026-03-10 12:07:22 +00:00
f82389225c Changes to docker configuration to decrease build time 2026-03-10 12:07:13 +00:00
165852e738 Remove extra attach and rename bottom button
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m18s
2026-03-10 12:02:14 +00:00
c58c19cc1e Make links clickable
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m49s
2026-03-10 11:58:06 +00:00
26ea0108e0 Add scroll to bottom on chat
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-10 11:56:52 +00:00
604576b46a Only show attach button if user is admin
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m4s
2026-03-09 18:11:03 +00:00
33d72fd20a Fix side scrolling for iphones
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m1s
2026-03-09 18:03:19 +00:00
d3cbc687d5 Fix sidebar on first breakpoint
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m55s
2026-03-09 18:00:51 +00:00
d7b76e4742 open port 3000 for gitea runner
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2m36s
2026-03-09 17:55:10 +00:00
64c2ba5562 Fix page height on first breakpoint
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 17:50:49 +00:00
6796367dbe Fix page height on first breakpoint
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 17:40:00 +00:00
c2580c984d Allow chat to get videos
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 17:30:54 +00:00
68db930049 Don't use SaveUploadedFile (causing permission issues)
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 17:21:26 +00:00
63da086da2 Removed setting own permissions, let dockerfile entryhost do it
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 17:10:24 +00:00
6326a438dc Halftone + mask reduces performance alot, change background
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 16:56:20 +00:00
7c980f1b1f Fix file permissions, still
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 16:53:45 +00:00
141ceab7e6 Reduce performance lost on large screens
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 16:41:55 +00:00
d03f9668ad Add error handling 2026-03-09 16:41:38 +00:00
41d6cf0dac omg fix undefined variable
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 16:29:56 +00:00
1e3c6adf5e Fix file permissions on image upload
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 16:23:44 +00:00
99ddd7d494 Fix file permissions
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 16:20:47 +00:00
8e50537333 Get AI to fix vunerabilities in site
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 14:12:29 +00:00
85a2325683 change file permissions to /uploads
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m0s
2026-03-09 13:59:59 +00:00
0a8a752433 Add file upload to website and integrate into chat
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m12s
2026-03-09 13:47:45 +00:00
4c396ef30f Add file upload to website and integrate into chat
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-09 13:47:38 +00:00
77e2c272cb Update mobile layout adjustments
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m53s
2026-03-09 13:06:17 +00:00
1578a05762 Remove extra whitespace on CV.vue and stopped height becoming 0 on image transitions
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m28s
2026-03-09 12:07:05 +00:00
a6bc1d5126 Add transcript to site
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m30s
2026-03-09 11:58:44 +00:00
2737b4f0d0 Avoid panic on spotify if not authed
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m35s
2026-03-07 17:46:55 +00:00
9fa953c969 Add local dev mode with HTTP-only nginx and DB seeding)
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 5m11s
2026-03-07 17:36:54 +00:00
5a45f1f427 Revert "Update notes component to reflect obsidian notes"
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 28s
This reverts commit 4458844029.
2026-03-07 17:07:21 +00:00
4458844029 Update notes component to reflect obsidian notes
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m54s
2026-03-07 17:04:23 +00:00
3200ef5bee make text size even larger
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m42s
2026-03-07 16:57:46 +00:00
0da6d3f0ed check duplicates before making claude request
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m31s
2026-03-07 16:51:11 +00:00
88ce32abeb make popup text size larger
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m45s
2026-03-07 16:46:03 +00:00
adcf1bda48 Check that paces are reasonable
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-07 16:43:08 +00:00
7450b5a624 Add gym chart to show rowing results
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m45s
2026-03-07 16:31:15 +00:00
ab2b0a1e3d Add padding to chat
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m47s
2026-03-06 16:09:14 +00:00
ff82b8bdf9 Change author ID colour
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m48s
2026-03-06 16:02:37 +00:00
1429a6a5cb Change author ID colour
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-03-06 16:01:44 +00:00
7a71484ecc Show author id in chat log
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m57s
2026-03-06 15:56:12 +00:00
e1563b55f4 remove todo make persistent chatlog from readme
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 8s
2026-03-06 13:29:59 +00:00
4fbeabc3ae Make chat longer
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m43s
2026-03-05 21:57:54 +00:00
a83b98eb2b Make chat persistent across reboot
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m25s
2026-03-05 21:43:04 +00:00
5346b24999 Make chat component autoscroll
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m30s
2026-03-05 21:31:00 +00:00
3779a1cbcc Add important todo to site
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4s
2026-03-05 20:38:24 +00:00
3f39f6327c Lookmaxing
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m54s
2026-03-05 20:32:14 +00:00
9dc9a3a063 Pose max message limit on chat function so no crash ^_^
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m30s
2026-03-05 20:07:08 +00:00
a6b543cf65 Make chat component look nicer
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m36s
2026-03-05 19:58:07 +00:00
4a65836210 Make chat component look nicer and upgrade websocket connection
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m47s
2026-03-05 19:51:33 +00:00
95635c86b3 Fix up live chat
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m26s
2026-03-05 19:14:05 +00:00
3056b23b50 nicer name
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3s
2026-03-05 19:04:50 +00:00
72013f5cdd update readme
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3s
2026-03-05 19:03:40 +00:00
7aa62659e5 remove silly background
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m35s
2026-03-05 17:35:25 +00:00
aa3f0a189d refactor slideshow code to its component and added Miku & miku background
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m42s
2026-03-05 13:31:28 +00:00
646f93136d update rowing information to non fricken nanoseconds who though time.Durations should be nanoseconds
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m45s
2026-03-04 16:48:21 +00:00
54852eba82 Update CV
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m47s
2026-03-04 16:21:30 +00:00
e43c07b30a more verbose error response
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m17s
2026-03-04 16:10:52 +00:00
190bc6076b remove json boilerplate, log error and return response
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m45s
2026-03-04 15:58:14 +00:00
88884121ab allow multiple files to be uploaded to rowing endpoint
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m51s
2026-03-04 15:26:17 +00:00
e25fc5f1d1 implement create rowing component
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m39s
2026-03-04 14:45:50 +00:00
5bcc65668e move admin component files and add rowing component 2026-03-04 14:40:56 +00:00
2c1ecce99a Merge remote-tracking branch 'gitea/rowing'
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m48s
2026-03-04 14:32:34 +00:00
f2ba3494b1 remove no slip
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m35s
2026-03-03 10:32:38 +00:00
d56bd5783d eliminate the wretched hiding console.log, filthy log......
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m20s
2026-02-24 22:48:13 +00:00
f60636942f eliminate the wretched hiding console.log, filthy log......
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m31s
2026-02-24 22:40:55 +00:00
b087172bb1 slowed down the whole animation industry
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m30s
2026-02-24 22:31:54 +00:00
0c93c6bc27 make colors match
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m41s
2026-02-23 16:16:09 +00:00
48ae2f59ea adding important context
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m37s
2026-02-23 16:06:05 +00:00
c9faa90abd adding important context
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m43s
2026-02-23 16:03:52 +00:00
ef78974744 stamps width fix, also screw mobile even more
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m39s
2026-02-23 15:56:52 +00:00
49499052b0 add link to feed
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m49s
2026-02-23 15:51:56 +00:00
dbb4914745 fix mobile, nobody likes mobile......... hate mobile
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m28s
2026-02-23 15:49:04 +00:00
34fa96ddab adding git feed
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m24s
2026-02-23 15:45:46 +00:00
8a9f3c373d Update links
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m27s
2026-02-23 14:23:43 +00:00
dc05ade798 update readme
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m29s
2026-02-23 14:15:32 +00:00
1e47919a40 update readme
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3s
2026-02-23 13:23:50 +00:00
8e9734fca7 update cv
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m27s
2026-02-23 13:14:41 +00:00
da9a083f2d eddy fix
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m34s
2026-02-21 20:06:59 +00:00
3c40eb9f08 I should seriously look at what I'm commiting before I commit it.... >_< dw I won't do this in professional context
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m26s
2026-02-20 16:28:16 +00:00
e016e3af46 fix infinite loop :( shouldn't have existed, silly programmer
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 50s
2026-02-20 16:26:22 +00:00
0c91f512b4 adding more detail to cv
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m23s
2026-02-20 16:24:15 +00:00
f63b61431b maybe bedroom is not professional
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m24s
2026-02-20 16:22:39 +00:00
f3ea83c477 changed cv
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m28s
2026-02-20 16:13:01 +00:00
4b5ed4787a center text and fullwidth button
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m30s
2026-02-19 21:41:49 +00:00
747a403bcb idk what this changes
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3s
2026-02-18 21:45:17 +00:00
fe16ccab97 add ignore to webpack future content
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 10s
2026-02-18 21:43:51 +00:00
7bcb485fc6 move radio and add to homepage
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-02-18 21:42:37 +00:00
a3d73b12f4 Adding gitea link
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m28s
2026-02-18 21:29:30 +00:00
47a8e6c35e Adding gitea link
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m22s
2026-02-18 21:27:43 +00:00
f885ff9175 Adding gitea link
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m25s
2026-02-18 21:22:41 +00:00
d574fa7692 change intro
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m20s
2026-02-18 21:13:32 +00:00
ac171f7846 yes
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 1m24s
Merge branch 'main' of ssh://adam-french.co.uk:2222/adamf/web_server
2026-02-18 21:09:20 +00:00
b5b86a2a37 revert to old website intro 2026-02-18 21:08:33 +00:00
cfdb5b4d50 Update .gitea/workflows/deploy.yaml
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3s
2026-02-18 16:38:06 +00:00
37580cdc42 Stop da haxxers
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3s
2026-02-18 16:26:52 +00:00
711236b776 fix actions
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m35s
2026-02-18 16:21:52 +00:00
75454c2ed8 fix actions
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 5s
2026-02-18 16:19:58 +00:00
78c824c4c8 remove mountpoint
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2m56s
2026-02-17 18:37:46 +00:00
ba3b933068 remove mountpoint
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 5s
2026-02-17 18:36:23 +00:00
14c430bbad adding gitea actions
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2s
2026-02-17 18:34:55 +00:00
26c7422e34 adding gitea-runner service 2026-02-17 18:10:19 +00:00
470b1c79d8 fix ssh domain for gitea 2026-02-16 16:57:20 +00:00
d849b606ec fix certbot env vars 2026-02-16 15:07:44 +00:00
46a9da4c90 gitea runner 2026-02-16 14:52:33 +00:00
398a610cb2 fix root of gitea 2026-02-16 13:58:09 +00:00
b506bae515 fix gitea 2026-02-16 13:57:28 +00:00
11ad0b5a83 reverse proxy to gitea 2026-02-16 13:21:11 +00:00
d7393e1419 Revert "Revert "idk what I changed""
This reverts commit 0d32333c0c.
2026-02-16 13:03:13 +00:00
0d32333c0c Revert "idk what I changed"
This reverts commit 7dc3f49273.
2026-02-16 13:02:31 +00:00
050a38a76f Merge branch 'main' of /home/adamf/repos/web_server 2026-02-16 12:56:23 +00:00
bc43e9ed02 add gitea 2026-02-16 12:52:08 +00:00
75b8b02825 ignore gitea volume 2026-02-16 12:51:48 +00:00
5c69a1d0a7 adding gitea 2026-02-16 11:46:49 +00:00
aa915e1071 Merge branch 'main' of 192.168.178.24:~/repos/web_server
ye
2026-02-14 22:03:47 +00:00
7dc3f49273 idk what I changed 2026-02-14 22:03:32 +00:00
21d3997a16 remove ts 2026-02-10 17:04:52 +00:00
c56ba217dd remove tsconfig 2026-02-10 17:01:26 +00:00
91804f1fe7 adding vueuse 2026-02-10 17:00:59 +00:00
7e74ce5a2a adding typescript 2026-02-10 16:57:00 +00:00
e92ac49140 new components 2026-02-10 16:46:49 +00:00
352 changed files with 27142 additions and 4269 deletions

View File

@@ -0,0 +1,27 @@
name: Deploy with Docker Compose
on:
push:
branches: [main]
jobs:
deploy:
runs-on: self-hosted
steps:
- name: Pull changes
working-directory: /home/adamf/deploy/web_server
env:
GIT_SSH_COMMAND: "ssh -o StrictHostKeyChecking=no"
run: |
git config --global --add safe.directory /home/adamf/deploy/web_server
git fetch ssh://git@localhost:2222/adamf/web_server.git main
git reset --hard FETCH_HEAD
- name: Run docker compose up
working-directory: /home/adamf/deploy/web_server
env:
DOCKER_API_VERSION: "1.41"
run: docker compose up -d --build --remove-orphans
- name: Prune unused Docker resources
run: docker image prune -f

19
.gitignore vendored
View File

@@ -1,8 +1,25 @@
icecast2/fallback_music/*
!icecast2/fallback_music/.gitkeep
searxng/settings.yml
certbot/conf
certbot/www
backend/token/
.env
gitea/data/*
gitea/config/app.ini
# Gitea runner
gitea-runner/.runner
gitea-runner/act_runner
gitea-runner/nohup.out
# Rust build artifacts
**/target/
# Generated WASM output
vue/src/wasm/
# Logs
logs
*.log
@@ -41,5 +58,7 @@ coverage
# Vitest
__screenshots__/
.deploy
*.xcf
sync-secrets.sh

70
CLAUDE.md Normal file
View File

@@ -0,0 +1,70 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Build & Run Commands
### Full stack (dev mode, HTTP only)
```
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
```
Dev mode seeds the database with test data (`SEED_DB=true`) and disables certbot/SSL. Visit `http://localhost`.
### Full stack (production, HTTPS)
```
docker compose up --build
```
### Frontend only (hot reload)
```
cd vue && npm run dev
```
Vite dev server proxies `/api` to `localhost:8080`, `/gitea` to `localhost:3000`, `/radio` to `localhost:8000`.
### Frontend build
```
cd vue && npm run build
```
### Regenerate GraphQL (after editing schema files)
```
cd backend && go run github.com/99designs/gqlgen generate
```
This regenerates `graph/generated.go` and `graph/model/models_gen.go`. Resolver implementations in `*.resolvers.go` files are preserved.
## Architecture
Dockerized multi-service personal website self-hosted on a Raspberry Pi.
**Backend** (`backend/`): Go with Gin router. GraphQL API via gqlgen at `POST /api/graphql`. REST endpoints for auth, file uploads, Spotify OAuth, and WebSockets. GORM for PostgreSQL with auto-migrations (no separate migration files). JWT auth stored in HTTP-only cookies.
**Frontend** (`vue/`): Vue 3 SPA with Vite, Tailwind CSS v4, Pinia stores, Vue Router. Built in a separate container; assets served through Nginx (production) or proxied to Vite dev server (dev mode).
**Nginx** (`nginx/`): Reverse proxy + SPA server. Config is templated (`nginx.conf.template`) and selected at runtime by `entrypoint.sh` based on `DEV_MODE` and certificate presence. Rate limiting on login (5/min), API (30/sec), uploads (5/min).
## Backend Structure
- `main.go` — entry point: wires up DB, services, router
- `handlers/store.go``Store` struct holds DB, SpotifyAuth, ClaudeClient, Auth, etc. Passed to all handlers
- `handlers/handle_*.go` — REST handlers grouped by domain
- `graph/schema/*.graphql` — GraphQL schema files (source of truth)
- `graph/*.resolvers.go` — resolver implementations (one per schema file, `follow-schema` layout)
- `graph/generated.go` — auto-generated by gqlgen, do not edit
- `graph/model/models_gen.go` — auto-generated GraphQL models, do not edit
- `models/models.go` — GORM database models (User, Post, Message, Activity, Favorite, Rowing)
- `services/` — database init, JWT auth, WebSocket server, Spotify OAuth, Gitea feed, Claude client, DB seeding
## Frontend Structure
- `src/graphql.js` — thin axios-based GraphQL client (`POST /api/graphql`)
- `src/stores/` — Pinia stores for auth, posts, favorites, activities, songs, messages, homeData
- `src/views/` — page components (Home, Admin, CV, Notes, Bookmarks, shrines)
- `src/components/` — reusable UI components
## Key Patterns
- **GraphQL models vs GORM models**: `gqlgen.yml` maps GraphQL types directly to GORM models in `backend/models`. The `graph/model/` package has only generated input/payload types.
- **Auth flow**: Login sets `access_token` (24h) and `refresh_token` (365h) as HTTP-only cookies. `AuthMiddleware` validates tokens and injects user into Gin context. `AuthContextMiddleware` passes Gin context into GraphQL resolver context.
- **Spotify tokens**: Persisted to `/backend/token/spotify_token.json` inside the container, surviving restarts.
- **Gitea feed**: Backend proxies and caches (1 min TTL) the Gitea activity feed API.
- **All GORM models use soft delete** (`gorm.DeletedAt` field).

359
README.md Normal file
View File

@@ -0,0 +1,359 @@
# My Web
![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 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`
Create a `.env` file in the project root. All services read from this file.
| 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 Config
Copy from the template and fill in secrets:
```sh
cp gitea/config/app.ini.template gitea/config/app.ini
```
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 Config
Copy from the template:
```sh
cp searxng/settings.yml.template searxng/settings.yml
```
The Docker entrypoint handles environment variable substitution (`${BASE_URL}`, `${SEARXNG_SECRET_KEY}`) automatically, so manual setup is only needed when running outside Docker.
### Spotify Token Setup
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
### Obsidian Notes Setup
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)
### SSL Certificates (Certbot)
**Initial setup (production):**
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
**Dev mode:** Nginx generates a self-signed certificate for localhost automatically.
### Icecast Radio
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`.
### 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

@@ -1,4 +1,4 @@
FROM golang:1.24
FROM golang:1.25
WORKDIR /backend

View File

@@ -1,31 +1,38 @@
module adam-french.co.uk/backend
go 1.24.0
go 1.25
require (
github.com/99designs/gqlgen v0.17.88
github.com/anthropics/anthropic-sdk-go v1.26.0
github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/gorilla/websocket v1.5.3
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
github.com/vektah/gqlparser/v2 v2.5.32
github.com/zmb3/spotify/v2 v2.4.3
golang.org/x/crypto v0.43.0
golang.org/x/crypto v0.48.0
golang.org/x/oauth2 v0.30.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.3.0 // 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/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.27.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
@@ -41,7 +48,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.54.0 // indirect
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
github.com/sosodev/duration v1.4.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
@@ -50,12 +57,11 @@ require (
github.com/ugorji/go/codec v1.3.0 // indirect
go.uber.org/mock v0.5.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.36.10 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

View File

@@ -31,10 +31,22 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc=
github.com/99designs/gqlgen v0.17.88/go.mod h1:qeqYFEgOeSKqWedOjogPizimp2iu4E23bdPvl4jTYic=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
@@ -50,6 +62,19 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
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/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.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -71,10 +96,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
@@ -102,10 +129,7 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@@ -129,12 +153,16 @@ github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
@@ -178,6 +206,10 @@ github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQ
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -203,6 +235,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -226,8 +260,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -260,8 +294,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -294,14 +328,13 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5 h1:Ati8dO7+U7mxpkPSxBZQEvzHVUYB/MqCklCN8ig5w/o=
golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
@@ -315,8 +348,8 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -351,8 +384,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
@@ -364,12 +397,13 @@ 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.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.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.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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -415,8 +449,8 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -443,7 +477,6 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@@ -499,12 +532,14 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

77
backend/gqlgen.yml Normal file
View File

@@ -0,0 +1,77 @@
schema:
- graph/schema/*.graphql
exec:
filename: graph/generated.go
package: graph
model:
filename: graph/model/models_gen.go
package: model
resolver:
layout: follow-schema
dir: graph
package: graph
filename_template: "{name}.resolvers.go"
models:
ID:
model:
- github.com/99designs/gqlgen/graphql.IntID
User:
model:
- adam-french.co.uk/backend/models.User
fields:
password:
resolver: false
deletedAt:
resolver: false
Post:
model:
- adam-french.co.uk/backend/models.Post
fields:
deletedAt:
resolver: false
Activity:
model:
- adam-french.co.uk/backend/models.Activity
fields:
deletedAt:
resolver: false
Favorite:
model:
- adam-french.co.uk/backend/models.Favorite
fields:
deletedAt:
resolver: false
Rowing:
model:
- adam-french.co.uk/backend/models.Rowing
fields:
deletedAt:
resolver: false
Message:
model:
- adam-french.co.uk/backend/models.Message
fields:
deletedAt:
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

@@ -0,0 +1,51 @@
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 *activityResolver) ID(ctx context.Context, obj *models.Activity) (int, error) {
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.
func (r *Resolver) Activity() ActivityResolver { return &activityResolver{r} }
type activityResolver struct{ *Resolver }

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 }

52
backend/graph/context.go Normal file
View File

@@ -0,0 +1,52 @@
package graph
import (
"context"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
)
type contextKey string
const (
userClaimsKey contextKey = "userClaims"
ginContextKey contextKey = "ginContext"
)
func UserClaimsFromCtx(ctx context.Context) *jwt.MapClaims {
claims, ok := ctx.Value(userClaimsKey).(*jwt.MapClaims)
if !ok {
return nil
}
return claims
}
func UserIDFromCtx(ctx context.Context) (uint, bool) {
claims := UserClaimsFromCtx(ctx)
if claims == nil {
return 0, false
}
idF, ok := (*claims)["id"].(float64)
if !ok {
return 0, false
}
return uint(idF), true
}
func IsAdminFromCtx(ctx context.Context) bool {
claims := UserClaimsFromCtx(ctx)
if claims == nil {
return false
}
admin, ok := (*claims)["admin"].(bool)
return ok && admin
}
func GinContextFromCtx(ctx context.Context) *gin.Context {
gc, ok := ctx.Value(ginContextKey).(*gin.Context)
if !ok {
return nil
}
return gc
}

View File

@@ -0,0 +1,51 @@
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 *favoriteResolver) ID(ctx context.Context, obj *models.Favorite) (int, error) {
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.
func (r *Resolver) Favorite() FavoriteResolver { return &favoriteResolver{r} }
type favoriteResolver struct{ *Resolver }

11120
backend/graph/generated.go Normal file

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,17 @@
package graph
import (
"adam-french.co.uk/backend/graph/model"
"adam-french.co.uk/backend/services"
)
func mapGiteaFeed(feed *services.GiteaFeedResponse) *model.GiteaFeedItem {
return &model.GiteaFeedItem{
AvatarURL: feed.ActUser.AvatarURL,
RepoURL: feed.Repo.HTMLURL,
RepoName: feed.Repo.FullName,
OpType: feed.OpType,
CommitMessage: services.ParseCommitMessage(feed.Content),
CreatedAt: feed.Created,
}
}

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

@@ -0,0 +1,40 @@
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"
"adam-french.co.uk/backend/models"
)
// ID is the resolver for the id field.
func (r *messageResolver) ID(ctx context.Context, obj *models.Message) (int, error) {
return int(obj.ID), nil
}
// AuthorID is the resolver for the authorId field.
func (r *messageResolver) AuthorID(ctx context.Context, obj *models.Message) (int, error) {
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.
func (r *Resolver) Message() MessageResolver { return &messageResolver{r} }
type messageResolver struct{ *Resolver }

View File

@@ -0,0 +1,25 @@
package graph
import (
"context"
"adam-french.co.uk/backend/services"
"github.com/gin-gonic/gin"
)
func AuthContextMiddleware(auth *services.Auth) gin.HandlerFunc {
return func(c *gin.Context) {
ctx := context.WithValue(c.Request.Context(), ginContextKey, c)
accessToken, err := c.Cookie("access_token")
if err == nil {
claims, err := auth.VerifyJWT(accessToken)
if err == nil {
ctx = context.WithValue(ctx, userClaimsKey, claims)
}
}
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}

View File

@@ -0,0 +1,142 @@
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
package model
import (
"time"
"adam-french.co.uk/backend/models"
)
type AuthPayload struct {
User *models.User `json:"user"`
}
type CreateActivityInput struct {
Type string `json:"type"`
Name string `json:"name"`
Link *string `json:"link,omitempty"`
}
type CreateBookmarkInput struct {
Category string `json:"category"`
Name string `json:"name"`
Link string `json:"link"`
}
type CreateFavoriteInput struct {
Type string `json:"type"`
Name string `json:"name"`
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 {
Title string `json:"title"`
Content string `json:"content"`
}
type CreateUserInput struct {
Username string `json:"username"`
Password string `json:"password"`
}
type GiteaFeedItem struct {
AvatarURL string `json:"avatarUrl"`
RepoURL string `json:"repoUrl"`
RepoName string `json:"repoName"`
OpType string `json:"opType"`
CommitMessage string `json:"commitMessage"`
CreatedAt time.Time `json:"createdAt"`
}
type LoginInput struct {
Username string `json:"username"`
Password string `json:"password"`
}
type Mutation struct {
}
type Query struct {
}
type SpotifyAlbum struct {
Name string `json:"name"`
Images []*SpotifyImage `json:"images"`
}
type SpotifyArtist struct {
Name string `json:"name"`
}
type SpotifyImage struct {
URL string `json:"url"`
}
type SpotifyPlaying struct {
Playing bool `json:"playing"`
Track *SpotifyTrack `json:"track,omitempty"`
}
type SpotifyRecentItem struct {
Track *SpotifyTrack `json:"track"`
PlayedAt time.Time `json:"playedAt"`
}
type SpotifyTrack struct {
Name string `json:"name"`
Artists []*SpotifyArtist `json:"artists"`
Album *SpotifyAlbum `json:"album"`
}
type SteamGame struct {
AppID int `json:"appId"`
Name string `json:"name"`
Playtime2Weeks int `json:"playtime2Weeks"`
PlaytimeForever int `json:"playtimeForever"`
HeaderImageURL string `json:"headerImageUrl"`
}
type SteamStatus struct {
Online bool `json:"online"`
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 {
Title string `json:"title"`
Content string `json:"content"`
}

View File

@@ -0,0 +1,118 @@
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"
)
// 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.
func (r *postResolver) ID(ctx context.Context, obj *models.Post) (int, error) {
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.
func (r *Resolver) Post() PostResolver { return &postResolver{r} }
type postResolver struct{ *Resolver }

12
backend/graph/resolver.go Normal file
View File

@@ -0,0 +1,12 @@
package graph
import "adam-french.co.uk/backend/handlers"
// This file will not be regenerated automatically.
//
// It serves as dependency injection for your app, add any dependencies you require
// here.
type Resolver struct {
Store *handlers.Store
}

View File

@@ -0,0 +1,45 @@
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"
"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.
func (r *rowingResolver) ID(ctx context.Context, obj *models.Rowing) (int, error) {
return int(obj.ID), nil
}
// Time is the resolver for the time field.
func (r *rowingResolver) Time(ctx context.Context, obj *models.Rowing) (int, error) {
return int(obj.Time), nil
}
// Distance is the resolver for the distance field.
func (r *rowingResolver) Distance(ctx context.Context, obj *models.Rowing) (int, error) {
return int(obj.Distance), nil
}
// Rowing returns RowingResolver implementation.
func (r *Resolver) Rowing() RowingResolver { return &rowingResolver{r} }
type rowingResolver struct{ *Resolver }

View File

@@ -0,0 +1,15 @@
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
// Mutation returns MutationResolver implementation.
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
// Query returns QueryResolver implementation.
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
type mutationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }

View File

@@ -0,0 +1,22 @@
type Activity {
id: ID!
createdAt: Time!
updatedAt: Time!
type: String!
name: String!
link: String
}
input CreateActivityInput {
type: String!
name: String!
link: String
}
extend type Query {
activities: [Activity!]!
}
extend type Mutation {
createActivity(input: CreateActivityInput!): Activity!
}

View File

@@ -0,0 +1,18 @@
input LoginInput {
username: String!
password: String!
}
type AuthPayload {
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

@@ -0,0 +1,22 @@
type Favorite {
id: ID!
createdAt: Time!
updatedAt: Time!
type: String!
name: String!
link: String
}
input CreateFavoriteInput {
type: String!
name: String!
link: String
}
extend type Query {
favorites: [Favorite!]!
}
extend type Mutation {
createFavorite(input: CreateFavoriteInput!): Favorite!
}

View File

@@ -0,0 +1,12 @@
type GiteaFeedItem {
avatarUrl: String!
repoUrl: String!
repoName: String!
opType: String!
commitMessage: String!
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

@@ -0,0 +1,11 @@
type Message {
id: ID!
content: String!
authorId: Int!
fileUrl: String
createdAt: Time!
}
extend type Query {
messages: [Message!]!
}

View File

@@ -0,0 +1,29 @@
type Post {
id: ID!
createdAt: Time!
updatedAt: Time!
title: String!
author: User
content: String!
}
input CreatePostInput {
title: String!
content: String!
}
input UpdatePostInput {
title: 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

@@ -0,0 +1,13 @@
type Rowing {
id: ID!
createdAt: Time!
date: Time!
time: Int!
distance: Int!
timePer500m: Float!
calories: Float!
}
extend type Query {
rowingSessions: [Rowing!]!
}

View File

@@ -0,0 +1,4 @@
scalar Time
type Query
type Mutation

View File

@@ -0,0 +1,33 @@
type SpotifyArtist {
name: String!
}
type SpotifyImage {
url: String!
}
type SpotifyAlbum {
name: String!
images: [SpotifyImage!]!
}
type SpotifyTrack {
name: String!
artists: [SpotifyArtist!]!
album: SpotifyAlbum!
}
type SpotifyPlaying {
playing: Boolean!
track: SpotifyTrack
}
type SpotifyRecentItem {
track: SpotifyTrack!
playedAt: Time!
}
extend type Query {
spotifyListening: SpotifyPlaying
spotifyRecent: [SpotifyRecentItem!]
}

View File

@@ -0,0 +1,16 @@
type SteamGame {
appId: Int!
name: String!
playtime2Weeks: Int!
playtimeForever: Int!
headerImageUrl: String!
}
type SteamStatus {
online: Boolean!
recentGames: [SteamGame!]!
}
extend type Query {
steamStatus: SteamStatus
}

View File

@@ -0,0 +1,23 @@
type User {
id: ID!
createdAt: Time!
updatedAt: Time!
username: String!
admin: Boolean!
}
input CreateUserInput {
username: 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,51 @@
package graph
import (
"adam-french.co.uk/backend/graph/model"
"github.com/zmb3/spotify/v2"
)
func mapSpotifyImages(images []spotify.Image) []*model.SpotifyImage {
result := make([]*model.SpotifyImage, len(images))
for i, img := range images {
result[i] = &model.SpotifyImage{URL: img.URL}
}
return result
}
func mapSpotifyTrack(track *spotify.FullTrack) *model.SpotifyTrack {
artists := make([]*model.SpotifyArtist, len(track.Artists))
for i, a := range track.Artists {
artists[i] = &model.SpotifyArtist{Name: a.Name}
}
return &model.SpotifyTrack{
Name: track.Name,
Artists: artists,
Album: &model.SpotifyAlbum{
Name: track.Album.Name,
Images: mapSpotifyImages(track.Album.Images),
},
}
}
func mapRecentItems(items []spotify.RecentlyPlayedItem) []*model.SpotifyRecentItem {
result := make([]*model.SpotifyRecentItem, len(items))
for i, item := range items {
artists := make([]*model.SpotifyArtist, len(item.Track.Artists))
for j, a := range item.Track.Artists {
artists[j] = &model.SpotifyArtist{Name: a.Name}
}
result[i] = &model.SpotifyRecentItem{
PlayedAt: item.PlayedAt,
Track: &model.SpotifyTrack{
Name: item.Track.Name,
Artists: artists,
Album: &model.SpotifyAlbum{
Name: item.Track.Album.Name,
Images: mapSpotifyImages(item.Track.Album.Images),
},
},
}
}
return result
}

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

@@ -0,0 +1,22 @@
package graph
import (
"fmt"
"adam-french.co.uk/backend/graph/model"
"adam-french.co.uk/backend/services"
)
func mapSteamGames(games []services.SteamRecentGame) []*model.SteamGame {
result := make([]*model.SteamGame, len(games))
for i, g := range games {
result[i] = &model.SteamGame{
AppID: g.AppID,
Name: g.Name,
Playtime2Weeks: g.Playtime2Weeks,
PlaytimeForever: g.PlaytimeForever,
HeaderImageURL: fmt.Sprintf("https://cdn.akamai.steamstatic.com/steam/apps/%d/header.jpg", g.AppID),
}
}
return result
}

View File

@@ -0,0 +1,118 @@
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"
"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.
func (r *userResolver) ID(ctx context.Context, obj *models.User) (int, error) {
return int(obj.ID), nil
}
// User returns UserResolver implementation.
func (r *Resolver) User() UserResolver { return &userResolver{r} }
type userResolver struct{ *Resolver }

View File

@@ -1,40 +0,0 @@
package handlers
import (
"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 {
ctx.JSON(http.StatusInternalServerError, err.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, err.Error())
return
}
activity := models.Activity{Type: input.Type, Name: input.Name, Link: input.Link}
tx := store.DB.Create(&activity)
if tx.Error != nil {
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
return
}
ctx.JSON(http.StatusCreated, activity)
}

View File

@@ -1,24 +1,30 @@
package handlers
import (
"fmt"
"log"
"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"`
}
func (store *Store) AuthMiddlewear(ctx *gin.Context) {
access_token, err := ctx.Cookie("access_token")
if err != nil {
ctx.AbortWithStatusJSON(401, err.Error())
ctx.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
return
}
claims, err := store.Auth.VerifyJWT(access_token)
if err != nil {
ctx.AbortWithStatusJSON(401, err.Error())
ctx.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
return
}
@@ -27,6 +33,109 @@ func (store *Store) AuthMiddlewear(ctx *gin.Context) {
ctx.Next()
}
func (store *Store) AdminMiddleware(ctx *gin.Context) {
claims, exists := ctx.Get("userClaims")
if !exists {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
mapClaims, ok := claims.(*jwt.MapClaims)
if !ok {
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid claims"})
return
}
admin, ok := (*mapClaims)["admin"].(bool)
if !ok || !admin {
ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
return
}
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) {
access_token, err := ctx.Cookie("access_token")
if err != nil {
@@ -36,13 +145,13 @@ func (store *Store) CheckToken(ctx *gin.Context) {
claims, err := store.Auth.VerifyJWT(access_token)
if err != nil {
ctx.JSON(401, err.Error())
ctx.JSON(401, gin.H{"error": "unauthorized"})
return
}
userIDF, ok := (*claims)["id"].(float64)
if !ok {
ctx.JSON(401, gin.H{"error": "claims does not contain id"})
ctx.JSON(401, gin.H{"error": "unauthorized"})
return
}
userID := uint(userIDF)
@@ -50,8 +159,9 @@ func (store *Store) CheckToken(ctx *gin.Context) {
user := models.User{ID: userID}
tx := store.DB.First(&user)
if tx.Error != nil {
ctx.JSON(http.StatusNotFound, tx.Error.Error())
removeCookies(ctx)
log.Println(tx.Error)
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
store.removeCookies(ctx)
return
}
@@ -61,17 +171,16 @@ func (store *Store) CheckToken(ctx *gin.Context) {
func (store *Store) RefreshToken(ctx *gin.Context) {
refreshToken, err := ctx.Cookie("refresh_token")
if err != nil {
ctx.JSON(http.StatusUnauthorized, err.Error())
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
claims, err := store.Auth.VerifyJWT(refreshToken)
if err != nil {
ctx.JSON(http.StatusUnauthorized, err.Error())
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
fmt.Printf("claims: %v\n", claims)
userIDF, ok := (*claims)["id"].(float64)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid token claims"})
@@ -82,22 +191,25 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
user := models.User{ID: userID}
tx := store.DB.First(&user)
if tx.Error != nil {
ctx.JSON(http.StatusNotFound, tx.Error.Error())
removeCookies(ctx)
log.Println(tx.Error)
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
store.removeCookies(ctx)
return
}
tokens, err := store.Auth.GenerateJWT(&user)
if err != nil {
ctx.JSON(http.StatusInternalServerError, err.Error())
log.Println(err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(
"access_token",
tokens.AccessToken,
int(store.Auth.Config.AccessTokenLifetime.Seconds()),
store.Auth.Config.Endpoint,
"/",
store.Auth.Config.Domain,
true, true,
)
@@ -105,7 +217,7 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
"refresh_token",
tokens.RefreshToken,
int(store.Auth.Config.RefreshTokenLifetime.Seconds()),
store.Auth.Config.Endpoint,
"/",
store.Auth.Config.Domain,
true, true,
)
@@ -114,34 +226,41 @@ func (store *Store) RefreshToken(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
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, err.Error())
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
user := models.User{}
if err := store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
ctx.JSON(http.StatusNotFound, err.Error())
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil {
ctx.JSON(http.StatusUnauthorized, err.Error())
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
tokens, err := store.Auth.GenerateJWT(&user)
if err != nil {
ctx.JSON(http.StatusInternalServerError, err.Error())
log.Println(err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(
"access_token",
tokens.AccessToken,
int(store.Auth.Config.AccessTokenLifetime.Seconds()),
store.Auth.Config.Endpoint,
"/",
store.Auth.Config.Domain,
true, true,
)
@@ -149,7 +268,7 @@ func (store *Store) Login(ctx *gin.Context) {
"refresh_token",
tokens.RefreshToken,
int(store.Auth.Config.RefreshTokenLifetime.Seconds()),
store.Auth.Config.Endpoint,
"/",
store.Auth.Config.Domain,
true, true,
)
@@ -158,26 +277,27 @@ func (store *Store) Login(ctx *gin.Context) {
}
func (store *Store) Logout(ctx *gin.Context) {
removeCookies(ctx)
store.removeCookies(ctx)
ctx.Status(http.StatusOK)
}
func removeCookies(ctx *gin.Context) {
func (store *Store) removeCookies(ctx *gin.Context) {
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(
"access_token",
"",
-1,
"",
"",
"/",
store.Auth.Config.Domain,
true, true,
)
ctx.SetCookie(
"refresh_token",
"",
-1,
"",
"",
"/",
store.Auth.Config.Domain,
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,40 +0,0 @@
package handlers
import (
"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 {
ctx.JSON(http.StatusInternalServerError, err.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, err.Error())
return
}
favorite := models.Favorite{Type: input.Type, Name: input.Name, Link: input.Link}
tx := store.DB.Create(&favorite)
if tx.Error != nil {
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
return
}
ctx.JSON(http.StatusCreated, favorite)
}

View File

@@ -0,0 +1,97 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
var allowedExtensions = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
".mp4": true, ".webm": true, ".mp3": true, ".ogg": true,
".pdf": true, ".txt": true,
}
var extensionToMIMEPrefix = map[string]string{
".jpg": "image/", ".jpeg": "image/", ".png": "image/", ".gif": "image/", ".webp": "image/",
".mp4": "video/", ".webm": "video/",
".pdf": "application/pdf", ".txt": "text/",
}
func (store *Store) UploadMessageFile(ctx *gin.Context) {
file, err := ctx.FormFile("file")
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
return
}
const maxSize = 10 << 20 // 10MB
if file.Size > maxSize {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file too large"})
return
}
ext := strings.ToLower(filepath.Ext(file.Filename))
if !allowedExtensions[ext] {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file type not allowed"})
return
}
// Validate actual content type matches extension
f, err := file.Open()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
return
}
buf := make([]byte, 512)
n, err := f.Read(buf)
f.Close()
if err != nil && n == 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "failed to read file content"})
return
}
detectedType := http.DetectContentType(buf[:n])
expectedPrefix, ok := extensionToMIMEPrefix[ext]
if ok && !strings.HasPrefix(detectedType, expectedPrefix) {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file content does not match extension"})
return
}
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})
return
}
filename := hex.EncodeToString(b) + ext
uploadDir := "/backend/uploads/"
dest := filepath.Join(uploadDir, filename)
src, err := file.Open()
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
return
}
defer src.Close()
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
return
}
defer out.Close()
if _, err := io.Copy(out, src); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
return
}
ctx.JSON(http.StatusOK, gin.H{"url": "/uploads/" + filename})
}

View File

@@ -1,167 +0,0 @@
package handlers
import (
"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 {
ctx.JSON(http.StatusInternalServerError, err.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 {
ctx.JSON(http.StatusNotFound, err.Error())
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, err.Error())
return
}
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)
if !(*claims)["admin"].(bool) {
ctx.JSON(http.StatusForbidden, gin.H{"error": "you are not admin :("})
return
}
// Create post
post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID}
tx := store.DB.Create(&post)
if tx.Error != nil {
ctx.JSON(http.StatusInternalServerError, tx.Error.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 {
ctx.JSON(http.StatusNotFound, err.Error())
return
}
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)
if !(userID == post.AuthorID) {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user and post author id missmatch"})
}
var input CreatePostInput
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, err.Error())
return
}
post.Title = input.Title
post.Content = input.Content
tx := store.DB.Save(&post)
if tx.Error != nil {
ctx.JSON(http.StatusInternalServerError, tx.Error.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 {
ctx.JSON(http.StatusNotFound, err.Error())
return
}
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)
if !(userID == post.AuthorID) {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user and post author id missmatch"})
}
store.DB.Delete(&post)
ctx.JSON(http.StatusOK, post)
}

View File

@@ -0,0 +1,185 @@
package handlers
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/gin-gonic/gin"
)
const fallbackMusicDir = "/backend/fallback_music"
var allowedAudioExtensions = map[string]bool{
".mp3": true, ".ogg": true, ".flac": true, ".wav": true, ".m4a": true, ".opus": true,
}
func (store *Store) UploadRadioSong(ctx *gin.Context) {
file, err := ctx.FormFile("file")
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
return
}
const maxSize = 50 << 20 // 50MB
if file.Size > maxSize {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file too large (max 50MB)"})
return
}
ext := strings.ToLower(filepath.Ext(file.Filename))
if !allowedAudioExtensions[ext] {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file type not allowed (accepted: .mp3, .ogg, .flac, .wav, .m4a, .opus)"})
return
}
filename := filepath.Base(file.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 {
ctx.JSON(http.StatusConflict, gin.H{"error": "file already exists"})
return
}
if err := ctx.SaveUploadedFile(file, dest); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
return
}
ctx.JSON(http.StatusOK, gin.H{"name": filename})
}
func (store *Store) ListRadioSongs(ctx *gin.Context) {
type songInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
Modified int64 `json:"modified"`
Disabled bool `json:"disabled"`
}
songs := []songInfo{}
// Read enabled songs
entries, err := os.ReadDir(fallbackMusicDir)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read music directory"})
return
}
for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
songs = append(songs, songInfo{
Name: entry.Name(),
Size: info.Size(),
Modified: info.ModTime().Unix(),
Disabled: false,
})
}
// Read disabled songs
disabledDir := filepath.Join(fallbackMusicDir, "disabled")
disabledEntries, err := os.ReadDir(disabledDir)
if err == nil {
for _, entry := range disabledEntries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
songs = append(songs, songInfo{
Name: entry.Name(),
Size: info.Size(),
Modified: info.ModTime().Unix(),
Disabled: true,
})
}
}
ctx.JSON(http.StatusOK, gin.H{"songs": songs})
}
func (store *Store) DisableRadioSong(ctx *gin.Context) {
filename := filepath.Base(ctx.Param("filename"))
if filename == "." || filename == "/" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
return
}
src := filepath.Join(fallbackMusicDir, filename)
if _, err := os.Stat(src); os.IsNotExist(err) {
ctx.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
disabledDir := filepath.Join(fallbackMusicDir, "disabled")
if err := os.MkdirAll(disabledDir, 0o755); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create disabled directory"})
return
}
dst := filepath.Join(disabledDir, filename)
if err := os.Rename(src, dst); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to disable song"})
return
}
ctx.JSON(http.StatusOK, gin.H{"disabled": filename})
}
func (store *Store) EnableRadioSong(ctx *gin.Context) {
filename := filepath.Base(ctx.Param("filename"))
if filename == "." || filename == "/" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
return
}
src := filepath.Join(fallbackMusicDir, "disabled", filename)
if _, err := os.Stat(src); os.IsNotExist(err) {
ctx.JSON(http.StatusNotFound, gin.H{"error": "file not found in disabled directory"})
return
}
dst := filepath.Join(fallbackMusicDir, filename)
if err := os.Rename(src, dst); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to enable song"})
return
}
ctx.JSON(http.StatusOK, gin.H{"enabled": filename})
}
func (store *Store) DeleteRadioSong(ctx *gin.Context) {
filename := filepath.Base(ctx.Param("filename"))
if filename == "." || filename == "/" {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
return
}
path := filepath.Join(fallbackMusicDir, filename)
if _, err := os.Stat(path); os.IsNotExist(err) {
ctx.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
return
}
if err := os.Remove(path); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete file"})
return
}
ctx.JSON(http.StatusOK, gin.H{"deleted": filename})
}

View File

@@ -5,8 +5,9 @@ import (
"encoding/base64"
"encoding/json"
"io"
"log"
"net/http"
"time"
"strings"
"github.com/rwcarlsen/goexif/exif"
@@ -16,15 +17,16 @@ import (
)
type ExtractedRowingData struct {
TimeMinutes float64 `json:"timeMinutes"`
TimeSeconds float64 `json:"timeSeconds"`
Distance float64 `json:"distance"`
TimeMinutes uint64 `json:"timeMinutes"`
TimeSeconds uint64 `json:"timeSeconds"`
Distance uint64 `json:"distance"`
}
func (store *Store) GetRowing(ctx *gin.Context) {
var rowing []models.Rowing
if err := store.DB.Order("Created_At DESC").Find(&rowing).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, err.Error())
log.Println(err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
ctx.JSON(http.StatusOK, rowing)
@@ -82,6 +84,13 @@ func (store *Store) CreateRowing(ctx *gin.Context) {
}
encoded := base64.StdEncoding.EncodeToString(data)
// Reject duplicates: same EXIF datetime already recorded
var existing models.Rowing
if err := store.DB.Where("date = ?", dateTaken).First(&existing).Error; err == nil {
ctx.JSON(http.StatusConflict, gin.H{"error": "duplicate entry for this date"})
return
}
// Build the message with an image + text prompt
message, err := store.ClaudeClient.Messages.New(context.Background(), anthropic.MessageNewParams{
Model: anthropic.ModelClaudeHaiku4_5,
@@ -110,21 +119,30 @@ No text, no markdown, no explanation. Just the JSON object.`),
},
},
})
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to analyze image"})
log.Println(err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process image"})
return
}
if len(message.Content) == 0 {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "empty response from Claude"})
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "empty response from image processor"})
return
}
extractedData := ExtractedRowingData{}
err = json.Unmarshal([]byte(message.Content[0].Text), &extractedData)
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)
err = json.Unmarshal([]byte(raw), &extractedData)
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse JSON response"})
log.Println(err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse image data"})
return
}
@@ -134,15 +152,39 @@ No text, no markdown, no explanation. Just the JSON object.`),
}
totalSeconds := extractedData.TimeMinutes*60 + extractedData.TimeSeconds
totalDuration := time.Duration(totalSeconds * float64(time.Second))
per500m := time.Duration(totalSeconds / extractedData.Distance * 500 * float64(time.Second))
// Validate for anomalous values
const (
minDistance = 100 // metres
maxDistance = 100000 // metres
minTotalSecs = 30 // 30 seconds
maxTotalSecs = 7200 // 2 hours
minPacePer500m = 80 // ~1:20 /500m (faster than any human)
maxPacePer500m = 150 // ~2:30 /500m (slow, not important)
)
if extractedData.Distance < minDistance || extractedData.Distance > maxDistance {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous distance value"})
return
}
if totalSeconds < minTotalSecs || totalSeconds > maxTotalSecs {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous time value"})
return
}
per500m := float64(totalSeconds) / float64(extractedData.Distance) * 500.0
if per500m < minPacePer500m || per500m > maxPacePer500m {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous pace value"})
return
}
calories := float64(extractedData.Distance) / 7500.0 * 500.0
rowing := models.Rowing{
Date: dateTaken,
Time: totalDuration,
Time: totalSeconds,
TimePer500m: per500m,
Distance: extractedData.Distance,
Calories: extractedData.Distance / 7500.0 * 500.0,
Calories: calories,
}
if err := store.DB.Create(&rowing).Error; err != nil {

View File

@@ -2,6 +2,7 @@ package handlers
import (
"context"
"log"
"net/http"
"time"
@@ -17,11 +18,16 @@ func (store *Store) CompleteSpotifyAuth(ctx *gin.Context) {
token, err := store.SpotifyAuth.Token(c, state, ctx.Request)
if err != nil {
ctx.String(http.StatusInternalServerError, "Couldn't get token: %v", err)
log.Println(err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "authentication failed"})
return
}
services.SaveSpotifyToken(services.SPOTIFY_TOKEN_JSON_PATH, token)
if err := services.SaveSpotifyToken(services.SPOTIFY_TOKEN_JSON_PATH, token); err != nil {
log.Println(err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
client := spotify.New(store.SpotifyAuth.Client(c, token))
@@ -29,9 +35,6 @@ func (store *Store) CompleteSpotifyAuth(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{
"message": "Authentication successful",
"token": token.AccessToken,
"type": token.TokenType,
"expiry": token.Expiry,
})
}
@@ -45,7 +48,8 @@ func (store *Store) ListeningTo(ctx *gin.Context) {
playing, err := store.SpotifyClient.PlayerCurrentlyPlaying(c)
if err != nil {
ctx.JSON(500, gin.H{"error": err.Error()})
log.Println(err)
ctx.JSON(500, gin.H{"error": "failed to fetch currently playing"})
return
}
@@ -53,15 +57,22 @@ func (store *Store) ListeningTo(ctx *gin.Context) {
}
func (store *Store) RecentlyPlayed(ctx *gin.Context) {
if store.SpotifyClient == nil {
ctx.JSON(500, gin.H{"error": "Spotify not authenticated"})
return
}
opts := spotify.RecentlyPlayedOptions{Limit: 3}
if store.RecentSongsFresh() {
ctx.JSON(200, *store.RecentSongs)
return
}
played, err := store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts)
if err != nil {
ctx.JSON(500, gin.H{"error": err.Error()})
log.Println(err)
ctx.JSON(500, gin.H{"error": "failed to fetch recently played"})
return
}

View File

@@ -1,162 +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"`
}
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())
}
// Generate JWT token
tokens, err := store.Auth.GenerateJWT(&user)
if err != nil {
ctx.JSON(http.StatusInternalServerError, err.Error())
return
}
ctx.SetCookie(
"access_token",
tokens.AccessToken,
int(store.Auth.Config.AccessTokenLifetime.Seconds()),
store.Auth.Config.Endpoint,
store.Auth.Config.Domain,
true, true,
)
ctx.SetCookie(
"refresh_token",
tokens.RefreshToken,
int(store.Auth.Config.RefreshTokenLifetime.Seconds()),
store.Auth.Config.Endpoint,
store.Auth.Config.Domain,
true, true,
)
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) 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.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

@@ -8,7 +8,7 @@ import (
func (store *Store) ConnectWebSocket(ctx *gin.Context) {
conn, err := services.Upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
if err != nil {
ctx.JSON(500, gin.H{"error": err.Error()})
// Upgrader already wrote the HTTP error response, so just return
return
}

View File

@@ -17,7 +17,34 @@ type Store struct {
ClaudeClient *anthropic.Client
Auth *services.Auth
Notes *services.Notes
LoginLimiter *services.RateLimiter
EmailSync *services.EmailSyncService
RecentSongs *[]spotify.RecentlyPlayedItem
RecentSongsFetchedAt time.Time
GiteaHost string
GiteaPort string
GiteaFeed *services.GiteaFeedResponse
GiteaFeedFetchedAt time.Time
SteamAPIKey string
SteamID string
SteamRecentGames []services.SteamRecentGame
SteamOnline bool
SteamFetchedAt time.Time
}
func (s *Store) GiteaFeedFresh() bool {
if s.GiteaFeed == nil {
return false
}
return time.Since(s.GiteaFeedFetchedAt) < time.Minute
}
func (s *Store) SteamFresh() bool {
if s.SteamRecentGames == nil {
return false
}
return time.Since(s.SteamFetchedAt) < 5*time.Minute
}

View File

@@ -1,27 +1,38 @@
package main
import (
"context"
"fmt"
"io"
"log"
"os"
"time"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/lru"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/gin-gonic/gin"
"github.com/vektah/gqlparser/v2/ast"
"adam-french.co.uk/backend/graph"
"adam-french.co.uk/backend/handlers"
"adam-french.co.uk/backend/services"
)
func main() {
logsDir := "/backend/logs"
logFile, err := os.OpenFile(logsDir+"/go.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
logFile, err := os.OpenFile(logsDir+"/go.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
panic(err)
}
gin.DefaultWriter = io.MultiWriter(os.Stdout, logFile)
if os.Getenv("DEV_MODE") != "true" {
gin.SetMode(gin.ReleaseMode)
}
r := gin.Default()
err = r.SetTrustedProxies([]string{"172.28.0.0/16"})
@@ -39,6 +50,11 @@ func main() {
if err != nil {
log.Fatal(err)
}
if os.Getenv("SEED_DB") == "true" {
services.SeedDatabase(db)
}
domainName := os.Getenv("DOMAIN")
services.InitWebSocket(db, domainName)
// SPOTIFY
spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE")
@@ -54,9 +70,8 @@ func main() {
claudeClient := services.InitClaude(&claudeConfig)
authSecret := os.Getenv("BACKEND_SECRET")
domainName := os.Getenv("DOMAIN")
backendEndpoint := os.Getenv("BACKEND_ENDPOINT")
accessTokenLifetime := 24 * time.Hour
accessTokenLifetime := 7 * 24 * time.Hour
refreshTokenLifetime := 365 * 24 * time.Hour
authConfig := services.AuthConfig{Secret: []byte(authSecret), Domain: domainName, RefreshTokenLifetime: refreshTokenLifetime, AccessTokenLifetime: accessTokenLifetime, Endpoint: backendEndpoint}
auth := services.InitAuth(&authConfig)
@@ -65,41 +80,57 @@ func main() {
notesConfig := services.NotesConfig{Dir: notesDir}
notes := services.InitNotes(&notesConfig)
store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes}
giteaHost := os.Getenv("GITEA_HOST")
giteaPort := os.Getenv("GITEA_PORT")
steamAPIKey := os.Getenv("STEAM_API_KEY")
steamID := os.Getenv("STEAM_ID")
// 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)
// FAVORITES
r.GET("/favorites", store.GetFavorites)
protected.POST("/favorites", store.CreateFavorite)
admin := r.Group("/", store.AuthMiddlewear, store.AdminMiddleware)
// ROWING
r.GET("/rowing", store.GetRowing)
protected.POST("/rowing", store.CreateRowing)
// ACTIVITIES
r.GET("/activity", store.GetActivity)
protected.POST("/activity", store.CreateActivity)
// POSTS
r.GET("/posts", store.GetPosts)
protected.POST("/posts", store.CreatePost)
r.GET("/posts/:id", store.GetPost)
protected.PUT("/posts/:id", store.UpdatePost)
protected.DELETE("/posts/:id", store.DeletePost)
// USERS
r.GET("/user/:id", store.GetUser)
protected.PUT("/user/:id", store.UpdateUser)
protected.DELETE("/user/:id", store.DeleteUser)
r.GET("/user", store.GetUsers)
r.POST("/user", store.CreateUser)
admin.POST("/rowing", store.CreateRowing)
// AUTH
r.POST("/auth/login", store.Login)
r.POST("/auth/refresh", store.RefreshToken)
r.GET("/auth/check", store.CheckToken)
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
r.GET("/spotify/callback", store.CompleteSpotifyAuth)
@@ -107,17 +138,53 @@ func main() {
r.GET("/spotify/recent", store.RecentlyPlayed)
// r.POST("/spotify", store.SendSong)
// RADIO
admin.POST("/radio/upload", store.UploadRadioSong)
admin.GET("/radio/songs", store.ListRadioSongs)
admin.DELETE("/radio/songs/:filename", store.DeleteRadioSong)
admin.PATCH("/radio/songs/:filename/disable", store.DisableRadioSong)
admin.PATCH("/radio/songs/:filename/enable", store.EnableRadioSong)
// MESSAGES
r.GET("/ws", store.ConnectWebSocket)
protected.POST("/messages/upload", store.UploadMessageFile)
// NOTES
r.GET("/notes/*path", store.GetNoteFile)
admin.GET("/notes/*path", store.GetNoteFile)
// GRAPHQL
gqlSrv := handler.New(graph.NewExecutableSchema(graph.Config{
Resolvers: &graph.Resolver{Store: &store},
}))
gqlSrv.AddTransport(transport.Websocket{KeepAlivePingInterval: 10 * time.Second})
gqlSrv.AddTransport(transport.Options{})
gqlSrv.AddTransport(transport.POST{})
gqlSrv.AddTransport(transport.MultipartForm{})
gqlSrv.SetQueryCache(lru.New[*ast.QueryDocument](1000))
gqlSrv.Use(extension.FixedComplexityLimit(200))
devMode := os.Getenv("DEV_MODE") == "true"
if devMode && os.Getenv("GQL_INTROSPECTION") == "true" {
gqlSrv.Use(extension.Introspection{})
}
r.POST("/graphql", graph.AuthContextMiddleware(auth), func(c *gin.Context) {
gqlSrv.ServeHTTP(c.Writer, c.Request)
})
if devMode && os.Getenv("GQL_PLAYGROUND") == "true" {
r.GET("/graphql", func(c *gin.Context) {
playground.Handler("GraphQL Playground", "/graphql").ServeHTTP(c.Writer, c.Request)
})
}
// HELLO WORLD
r.GET("/", func(c *gin.Context) {
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")
r.Run(fmt.Sprintf(":%s", port))
}

View File

@@ -30,8 +30,8 @@ type Post struct {
type Message struct {
ID uint `gorm:"primarykey" json:"id"`
Content string `json:"text"`
AuthorID uint `json:"-"`
Author *User `gorm:"foreignKey:AuthorID" json:"author"`
AuthorID uint `json:"authorId"`
FileURL string `json:"fileUrl,omitempty"`
CreatedAt time.Time `json:"createdAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
}
@@ -61,8 +61,52 @@ type Rowing struct {
CreatedAt time.Time `json:"createdAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
Date time.Time `json:"date"`
Time time.Duration `json:"time"`
TimePer500m time.Duration `json:"timePer500m"`
Distance float64 `json:"distance"`
Time uint64 `json:"time"`
Distance uint64 `json:"distance"`
TimePer500m float64 `json:"timePer500m"`
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

@@ -37,6 +37,11 @@ func migrateDatabase(db *gorm.DB) error {
&models.Activity{},
&models.Favorite{},
&models.Rowing{},
&models.Message{},
&models.JobApplication{},
&models.JobAppReference{},
&models.Bookmark{},
&models.ProcessedEmail{},
)
if err != nil {
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.`

72
backend/services/gitea.go Normal file
View File

@@ -0,0 +1,72 @@
package services
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type GiteaFeedCommit struct {
Message string `json:"Message"`
}
type GiteaFeedContent struct {
Commits []GiteaFeedCommit `json:"Commits"`
}
type GiteaFeedUser struct {
AvatarURL string `json:"avatar_url"`
}
type GiteaFeedRepo struct {
FullName string `json:"full_name"`
HTMLURL string `json:"html_url"`
}
type GiteaFeedResponse struct {
ActUser GiteaFeedUser `json:"act_user"`
Repo GiteaFeedRepo `json:"repo"`
OpType string `json:"op_type"`
Content string `json:"content"`
Created time.Time `json:"created"`
}
func FetchLatestFeed(host, port string) (*GiteaFeedResponse, error) {
url := fmt.Sprintf("http://%s:%s/api/v1/users/adamf/activities/feeds?limit=1", host, port)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var items []GiteaFeedResponse
if err := json.Unmarshal(body, &items); err != nil {
return nil, err
}
if len(items) == 0 {
return nil, nil
}
return &items[0], nil
}
func ParseCommitMessage(content string) string {
var c GiteaFeedContent
if err := json.Unmarshal([]byte(content), &c); err != nil {
return ""
}
if len(c.Commits) == 0 {
return ""
}
return c.Commits[0].Message
}

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
}

65
backend/services/seed.go Normal file
View File

@@ -0,0 +1,65 @@
package services
import (
"log"
"time"
"adam-french.co.uk/backend/models"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
func SeedDatabase(db *gorm.DB) {
var user models.User
if db.First(&user).Error == nil {
log.Println("Database already has data, skipping seed")
return
}
log.Println("Seeding database with test data...")
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
if err != nil {
log.Fatal("Failed to hash seed password:", err)
}
testUser := models.User{
Username: "testuser",
Password: hashedPassword,
Admin: true,
}
db.Create(&testUser)
posts := []models.Post{
{Title: "Welcome to my blog", Content: "This is the first test post with some example content.", AuthorID: testUser.ID},
{Title: "Learning Go", Content: "Go is a great language for building web servers and APIs.", AuthorID: testUser.ID},
{Title: "Vue 3 Tips", Content: "The composition API makes Vue components much more flexible.", AuthorID: testUser.ID},
}
db.Create(&posts)
link1 := "https://example.com/project"
link2 := "https://example.com/book"
activities := []models.Activity{
{Type: "project", Name: "coding"},
{Type: "hobby", Name: "reading", Link: &link1},
{Type: "fitness", Name: "exercise"},
}
db.Create(&activities)
favorites := []models.Favorite{
{Type: "language", Name: "Go"},
{Type: "book", Name: "Designing Data-Intensive Applications", Link: &link2},
{Type: "framework", Name: "Vue"},
}
db.Create(&favorites)
now := time.Now()
rowingEntries := []models.Rowing{
{Date: now.AddDate(0, 0, -14), Time: 1800, Distance: 5000, TimePer500m: 120.0, Calories: 300},
{Date: now.AddDate(0, 0, -7), Time: 1750, Distance: 5200, TimePer500m: 118.5, Calories: 315},
{Date: now, Time: 1700, Distance: 5400, TimePer500m: 116.2, Calories: 330},
}
db.Create(&rowingEntries)
log.Println("Database seeded successfully")
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"github.com/zmb3/spotify/v2"
@@ -34,6 +35,10 @@ func SaveSpotifyToken(path string, tok *oauth2.Token) error {
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

83
backend/services/steam.go Normal file
View File

@@ -0,0 +1,83 @@
package services
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
type SteamRecentGame struct {
AppID int `json:"appid"`
Name string `json:"name"`
Playtime2Weeks int `json:"playtime_2weeks"`
PlaytimeForever int `json:"playtime_forever"`
}
type SteamRecentGamesResponse struct {
Response struct {
TotalCount int `json:"total_count"`
Games []SteamRecentGame `json:"games"`
} `json:"response"`
}
type SteamPlayerSummary struct {
PersonaState int `json:"personastate"`
}
type SteamPlayerSummariesResponse struct {
Response struct {
Players []SteamPlayerSummary `json:"players"`
} `json:"response"`
}
func FetchRecentlyPlayedGames(apiKey, steamID string) ([]SteamRecentGame, error) {
url := fmt.Sprintf("https://api.steampowered.com/IPlayerService/GetRecentlyPlayedGames/v1/?key=%s&steamid=%s&count=3", apiKey, steamID)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result SteamRecentGamesResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
return result.Response.Games, nil
}
func FetchPlayerSummary(apiKey, steamID string) (*SteamPlayerSummary, error) {
url := fmt.Sprintf("https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=%s&steamids=%s", apiKey, steamID)
client := &http.Client{Timeout: 5 * time.Second}
resp, err := client.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result SteamPlayerSummariesResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, err
}
if len(result.Response.Players) == 0 {
return nil, nil
}
return &result.Response.Players[0], nil
}

View File

@@ -1,33 +1,67 @@
package services
import (
"net/http"
"net/url"
"sync"
"time"
"adam-french.co.uk/backend/models"
"gorm.io/gorm"
"github.com/gorilla/websocket"
)
const maxMessages = 50
var allowedDomain string
var Upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return false
}
u, err := url.Parse(origin)
if err != nil {
return false
}
host := u.Hostname()
return host == allowedDomain || host == "www."+allowedDomain || host == "localhost"
},
}
var (
clients = make(map[*websocket.Conn]bool)
messages = make([]models.Message, 0)
mu sync.Mutex
wsDB *gorm.DB
nextAuthorID uint
)
const (
rateLimitWindow = time.Second
rateLimitMaxMsgs = 10
)
func InitWebSocket(database *gorm.DB, domain string) {
wsDB = database
allowedDomain = domain
}
func HandleWebSocket(conn *websocket.Conn) {
defer conn.Close()
mu.Lock()
clients[conn] = true
nextAuthorID++
authorID := nextAuthorID
// Send existing message history to new client
for _, msg := range messages {
var history []models.Message
wsDB.Order("created_at ASC").Limit(maxMessages).Find(&history)
for _, msg := range history {
if err := conn.WriteJSON(msg); err != nil {
mu.Unlock()
return
@@ -35,17 +69,32 @@ func HandleWebSocket(conn *websocket.Conn) {
}
mu.Unlock()
msgCount := 0
windowStart := time.Now()
for {
var incoming models.Message
if err := conn.ReadJSON(&incoming); err != nil {
break
}
incoming.CreatedAt = time.Now()
now := time.Now()
if now.Sub(windowStart) > rateLimitWindow {
msgCount = 0
windowStart = now
}
msgCount++
if msgCount > rateLimitMaxMsgs {
continue
}
incoming.AuthorID = authorID
// Store and broadcast
mu.Lock()
messages = append(messages, incoming)
wsDB.Create(&incoming)
wsDB.Where("id NOT IN (?)",
wsDB.Model(&models.Message{}).Select("id").Order("created_at DESC").Limit(maxMessages),
).Delete(&models.Message{})
for client := range clients {
if err := client.WriteJSON(incoming); err != nil {
@@ -56,7 +105,6 @@ func HandleWebSocket(conn *websocket.Conn) {
mu.Unlock()
}
// Cleanup on disconnect
mu.Lock()
delete(clients, conn)
mu.Unlock()

View File

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

29
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,29 @@
services:
vue:
command: ["npm", "run", "dev"]
volumes:
- ./vue:/app
- /app/node_modules
- /app/src/wasm
environment:
- NODE_ENV=development
backend:
environment:
- SPOTIFY_REDIRECT_URI=https://localhost/api/spotify/callback
- GQL_PLAYGROUND=true
- GQL_INTROSPECTION=true
- DEV_MODE=true
- SEED_DB=true
nginx:
environment:
- DEV_MODE=true
ports:
- 80:80
- 443:443
hasura:
environment:
HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
HASURA_GRAPHQL_DEV_MODE: "true"
certbot:
profiles:
- disabled

View File

@@ -1,21 +1,49 @@
networks:
app-network:
driver: bridge
ipam:
config:
- subnet: 172.28.0.0/16
volumes:
# Postgres database
dbdata:
# File upload
uploads:
# Vue build
vue_dist:
# Searxng data
searxng_data:
# Open-WebUI data
openwebui_data:
services:
vue:
build:
context: ./vue
dockerfile: Dockerfile
container_name: vue
volumes:
- vue_dist:/output
networks:
- app-network
nginx:
build:
context: ./nginx
dockerfile: Dockerfile
container_name: nginx
env_file: ./.env
restart: unless-stopped
restart: always
depends_on:
- vue
- backend
- icecast2
- gitea
- hasura
- quartz
- searxng
- open-webui
networks:
- app-network
ports:
@@ -24,15 +52,19 @@ services:
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
- uploads:/uploads
- vue_dist:/etc/nginx/html
certbot:
image: certbot/certbot
image: certbot/certbot:v3.1.0
container_name: certbot
volumes:
- ./certbot/entrypoint.sh:/entrypoint.sh
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
entrypoint: ["/entrypoint.sh"]
env_file:
- .env
networks:
- app-network
@@ -41,7 +73,7 @@ services:
context: ./backend
dockerfile: Dockerfile
container_name: "${BACKEND_HOST}"
restart: unless-stopped
restart: always
depends_on:
- db
networks:
@@ -52,11 +84,30 @@ services:
- ./backend/token/:/backend/token
- ${OBSIDIAN_DIR}:/backend/notes
- ./logs:/backend/logs
- uploads:/backend/uploads
- ./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:
image: postgres:16
container_name: "${POSTGRES_HOST}"
restart: unless-stopped
restart: always
env_file:
- ./.env
networks:
@@ -64,6 +115,21 @@ services:
volumes:
- dbdata:/var/lib/postgresql/data
hasura:
image: hasura/graphql-engine:v2.44.0
container_name: "${HASURA_HOST}"
restart: always
depends_on:
- db
networks:
- app-network
environment:
HASURA_GRAPHQL_DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
HASURA_GRAPHQL_ADMIN_SECRET: "${HASURA_GRAPHQL_ADMIN_SECRET}"
HASURA_GRAPHQL_ENABLE_CONSOLE: "false"
HASURA_GRAPHQL_DEV_MODE: "false"
HASURA_GRAPHQL_ENABLED_LOG_TYPES: "startup, http-log, webhook-log, websocket-log, query-log"
icecast2:
build:
context: ./icecast2
@@ -74,5 +140,80 @@ services:
- app-network
env_file:
- ./.env
volumes:
- ./icecast2/fallback_music:/music:ro
ports:
- "${ICECAST_PORT}:${ICECAST_PORT}"
- "${LIQUIDSOAP_HARBOR_PORT:-8001}:${LIQUIDSOAP_HARBOR_PORT:-8001}"
quartz:
build:
context: ./quartz
dockerfile: Dockerfile
container_name: "${QUARTZ_HOST}"
restart: always
networks:
- app-network
env_file:
- ./.env
volumes:
- ${OBSIDIAN_DIR}:/quartz/content:ro
searxng:
build:
context: ./searxng
dockerfile: Dockerfile
container_name: "${SEARXNG_HOST}"
restart: unless-stopped
networks:
- app-network
environment:
- BASE_URL=https://www.${DOMAIN}/searxng/
- INSTANCE_NAME=searxng
- SEARXNG_SECRET_KEY=${SEARXNG_SECRET_KEY}
volumes:
- 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:
image: docker.gitea.com/gitea:1.25.4-rootless
container_name: "${GITEA_HOST}"
entrypoint: ["/usr/bin/dumb-init", "--", "/etc/gitea/entrypoint.sh"]
networks:
- app-network
environment:
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=${POSTGRES_HOST}
- GITEA__database__NAME=${POSTGRES_GITEA_DB}
- GITEA__database__USER=${POSTGRES_USER}
- GITEA__database__PASSWD=${POSTGRES_PASSWORD}
- GITEA__server__LFS_JWT_SECRET=${GITEA_LFS_JWT_SECRET}
- GITEA__security__INTERNAL_TOKEN=${GITEA_INTERNAL_TOKEN}
- GITEA__oauth2__JWT_SECRET=${GITEA_OAUTH2_JWT_SECRET}
- USER_UID=1000
- USER_GID=1000
restart: always
volumes:
- ./gitea/data:/var/lib/gitea
- ./gitea/config:/etc/gitea
- ./gitea/entrypoint.sh:/etc/gitea/entrypoint.sh:ro
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "2222:2222"
- "3000:3000"
depends_on:
- db

24
gitea-runner/download.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="0.2.11"
BASE_URL="https://gitea.com/gitea/act_runner/releases/download/v${VERSION}"
ARCH=$(uname -m)
case "$ARCH" in
x86_64) ASSET="act_runner-${VERSION}-linux-amd64" ;;
aarch64) ASSET="act_runner-${VERSION}-linux-arm64" ;;
armv7l) ASSET="act_runner-${VERSION}-linux-armv7" ;;
*)
echo "Unsupported architecture: $ARCH" >&2
exit 1
;;
esac
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
DEST="${SCRIPT_DIR}/act_runner"
echo "Downloading act_runner v${VERSION} for ${ARCH}..."
curl -fSL "${BASE_URL}/${ASSET}" -o "$DEST"
chmod +x "$DEST"
echo "Downloaded to $DEST"

27
gitea-runner/run.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/usr/bin/env bash
if [ ! -f ./act_runner ]; then
echo "act_runner binary not found. Downloading..." >&2
bash "$(dirname "$0")/download.sh"
fi
echo "Waiting for localhost:3000 to respond..." >&2
while ! curl -sf http://localhost:3000 > /dev/null 2>&1; do
sleep 2
done
echo "localhost:3000 is up." >&2
if [ ! -f .runner ]; then
echo "No .runner file found. Registering runner..." >&2
./act_runner register --no-interactive \
--instance http://localhost:3000 \
--token "${GITEA_RUNNER_REGISTRATION_TOKEN}" \
--name "${GITEA_RUNNER_NAME:-pi-runner}" \
--labels self-hosted
fi
echo "Starting act_runner daemon..." >&2
exec ./act_runner daemon

View File

@@ -0,0 +1,97 @@
APP_NAME = Gitea: Git with a cup of tea
RUN_USER = git
RUN_MODE = prod
WORK_PATH = /var/lib/gitea
[repository]
ROOT = /var/lib/gitea/git/repositories
[repository.local]
LOCAL_COPY_PATH = /tmp/gitea/local-repo
[repository.upload]
TEMP_PATH = /tmp/gitea/uploads
[server]
APP_DATA_PATH = /var/lib/gitea
SSH_DOMAIN = adam-french.co.uk
HTTP_PORT = 3000
ROOT_URL = https://adam-french.co.uk/gitea/
DISABLE_SSH = false
; In rootless gitea container only internal ssh server is supported
START_SSH_SERVER = true
SSH_PORT = 2222
SSH_LISTEN_PORT = 2222
BUILTIN_SSH_SERVER_USER = git
LFS_START_SERVER = true
DOMAIN = stppi.local
LFS_JWT_SECRET =
OFFLINE_MODE = true
[database]
PATH = /var/lib/gitea/data/gitea.db
DB_TYPE = postgres
HOST = db
NAME = gitea
USER = postgres
PASSWD =
SCHEMA =
SSL_MODE = disable
LOG_SQL = false
[session]
PROVIDER_CONFIG = /var/lib/gitea/data/sessions
PROVIDER = file
[picture]
AVATAR_UPLOAD_PATH = /var/lib/gitea/data/avatars
REPOSITORY_AVATAR_UPLOAD_PATH = /var/lib/gitea/data/repo-avatars
[attachment]
PATH = /var/lib/gitea/data/attachments
[log]
ROOT_PATH = /var/lib/gitea/data/log
MODE = console
LEVEL = info
[security]
INSTALL_LOCK = true
SECRET_KEY =
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
PASSWORD_HASH_ALGO = pbkdf2
[service]
DISABLE_REGISTRATION = true
REQUIRE_SIGNIN_VIEW = false
REGISTER_EMAIL_CONFIRM = false
ENABLE_NOTIFY_MAIL = false
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
ENABLE_CAPTCHA = false
DEFAULT_KEEP_EMAIL_PRIVATE = false
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
DEFAULT_ENABLE_TIMETRACKING = true
NO_REPLY_ADDRESS = noreply.localhost
[lfs]
PATH = /var/lib/gitea/git/lfs
[mailer]
ENABLED = false
[openid]
ENABLE_OPENID_SIGNIN = true
ENABLE_OPENID_SIGNUP = true
[cron.update_checker]
ENABLED = false
[repository.pull-request]
DEFAULT_MERGE_STYLE = merge
[repository.signing]
DEFAULT_TRUST_MODEL = committer
[oauth2]
JWT_SECRET =

10
gitea/entrypoint.sh Executable file
View File

@@ -0,0 +1,10 @@
#!/bin/sh
set -e
# Generate app.ini from template if it doesn't already exist
if [ ! -f /etc/gitea/app.ini ]; then
cp /etc/gitea/app.ini.template /etc/gitea/app.ini
echo "Generated app.ini from template"
fi
exec /usr/local/bin/docker-entrypoint.sh "$@"

1
icecast2/.dockerignore Normal file
View File

@@ -0,0 +1 @@
fallback_music/

View File

@@ -1,19 +1,13 @@
FROM debian:latest as builder
WORKDIR /app
FROM savonet/liquidsoap:v2.3.2
USER root
RUN apt-get update \
&& apt-get install --yes icecast2 gettext media-types
# RUN apt-get install --yes liquidsoap
&& apt-get install --yes icecast2 gettext media-types \
&& rm -rf /var/lib/apt/lists/*
RUN useradd radio
RUN chown -R radio:radio /etc/icecast2 /var/log/icecast2
# RUN chown -R radio:radio /etc/liquidsoap /var/log/liquidsoap
RUN mkdir -p /music /etc/liquidsoap
RUN chown -R radio:radio /etc/icecast2 /var/log/icecast2 /music /etc/liquidsoap
USER radio
COPY icecast.xml.template /etc/icecast2/icecast.xml.template
# COPY stream.liq.template /etc/liquidsoap/stream.liq.template
COPY stream.liq.template /etc/liquidsoap/stream.liq.template
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT [ "/entrypoint.sh" ]

View File

@@ -1,11 +1,12 @@
#!/bin/sh
#!/bin/bash
set -e
# Substitute environment variables into template
envsubst < /etc/icecast2/icecast.xml.template > /etc/icecast2/icecast.xml
# envsubst < /etc/liquidsoap/stream.liq.template > /etc/liquidsoap/stream.liq
envsubst < /etc/liquidsoap/stream.liq.template > /etc/liquidsoap/stream.liq
# Run icecast with the generated config
exec icecast2 -c /etc/icecast2/icecast.xml
# exec liquidsoap /etc/liquidsoap/stream.liq
# wait -n
icecast2 -c /etc/icecast2/icecast.xml &
sleep 2
liquidsoap /etc/liquidsoap/stream.liq &
wait -n
kill $(jobs -p) 2>/dev/null || true
exit 1

View File

View File

@@ -1,24 +1,17 @@
[general]
duration = 0 # 0 = run forever
bufferSecs = 5 # buffer size in seconds
reconnect = yes # reconnect on failure
reconnectDelay = 5
settings.server.telnet := false
[input]
device = pulse # PulseAudio input
sampleRate = 44100 # in Hz
bitsPerSample = 16
channel = 2
music = playlist("/music", mode="randomize", reload_mode="watch")
[icecast2-0]
bitrateMode = cbr
bitrate = 128 # kbps
format = mp3
server = ${ICECAST_HOST}
port = ${ICECAST_PORT}
password = ${ICECAST_SOURCE_PASSWORD}
mountPoint = ${ICECAST_MOUNT}
name = "Live DJ stream"
description = "Live microphone stream"
genre = "Various"
public = yes
live = input.harbor("${LIQUIDSOAP_HARBOR_MOUNT}", port=${LIQUIDSOAP_HARBOR_PORT}, password="${ICECAST_SOURCE_PASSWORD}")
radio = amplify(0.7, fallback(track_sensitive=false, [live, music, blank()]))
output.icecast(
%mp3,
host="localhost",
port=${ICECAST_PORT},
password="${ICECAST_SOURCE_PASSWORD}",
mount="${ICECAST_MOUNT}",
fallible=true,
radio
)

2
nginx/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
**/.git
**/.DS_Store

View File

@@ -1,32 +1,9 @@
FROM nginx:latest
RUN rm -rf /etc/nginx/html/*
# Install dependencies needed to add NodeSource repo
RUN apt-get update && apt-get install -y \
curl \
build-essential \
git \
gettext-base \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js LTS + npm
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
&& apt-get install -y nodejs \
&& npm install -g npm@latest
WORKDIR /app
COPY vue/ ./
RUN npm install
RUN npm run build
RUN mkdir -p /etc/nginx/html \
&& cp -r ./dist/* /etc/nginx/html/
FROM nginx:1.27
RUN rm -rf /etc/nginx/html/* && \
apt-get update && apt-get install -y gettext-base openssl && \
rm -rf /var/lib/apt/lists/*
COPY nginx.conf.template /etc/nginx/nginx.conf.template
COPY nginx_setup.conf.template /etc/nginx/nginx_setup.conf.template
COPY robots.txt /etc/nginx/html/robots.txt
COPY nginx_dev.conf.template /etc/nginx/nginx_dev.conf.template
COPY entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -1,15 +1,49 @@
#!/bin/sh
set -e
# Check if certificate exists
if [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then
# Check if DEV_MODE
if [ "$DEV_MODE" = "true" ]; then
echo "Dev mode. Generating self-signed certificate for HTTPS."
CERT_DIR="/etc/letsencrypt/live/localhost"
if [ ! -f "$CERT_DIR/fullchain.pem" ]; then
mkdir -p "$CERT_DIR"
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
-keyout "$CERT_DIR/privkey.pem" \
-out "$CERT_DIR/fullchain.pem" \
-subj "/CN=localhost" 2>/dev/null
fi
# 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.conf
elif [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then
echo "Certificates found. Using production nginx config."
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT}' \
< /etc/nginx/nginx.conf.template \
> /etc/nginx/nginx.conf
# 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
else
echo "Certificates NOT found. Using setup nginx config."
envsubst '${DOMAIN}' < /etc/nginx/nginx_setup.conf.template > /etc/nginx/nginx.conf
# 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
fi
# Ensure upload directory is traversable by nginx worker
chmod 755 /uploads 2>/dev/null || true
# Wait for Vue assets in production mode
if [ "$DEV_MODE" != "true" ]; then
echo "Waiting for Vue assets..."
elapsed=0
while [ ! -f /etc/nginx/html/index.html ] && [ $elapsed -lt 120 ]; do
sleep 1
elapsed=$((elapsed + 1))
done
if [ ! -f /etc/nginx/html/index.html ]; then
echo "WARNING: Vue assets not found after 120s, starting nginx anyway"
else
echo "Vue assets ready."
fi
fi
# Start nginx

View File

@@ -9,6 +9,15 @@ http {
server_tokens off;
charset utf-8;
resolver 127.0.0.11 valid=10s;
client_max_body_size 50M;
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=graphql:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
log_format compact
'$remote_addr "$request" $status rt=$request_time';
@@ -18,6 +27,22 @@ http {
text/javascript mjs;
}
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
font/woff2
application/font-woff2;
server {
listen 80;
server_name $DOMAIN www.$DOMAIN;
@@ -48,6 +73,12 @@ http {
http2 on;
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;
index index.html;
@@ -55,6 +86,36 @@ http {
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
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;
# Vite hashed assets - immutable, cache 1 year
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Fonts - cache 30 days
location /fonts/ {
expires 30d;
add_header Cache-Control "public";
}
# Images - cache 7 days
location /img/ {
expires 7d;
add_header Cache-Control "public";
}
location /uploads/ {
alias /uploads/;
add_header X-Content-Type-Options nosniff always;
add_header Content-Disposition "inline" always;
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'none'; script-src 'none'" always;
}
location / {
try_files $uri $uri/ /index.html;
}
@@ -70,16 +131,66 @@ http {
}
location = /img/stamps/mine.gif {
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Origin "https://www.$DOMAIN";
expires 7d;
add_header Cache-Control "public";
}
location /sound/ {
expires 7d;
add_header Cache-Control "public";
}
location $BACKEND_ENDPOINT {
return 301 $BACKEND_ENDPOINT/;
}
location $BACKEND_ENDPOINT/ {
location $BACKEND_ENDPOINT/ws {
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_pass $upstream_backend;
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;
}
location $BACKEND_ENDPOINT/auth/login {
limit_req zone=login burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
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/messages/upload {
limit_req zone=upload burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
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 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/ {
limit_req zone=api burst=20 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
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;
@@ -91,13 +202,154 @@ http {
}
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 X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /gitea {
return 301 /gitea/;
}
location /gitea/ {
rewrite ^/gitea/(.*)$ /$1 break;
proxy_pass $upstream_gitea;
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 /hasura {
return 301 /hasura/;
}
location /hasura/ {
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 X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /notes {
return 301 /notes/;
}
location /notes/ {
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_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;
}
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 {
return 301 /searxng/;
}
location /searxng/ {
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 X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
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

@@ -0,0 +1,378 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server_tokens off;
charset utf-8;
resolver 127.0.0.11 valid=10s;
client_max_body_size 50M;
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=upload:10m rate=5r/m;
log_format compact
'$remote_addr "$request" $status rt=$request_time';
access_log /var/log/nginx/access.log compact;
types {
text/javascript mjs;
}
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
font/woff2
application/font-woff2;
server {
listen 80;
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/ {
alias /uploads/;
add_header X-Content-Type-Options nosniff always;
add_header Content-Disposition "inline" always;
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'none'; script-src 'none'" always;
}
location / {
proxy_pass http://vue:5173;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
location $BACKEND_ENDPOINT {
return 301 $BACKEND_ENDPOINT/;
}
location $BACKEND_ENDPOINT/ws {
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass $upstream_backend;
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;
}
location $BACKEND_ENDPOINT/auth/login {
limit_req zone=login burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
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/messages/upload {
limit_req zone=upload burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
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/ {
limit_req zone=api burst=20 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
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 /radio {
return 301 /radio/;
}
location /radio/ {
rewrite ^/radio/(.*)$ /$1 break;
proxy_pass $upstream_icecast;
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 /gitea {
return 301 /gitea/;
}
location /gitea/ {
rewrite ^/gitea/(.*)$ /$1 break;
proxy_pass $upstream_gitea;
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 /hasura {
return 301 /hasura/;
}
location /hasura/ {
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 X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /notes {
return 301 /notes/;
}
location /notes/ {
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_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;
}
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 {
return 301 /searxng/;
}
location /searxng/ {
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 X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl;
server_name $DOMAIN www.$DOMAIN;
ssl_certificate /etc/letsencrypt/live/localhost/fullchain.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/ {
alias /uploads/;
add_header X-Content-Type-Options nosniff always;
add_header Content-Disposition "inline" always;
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'none'; script-src 'none'" always;
}
location / {
proxy_pass http://vue:5173;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
location $BACKEND_ENDPOINT {
return 301 $BACKEND_ENDPOINT/;
}
location $BACKEND_ENDPOINT/ws {
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass $upstream_backend;
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;
}
location $BACKEND_ENDPOINT/auth/login {
limit_req zone=login burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
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/messages/upload {
limit_req zone=upload burst=3 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
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/ {
limit_req zone=api burst=20 nodelay;
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
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 /radio {
return 301 /radio/;
}
location /radio/ {
rewrite ^/radio/(.*)$ /$1 break;
proxy_pass $upstream_icecast;
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 /gitea {
return 301 /gitea/;
}
location /gitea/ {
rewrite ^/gitea/(.*)$ /$1 break;
proxy_pass $upstream_gitea;
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 /hasura {
return 301 /hasura/;
}
location /hasura/ {
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 X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /notes {
return 301 /notes/;
}
location /notes/ {
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_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;
}
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 {
return 301 /searxng/;
}
location /searxng/ {
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 X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}

View File

@@ -1,12 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AF</title>
<link rel="icon" type="/img/x-icon" href="/img/favicon.ico" />
</head>
<body id="app">
<script type="module" src="/src/main.js"></script>
</body>
</html>

Binary file not shown.

View File

@@ -1,12 +0,0 @@
<script setup>
import { RouterView } from "vue-router";
import Navbar from "@/components/Navbar.vue";
import Footer from "@/components/Footer.vue";
</script>
<template>
<Navbar class="no-print" />
<RouterView />
<!-- <Footer style="height: 10vh" /> -->
</template>

View File

@@ -1,286 +0,0 @@
@import "tailwindcss";
/* PRINTING */
@media print {
.no-print,
.no-print * {
display: none !important;
margin: 0px;
padding: 0px;
width: 0x;
height: 0px;
}
}
/* END OF PRINTING */
/* FONTS */
@font-face {
font-family: "big_noodle_titling";
src: url("/fonts/big_noodle_titling.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: "CreatoDisplay";
src: url("/fonts/CreatoDisplay-Bold.otf") format("opentype");
font-weight: normal;
font-style: normal;
}
/* END OF FONTS */
/* VARIABLES */
:root {
/* RED, WHITE, BLACK are standard*/
--portal_grey: #dddddd;
--portal_orange: #ff9a00;
--portal_light_orange: #ff5d00;
--portal_blue: #0065ff;
--portal_light_blue: #00a2ff;
/* MAIN COLORS */
--primary: #55ffbb;
--secondary: #62ff57;
--tertiary: #ff579a;
--quaternary: #024942;
/* BACKGROUND COLORS */
--bg_primary: #1b110e;
--bg_secondary: #000;
--link: #222;
--bdr: 2px;
--spacing: 3px;
/* FONTS USED */
--font_heading: big_noodle_titling;
--font_default: CreatoDisplay;
}
@theme {
--color-primary: var(--primary);
--color-secondary: var(--secondary);
--color-tertiary: var(--tertiary);
--color-quaternary: var(--quaternary);
--color-bg_primary: var(--bg_primary);
--color-bg_secondary: var(--bg_secondary);
--color-link: var(--link);
--borderWidth-primary: var(--primary);
--borderWidth-secondary: var(--secondary);
--borderWidth-tertiary: var(--tertiary);
--font-heading: var(--font_heading);
--default-font-family: var(--font_default);
}
/* END OF VARIABLES */
/* ELEMENTS */
body {
margin: 0 auto;
width: 100vw;
height: 100vh;
}
main {
@apply overflow-y-scroll w-full h-full p-10;
}
input {
@apply text-secondary border-primary border;
}
small {
@apply text-tertiary;
}
code {
@apply text-tertiary;
}
ul {
@apply text-tertiary;
}
li {
@apply text-tertiary;
}
h1,
h2,
h3,
h4 {
@apply m-1 font-heading text-primary;
}
h3,
h4 {
@apply text-lg;
}
h1 {
@apply text-2xl;
}
h2 {
@apply text-xl;
}
p {
@apply text-secondary;
}
a {
@apply text-primary bg-link text-center font-heading text-xl tracking-wide;
}
input,
textarea {
@apply text-primary border;
}
table {
@apply border-primary border text-primary;
}
td {
@apply gap-1;
}
tr {
@apply border-primary border-b text-primary;
}
th {
@apply pr-3 pl-3 border-r border-dotted border-tertiary;
}
td {
@apply pr-3 pl-3;
}
/* END OF ELEMENTS */
/* CLASSES */
.img-stamp {
width: 99px;
height: 55px;
}
/* BORDERS */
.bdr-1 {
@apply border-30;
border-image: url("/img/borders/border1.gif") 30 round;
}
.bdr-1-inv {
@apply border-30;
border-image: url("/img/borders/border1inv.gif") 30 round;
}
.bdr-2 {
@apply border-5;
border-image: url("/img/borders/border4.gif") 7 round;
}
.bdr-cv {
@apply border-30;
border-image: url("/img/borders/bordercv.png") 30 round;
}
/* A5 Page */
.a5page-landscape {
@apply m-0 box-content;
height: 148mm;
width: 210mm;
}
.a5page-portrait {
@apply m-0 box-content;
width: 148mm;
height: 210mm;
}
/* A4 Page */
.a4page-portrait {
@apply m-0 box-content;
width: 210mm;
height: 297mm;
}
.a4page-landscape {
@apply m-0 box-content;
height: 210mm;
width: 297mm;
}
/* END OF CLASSES */
/* PHONE */
@media (max-width: 850px) {
.a4page-portrait {
width: 100%; /* fill mobile width */
height: fit-content;
margin: 0 auto; /* center horizontally */
box-sizing: border-box;
}
.a4page-landscape {
width: 100%; /* fill mobile width */
height: fit-content;
margin: 0 auto; /* center horizontally */
box-sizing: border-box;
}
}
@media (max-width: 600px) {
.a5page-portrait {
width: 100%;
height: fit-content;
margin: 0 auto;
box-sizing: border-box;
}
.a5page-landscape {
width: 100%;
height: fit-content;
margin: 0 auto;
box-sizing: border-box;
}
}
.tl {
@apply absolute top-0 left-0;
}
.tr {
@apply absolute top-0 right-0;
}
.bl {
@apply absolute bottom-0 left-0;
}
.br {
@apply absolute bottom-0 right-0;
}
.background {
@apply fixed;
}
.halftone {
--dot_size: 1px;
--bg_size: 3px;
--bg_pos: calc(var(--bg_size) / 2);
--blur: 0%;
background-color: var(--bg_secondary);
background-image: radial-gradient(
circle at center,
var(--bg_primary) var(--dot_size),
transparent var(--blur)
);
background-size: var(--bg_size) var(--bg_size);
background-position: 0 0;
mask-image: linear-gradient(
30deg,
rgba(1, 1, 1, 1) 0%,
rgba(1, 1, 1, 0.9) 100%
);
}

View File

@@ -1,3 +0,0 @@
<template>
<footer></footer>
</template>

View File

@@ -1,71 +0,0 @@
<script setup>
import Headline from "@/components/text/Headline.vue";
import { computed } from "vue";
import { useRoute } from "vue-router";
const route = useRoute();
const parentPath = computed(() => {
const segments = route.path.split("/").filter(Boolean);
if (segments.length == 1) {
return "/";
} else {
segments.pop();
return segments.length ? "/" + segments.join("/") : null;
}
});
const inHome = computed(() => {
return route.path == "/";
});
const faces = [
"^_^",
"¯\\_(ツ)_/¯",
"(◕‿◕✿)",
"ಠ_ಠ",
"ʘ‿ʘ",
"^̮^",
">_>",
"¬_¬",
"˙ ͜ʟ˙",
"( ͡° ͜ʖ ͡°)",
"[̲̅$̲̅(̲̅5̲̅)̲̅$̲̅]",
"(ง'̀-'́)ง",
"\ (•◡•) /",
"( ͡ᵔ ͜ʖ ͡ᵔ )",
"ᕙ(⇀‸↼‶)ᕗ",
"⚆ _ ⚆",
"(。◕‿◕。)",
"(╯°□°)╯︵ ʞooqǝɔɐɟ",
"̿ ̿ ̿'̿'\̵͇̿̿\з=(•_•)=ε/̵͇̿̿/'̿'̿ ̿",
"(☞゚ヮ゚)☞ ☜(゚ヮ゚☜)",
];
const faces_string = faces.join(" ");
</script>
<template>
<nav class="flex flex-row w-full h-fit border border-primary bg-bg_primary">
<RouterLink class="bdr-2 bg-bg_primary" to="/" v-if="!inHome">
<a>HOME</a>
</RouterLink>
<RouterLink
class="bdr-2 bg-bg_primary"
v-if="parentPath"
:to="parentPath"
>
<a>UP</a>
</RouterLink>
<Headline class="border flex-1">
<code class="whitespace-pre">{{ faces_string }}</code>
</Headline>
</nav>
</template>
<style scoped>
.left {
position: fixed;
top: 0;
left: 0;
}
</style>

View File

@@ -1,46 +0,0 @@
<template>
<div v-if="streamLive">
<img src="/img/tmpen31z3pe.PNG" />
<audio controls :src="streamUrl" ref="audio"></audio>
</div>
<div v-else>
<img src="/img/tmpen31z3pe.PNG" />
<div class="m-1">
<p>Stream is offline. Tune in Fridays @ 6:00pm, Monday @ 8:00am</p>
<Button @click="checkStream()">Check Stream</Button>
</div>
</div>
</template>
<script setup>
import Button from "@/components/input/Button.vue";
import { ref, onMounted } from "vue";
import axios from "axios";
const streamMount = ref("");
const streamUrl = ref("");
const streamLive = ref(false);
const audio = ref(null);
async function checkStream() {
try {
const res = await axios.get("/radio/status-json.xsl");
const data = res.data;
streamMount.value = data.icestats.source.listenurl.split("/").pop();
if (streamMount.value) {
streamLive.value = true;
streamUrl.value = "/radio/" + streamMount.value;
if (audio.value) audio.value.load();
}
} catch (err) {
streamLive.value = false;
}
}
onMounted(() => {
checkStream();
setInterval(checkStream, 120000);
});
</script>

View File

@@ -1,36 +0,0 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref } from "vue";
import axios from "axios";
const type = ref("");
const name = ref("");
const link = ref("");
async function post() {
try {
const res = await axios.post("/api/activity", {
type: type.value,
name: name.value,
link: link.value || undefined,
});
type.value = "";
name.value = "";
link.value = "";
console.log(res.data);
} catch (err) {
console.error(err);
}
}
</script>
<template>
<div class="flex flex-col">
<h1>Create Activity</h1>
<input type="text" v-model="type" placeholder="Type" />
<input type="text" v-model="name" placeholder="Name" />
<input type="text" v-model="link" placeholder="Link" />
<Button @click="post">Upload</Button>
</div>
</template>

View File

@@ -1,36 +0,0 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref } from "vue";
import axios from "axios";
const type = ref("");
const name = ref("");
const link = ref("");
async function post() {
try {
const res = await axios.post("/api/favorites", {
type: type.value,
name: name.value,
link: link.value || undefined,
});
type.value = "";
name.value = "";
link.value = "";
console.log(res.data);
} catch (err) {
console.error(err);
}
}
</script>
<template>
<div class="flex flex-col">
<h1>Create Favorite</h1>
<input type="text" v-model="type" placeholder="Type" />
<input type="text" v-model="name" placeholder="Name" />
<input type="text" v-model="link" placeholder="Link" />
<Button @click="post">Upload</Button>
</div>
</template>

View File

@@ -1,36 +0,0 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref } from "vue";
import axios from "axios";
const title = ref("");
const content = ref("");
async function post() {
try {
const res = await axios.post("/api/posts", {
title: title.value,
content: content.value,
});
title.value = "";
content.value = "";
console.log(res.data);
} catch (err) {
console.error(err);
}
}
</script>
<template>
<div class="flex flex-col">
<h1>Create Post</h1>
<input type="text" v-model="title" placeholder="Title" />
<textarea
class="h-50"
v-model="content"
placeholder="Content"
></textarea>
<Button @click="post">Upload</Button>
<!-- make textarea take up most the space -->
</div>
</template>

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