Compare commits

..

142 Commits

Author SHA1 Message Date
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
262 changed files with 13923 additions and 1573 deletions

View File

@@ -17,3 +17,6 @@ jobs:
env:
DOCKER_API_VERSION: "1.41"
run: docker compose up -d --build --remove-orphans
- name: Prune unused Docker resources
run: docker image prune -f

8
.gitignore vendored
View File

@@ -1,13 +1,16 @@
icecast2/fallback_music/*
!icecast2/fallback_music/.gitkeep
certbot/conf
certbot/www
backend/token/
.env
gitea/config/app.ini
gitea/data/*
gitea-runner/data/*
# Will add in future (webpack)
nginx/vue/crates/
# Rust build artifacts
**/target/
# Logs
logs
@@ -47,5 +50,6 @@ coverage
# Vitest
__screenshots__/
.deploy
*.xcf

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).

View File

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

View File

@@ -1,19 +1,24 @@
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
@@ -22,10 +27,11 @@ require (
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 +47,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 +56,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,10 @@ 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/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 +87,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 +120,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 +144,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 +197,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 +226,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 +251,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 +285,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 +319,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 +339,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 +375,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=
@@ -368,8 +392,8 @@ 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 +439,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 +467,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 +522,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=

59
backend/gqlgen.yml Normal file
View File

@@ -0,0 +1,59 @@
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

View File

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

8489
backend/graph/generated.go Normal file

File diff suppressed because it is too large Load Diff

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,27 @@
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
}
// 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,102 @@
// 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 CreateFavoriteInput struct {
Type string `json:"type"`
Name string `json:"name"`
Link *string `json:"link,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 UpdatePostInput struct {
Title string `json:"title"`
Content string `json:"content"`
}

View File

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

View File

@@ -0,0 +1,8 @@
input LoginInput {
username: String!
password: String!
}
type AuthPayload {
user: User!
}

View File

@@ -0,0 +1,14 @@
type Favorite {
id: ID!
createdAt: Time!
updatedAt: Time!
type: String!
name: String!
link: String
}
input CreateFavoriteInput {
type: String!
name: String!
link: String
}

View File

@@ -0,0 +1,8 @@
type GiteaFeedItem {
avatarUrl: String!
repoUrl: String!
repoName: String!
opType: String!
commitMessage: String!
createdAt: Time!
}

View File

@@ -0,0 +1,7 @@
type Message {
id: ID!
content: String!
authorId: Int!
fileUrl: String
createdAt: Time!
}

View File

@@ -0,0 +1,18 @@
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!
}

View File

@@ -0,0 +1,9 @@
type Rowing {
id: ID!
createdAt: Time!
date: Time!
time: Int!
distance: Int!
timePer500m: Float!
calories: Float!
}

View File

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

View File

@@ -0,0 +1,28 @@
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!
}

View File

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

View File

@@ -0,0 +1,12 @@
type User {
id: ID!
createdAt: Time!
updatedAt: Time!
username: String!
admin: Boolean!
}
input CreateUserInput {
username: String!
password: String!
}

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,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,22 @@
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 *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,11 +1,11 @@
package handlers
import (
"fmt"
"net/http"
"adam-french.co.uk/backend/models"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
@@ -27,6 +27,28 @@ 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) CheckToken(ctx *gin.Context) {
access_token, err := ctx.Cookie("access_token")
if err != nil {
@@ -68,10 +90,9 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
claims, err := store.Auth.VerifyJWT(refreshToken)
if err != nil {
ctx.JSON(http.StatusUnauthorized, err.Error())
return
}
fmt.Printf("claims: %v\n", claims)
userIDF, ok := (*claims)["id"].(float64)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid token claims"})
@@ -93,6 +114,7 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
return
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(
"access_token",
tokens.AccessToken,
@@ -122,12 +144,12 @@ func (store *Store) Login(ctx *gin.Context) {
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
}
@@ -137,6 +159,7 @@ func (store *Store) Login(ctx *gin.Context) {
return
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(
"access_token",
tokens.AccessToken,
@@ -164,6 +187,7 @@ func (store *Store) Logout(ctx *gin.Context) {
}
func removeCookies(ctx *gin.Context) {
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(
"access_token",
"",

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

@@ -67,11 +67,6 @@ func (store *Store) CreatePost(ctx *gin.Context) {
}
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)

View File

@@ -6,7 +6,7 @@ import (
"encoding/json"
"io"
"net/http"
"time"
"strings"
"github.com/rwcarlsen/goexif/exif"
@@ -16,9 +16,9 @@ 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) {
@@ -82,6 +82,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 +117,38 @@ No text, no markdown, no explanation. Just the JSON object.`),
},
},
})
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to analyze image"})
ctx.JSON(http.StatusInternalServerError, gin.H{
"raw": message.Content[0].Text,
"error": err.Error(),
})
return
}
if len(message.Content) == 0 {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "empty response from Claude"})
ctx.JSON(http.StatusInternalServerError, gin.H{
"raw": message.Content[0].Text,
"error": "empty response from Claude",
})
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"})
ctx.JSON(http.StatusInternalServerError, gin.H{
"error": "failed to parse JSON response",
"detail": err.Error(),
"raw": raw,
})
return
}
@@ -134,15 +158,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

@@ -53,10 +53,16 @@ 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)

View File

@@ -14,6 +14,10 @@ type UserCredentials struct {
Password string `json:"password" binding:"required"`
}
type SetAdminInput struct {
Admin *bool `json:"admin" binding:"required"`
}
func (store *Store) CreateUser(ctx *gin.Context) {
var input UserCredentials
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
@@ -31,32 +35,9 @@ func (store *Store) CreateUser(ctx *gin.Context) {
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)
}
@@ -109,6 +90,52 @@ func (store *Store) UpdateUser(ctx *gin.Context) {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "will be implemented"})
}
func (store *Store) SetUserAdmin(ctx *gin.Context) {
claimsVal, ok := ctx.Get("userClaims")
if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"})
return
}
claims, ok := claimsVal.(*jwt.MapClaims)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
return
}
callerIDF, ok := (*claims)["id"].(float64)
if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"})
return
}
callerID := uint(callerIDF)
targetID := ctx.Param("id")
var input SetAdminInput
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var user models.User
if err := store.DB.First(&user, targetID).Error; err != nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
if user.ID == callerID {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot change your own admin status"})
return
}
user.Admin = *input.Admin
if err := store.DB.Save(&user).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, user)
}
func (store *Store) DeleteUser(ctx *gin.Context) {
claimsVal, ok := ctx.Get("userClaims")
if !ok {
@@ -141,6 +168,7 @@ func (store *Store) DeleteUser(ctx *gin.Context) {
return
}
ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie(
"access_token",
"",

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

@@ -20,4 +20,29 @@ type Store struct {
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

@@ -7,15 +7,18 @@ import (
"os"
"time"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/gin-gonic/gin"
"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)
}
@@ -38,6 +41,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")
@@ -53,7 +61,6 @@ func main() {
claudeClient := services.InitClaude(&claudeConfig)
authSecret := os.Getenv("BACKEND_SECRET")
domainName := os.Getenv("DOMAIN")
backendEndpoint := os.Getenv("BACKEND_ENDPOINT")
accessTokenLifetime := 24 * time.Hour
refreshTokenLifetime := 365 * 24 * time.Hour
@@ -64,35 +71,43 @@ 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")
store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes, GiteaHost: giteaHost, GiteaPort: giteaPort, SteamAPIKey: steamAPIKey, SteamID: steamID}
protected := r.Group("/", store.AuthMiddlewear)
admin := r.Group("/", store.AuthMiddlewear, store.AdminMiddleware)
// FAVORITES
r.GET("/favorites", store.GetFavorites)
protected.POST("/favorites", store.CreateFavorite)
admin.POST("/favorites", store.CreateFavorite)
// ROWING
r.GET("/rowing", store.GetRowing)
protected.POST("/rowing", store.CreateRowing)
admin.POST("/rowing", store.CreateRowing)
// ACTIVITIES
r.GET("/activity", store.GetActivity)
protected.POST("/activity", store.CreateActivity)
admin.POST("/activity", store.CreateActivity)
// POSTS
r.GET("/posts", store.GetPosts)
protected.POST("/posts", store.CreatePost)
admin.POST("/posts", store.CreatePost)
r.GET("/posts/:id", store.GetPost)
protected.PUT("/posts/:id", store.UpdatePost)
protected.DELETE("/posts/:id", store.DeletePost)
admin.PUT("/posts/:id", store.UpdatePost)
admin.DELETE("/posts/:id", store.DeletePost)
// USERS
r.GET("/user/:id", store.GetUser)
protected.PUT("/user/:id", store.UpdateUser)
protected.DELETE("/user/:id", store.DeleteUser)
admin.PUT("/user/:id", store.UpdateUser)
admin.DELETE("/user/:id", store.DeleteUser)
r.GET("/user", store.GetUsers)
r.POST("/user", store.CreateUser)
admin.POST("/user", store.CreateUser)
admin.PATCH("/user/:id/admin", store.SetUserAdmin)
// AUTH
r.POST("/auth/login", store.Login)
@@ -108,10 +123,22 @@ func main() {
// MESSAGES
r.GET("/ws", store.ConnectWebSocket)
protected.POST("/messages/upload", store.UploadMessageFile)
// NOTES
r.GET("/notes/*path", store.GetNoteFile)
// GRAPHQL
gqlSrv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{
Resolvers: &graph.Resolver{Store: &store},
}))
r.POST("/graphql", graph.AuthContextMiddleware(auth), func(c *gin.Context) {
gqlSrv.ServeHTTP(c.Writer, c.Request)
})
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"})

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,8 @@ 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"`
}

View File

@@ -37,6 +37,7 @@ func migrateDatabase(db *gorm.DB) error {
&models.Activity{},
&models.Favorite{},
&models.Rowing{},
&models.Message{},
)
if err != nil {
return err

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
}

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")
}

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,66 @@
package services
import (
"net/http"
"strings"
"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
}
origin = strings.TrimPrefix(origin, "https://")
origin = strings.TrimPrefix(origin, "http://")
// Strip port for localhost comparisons (e.g. "localhost:80")
host := strings.Split(origin, ":")[0]
return origin == allowedDomain || origin == "www."+allowedDomain || host == "localhost"
},
}
var (
clients = make(map[*websocket.Conn]bool)
messages = make([]models.Message, 0)
mu sync.Mutex
clients = make(map[*websocket.Conn]bool)
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 +68,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 +104,6 @@ func HandleWebSocket(conn *websocket.Conn) {
mu.Unlock()
}
// Cleanup on disconnect
mu.Lock()
delete(clients, conn)
mu.Unlock()

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

@@ -0,0 +1,17 @@
services:
vue:
command: ["npm", "run", "dev"]
volumes:
- ./vue:/app
- /app/node_modules
environment:
- NODE_ENV=development
nginx:
environment:
- DEV_MODE=true
- SEED_DB=true
ports:
- 80:80
certbot:
profiles:
- disabled

View File

@@ -4,8 +4,20 @@ networks:
volumes:
dbdata:
uploads:
vue_dist:
services:
vue:
build:
context: ./vue
dockerfile: Dockerfile
container_name: vue
volumes:
- vue_dist:/output
networks:
- app-network
nginx:
build:
context: ./nginx
@@ -14,6 +26,7 @@ services:
env_file: ./.env
restart: always
depends_on:
- vue
- backend
- icecast2
- gitea
@@ -25,6 +38,8 @@ services:
volumes:
- ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot
- uploads:/uploads
- vue_dist:/etc/nginx/html
certbot:
image: certbot/certbot
@@ -55,6 +70,7 @@ services:
- ./backend/token/:/backend/token
- ${OBSIDIAN_DIR}:/backend/notes
- ./logs:/backend/logs
- uploads:/backend/uploads
db:
image: postgres:16
@@ -66,8 +82,6 @@ services:
- app-network
volumes:
- dbdata:/var/lib/postgresql/data
ports:
- 5432:5432
icecast2:
build:
@@ -79,12 +93,16 @@ 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}"
gitea-runner:
image: gitea/act_runner:latest
container_name: "${GITEA_RUNNER_HOST}"
profiles:
- disabled
environment:
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME}
CONFIG_FILE: /config.yaml
@@ -94,7 +112,7 @@ services:
volumes:
- ./gitea-runner/config.yaml:/config.yaml
- ./gitea-runner/data:/data
- /var/run/docker.sock:/var/run/docker.sock
- /var/run/docker.sock:/var/run/docker.sock # WARNING: Docker socket mount gives container host-level access. Runner is in 'disabled' profile to mitigate risk.
restart: unless-stopped
networks:
- app-network
@@ -110,6 +128,11 @@ services:
- 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
@@ -117,7 +140,7 @@ services:
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3000:3000"
- "2222:2222"
- "3000:3000"
depends_on:
- db

View File

@@ -25,7 +25,7 @@ SSH_LISTEN_PORT = 2222
BUILTIN_SSH_SERVER_USER = git
LFS_START_SERVER = true
DOMAIN = stppi.local
LFS_JWT_SECRET = XHIJprS_aMv0tizioZpUD38GGqTtNMFXMz1R6LuPvjU
LFS_JWT_SECRET =
OFFLINE_MODE = true
[database]
@@ -34,7 +34,7 @@ DB_TYPE = postgres
HOST = db
NAME = gitea
USER = postgres
PASSWD = password
PASSWD =
SCHEMA =
SSL_MODE = disable
LOG_SQL = false
@@ -60,7 +60,7 @@ INSTALL_LOCK = true
SECRET_KEY =
REVERSE_PROXY_LIMIT = 1
REVERSE_PROXY_TRUSTED_PROXIES = *
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3NzEyNDMyMTd9.yHsgFcEwDNWmZebftpe8tpWRFa5aR5tkpQuVYybeVaY
INTERNAL_TOKEN =
PASSWORD_HASH_ALGO = pbkdf2
[service]
@@ -95,4 +95,4 @@ DEFAULT_MERGE_STYLE = merge
DEFAULT_TRUST_MODEL = committer
[oauth2]
JWT_SECRET = pYiwW8xxGi23gysl2pa-02Cf567Z5ERvR6DDFGIn2iQ
JWT_SECRET =

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

@@ -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/
RUN rm -rf /etc/nginx/html/* && \
apt-get update && apt-get install -y gettext-base && \
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,38 @@
#!/bin/sh
set -e
# Check if certificate exists
if [ -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} ${GITEA_HOST} ${GITEA_PORT}' \
< /etc/nginx/nginx.conf.template \
> /etc/nginx/nginx.conf
# Check if dev mode, certificate exists, or setup mode
if [ "$DEV_MODE" = "true" ]; then
echo "Dev mode. Using HTTP-only nginx config."
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_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} ${GITEA_HOST} ${GITEA_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
echo "Certificates NOT found. Using setup nginx config."
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,12 @@ http {
server_tokens off;
charset utf-8;
client_max_body_size 10M;
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';
@@ -18,6 +24,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;
@@ -55,6 +77,31 @@ http {
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
# 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;
}
@@ -71,13 +118,53 @@ http {
location = /img/stamps/mine.gif {
add_header Access-Control-Allow-Origin *;
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/ws {
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
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 http://$BACKEND_HOST:$BACKEND_PORT/;
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 http://$BACKEND_HOST:$BACKEND_PORT/;
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 http://$BACKEND_HOST:$BACKEND_PORT/;
proxy_set_header Host $host;

View File

@@ -0,0 +1,134 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server_tokens off;
charset utf-8;
client_max_body_size 10M;
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;
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 http://$BACKEND_HOST:$BACKEND_PORT/;
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 http://$BACKEND_HOST:$BACKEND_PORT/;
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 http://$BACKEND_HOST:$BACKEND_PORT/;
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 http://$BACKEND_HOST:$BACKEND_PORT/;
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/ {
proxy_pass http://$ICECAST_HOST:$ICECAST_PORT/;
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/ {
proxy_pass http://$GITEA_HOST:$GITEA_PORT/;
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,3 +0,0 @@
<template>
<footer></footer>
</template>

View File

@@ -1,61 +0,0 @@
<template>
<div ref="container" @mouseover="handleHover" class="overflow-y-auto">
<slot />
</div>
</template>
<script setup>
import { useTemplateRef, onMounted, onBeforeUnmount } from "vue";
const container = useTemplateRef("container");
const SPEED = 0.0005; // % per frame
const PAUSE = 2000; // ms at top/bottom
let pos = 0;
let direction = 1; // 1 = down, -1 = up
let timeoutId;
let timeoutId2;
function handleHover() {
cancelAnimationFrame(timeoutId);
clearTimeout(timeoutId2);
timeoutId2 = setTimeout(
() => (timeoutId = requestAnimationFrame(tick)),
PAUSE,
);
}
function tick() {
const el = container.value;
const reachedBottom = pos <= 0;
const reachedTop = pos >= 1;
if (reachedBottom) {
pos = 0.001;
direction = 1;
handleHover();
return;
} else if (reachedTop) {
pos = 0.999;
direction = -1;
handleHover();
return;
}
pos += direction * SPEED;
el.scrollTop = pos * el.scrollHeight;
timeoutId = requestAnimationFrame(tick);
}
onMounted(() => {
timeoutId = requestAnimationFrame(tick);
});
onBeforeUnmount(() => {
cancelAnimationFrame(timeoutId);
});
</script>

View File

@@ -1,28 +0,0 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { useMessagesStore } from "@/stores/messages";
const messagesStore = useMessagesStore();
const messages = computed(() => messagesStore.messages);
onMounted(() => {
messagesStore.connect();
});
onUnmounted(() => {
messagesStore.disconnect();
});
</script>
<template>
<div>
<div class="flex flex-col">
<p v-for="message in messages" :key="message.id">
{{ message.content }}
</p>
</div>
<div class="flex flex-row">
<input v-model="messageInput" @keyup.enter="sendMessage" />
<Button @click="sendMessage">Send</Button>
</div>
</div>
</template>

View File

@@ -1,58 +0,0 @@
<script setup>
import axios from "axios";
import { ref, onMounted } from "vue";
const url =
"https://www.adam-french.co.uk/gitea/api/v1/users/adamf/activities/feeds?limit=1";
const feed = ref(null);
const isLoading = ref(true);
const hasError = ref(false);
async function checkFeed() {
try {
const res = await axios.get(url);
feed.value = res.data[0] || null;
hasError.value = false;
} catch (err) {
hasError.value = true;
} finally {
isLoading.value = false;
}
}
onMounted(() => {
checkFeed();
});
</script>
<template>
<div class="justify-center text-center">
<div v-if="isLoading">
<p>Loading latest activity...</p>
</div>
<div v-else-if="hasError">
<p>Could not fetch feed. Please try again later.</p>
</div>
<div v-else-if="feed" class="flex-col justify-center flex">
<h3>Last git activity</h3>
<img
:src="feed.act_user.avatar_url"
alt="User avatar"
class="avatar"
/>
<a :href="feed.repo.html_url">
<h3>repo: {{ feed.repo.full_name }}</h3>
</a>
<p>Action: {{ feed.op_type }}</p>
<p>Message: {{ JSON.parse(feed.content).Commits[0].Message }}</p>
<small> {{ new Date(feed.created).toLocaleString() }}</small>
</div>
<div v-else>
<p>No activity found.</p>
</div>
</div>
</template>

View File

@@ -1,20 +0,0 @@
<script setup>
import { computed } from "vue";
const props = defineProps({
linkArr: {
type: Array,
required: true,
},
});
const keys = ["name", "link"];
</script>
<template>
<a v-for="(row, rowIndex) in linkArr" :key="rowIndex" :href="row.link">
<p class="bdr-2 bg-bg_tertiary">
{{ row.name }}
</p>
</a>
</template>

View File

@@ -1,27 +0,0 @@
<script setup>
// Array will have the form
// [ {type: string, name: string, link?: string}]
const props = defineProps({
data: {
type: Array,
required: true,
},
});
const keys = ["type", "name", "link"];
</script>
<template>
<table>
<tbody>
<tr v-for="item in data" :key="item.id">
<th>{{ item.type }}</th>
<td v-if="item.link">
<a :href="item.link">{{ item.name }}</a>
</td>
<td v-else>{{ item.name }}</td>
</tr>
</tbody>
</table>
</template>

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>Radio is offline. Message for info!</p>
<Button class="w-full" @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,28 +0,0 @@
<script setup>
import ToggleHeader from "@/components/text/ToggleHeader.vue";
import { ref } from "vue";
import LinkTable from "@/components/util/LinkTable.vue";
const props = defineProps({
linkArr: {
type: Array,
required: true,
},
title: {
type: String,
required: true,
},
});
const show_links = ref(false);
</script>
<template>
<div class="h-fit w-fit">
<ToggleHeader v-model="show_links" class="justify-between flex"
>{{ title }}
</ToggleHeader>
<LinkTable v-if="show_links" :linkArr="props.linkArr" />
</div>
</template>

View File

@@ -1,73 +0,0 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import axios from "axios";
export const useAuthStore = defineStore("auth", () => {
const user = ref({});
const loggedIn = computed(() => !!user.value.username);
checkToken();
async function logOut() {
try {
const res = await axios.post("/api/auth/logout");
} catch (err) {
console.error(err);
}
user.value = {};
}
async function logIn(username, password) {
try {
const res = await axios.post("/api/auth/login", {
username,
password,
});
user.value = res.data;
} catch (err) {
console.error(err);
}
}
async function createUser(username, password) {
try {
const res = await axios.post("/api/user", {
username,
password,
});
user.value = res.data;
} catch (err) {
console.error(err);
}
}
async function refreshToken() {
try {
const res = await axios.post("/api/auth/refresh");
user.value = res.data;
} catch (err) {
console.log(err);
}
}
async function checkToken() {
try {
const res = await axios.get("/api/auth/check");
user.value = res.data;
} catch (err) {
user.value = {};
}
}
return {
user,
loggedIn,
logIn,
checkToken,
refreshToken,
logOut,
createUser,
};
});

View File

@@ -1,77 +0,0 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
const URL = "/api/ws";
const message_template = {
id: 0,
content: "Yo",
};
export const useMessagesStore = defineStore("messages", () => {
const socket = ref(null);
const messages = ref([message_template]);
const isConnected = ref(false);
const lastError = ref(null);
const messagesCount = computed(() => messages.value.length);
function connect() {
if (socket.value && isConnected.value) return;
socket.value = new WebSocket(URL);
socket.value.onopen = () => {
isConnected.value = true;
lastError.value = null;
};
socket.value.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
messages.value.push(data);
} catch {
// fallback if server sends plain text
messages.value.push(event.data);
}
};
socket.value.onerror = (error) => {
lastError.value = error;
};
socket.value.onclose = () => {
isConnected.value = false;
socket.value = null;
};
}
function disconnect() {
if (!socket.value) return;
socket.value.close();
socket.value = null;
isConnected.value = false;
}
function sendMessage(payload) {
if (!socket.value || !isConnected.value) return;
socket.value.send(JSON.stringify(payload));
}
function clearMessages() {
messages.value = [];
}
return {
messages,
isConnected,
lastError,
messagesCount,
connect,
disconnect,
sendMessage,
clearMessages,
};
});

View File

@@ -1,39 +0,0 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import axios from "axios";
const song_template = {
id: 1,
track: {
id: 1,
name: "^_^",
album: { images: [{ url: "/img/Untitled.png" }] },
artists: [{ name: ">_<" }],
},
};
export const useSongsStore = defineStore("songs", () => {
const songs = ref([song_template]);
const songsCount = computed(() => songs.value.length);
async function fetchSongs() {
try {
const res = await axios.get("/api/spotify/recent");
if (!Array.isArray(res.data)) {
throw new Error("Invalid response from Spotify API");
}
songs.value = res.data;
} catch (err) {
console.error("Cannot connect to Spotify API", err);
}
}
return {
songs,
songsCount,
fetchSongs,
};
});

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,46 +0,0 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref } from "vue";
import axios from "axios";
const image = ref(null);
const status = ref("");
const error = ref("");
function onFileChange(e) {
image.value = e.target.files[0] || null;
}
async function submit() {
if (!image.value) {
error.value = "Please select an image";
return;
}
error.value = "";
status.value = "Uploading...";
const formData = new FormData();
formData.append("image", image.value);
try {
const res = await axios.post("/api/rowing", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
status.value = `Saved: ${res.data.Distance}m in ${Math.floor(res.data.Time / 1e9 / 60)}:${String(Math.floor((res.data.Time / 1e9) % 60)).padStart(2, "0")}`;
image.value = null;
} catch (err) {
status.value = "";
error.value = err.response?.data?.error || "Upload failed";
}
}
</script>
<template>
<div class="flex flex-col gap-2">
<h1>Create Rowing</h1>
<input type="file" accept="image/jpeg,image/png,image/gif,image/webp" @change="onFileChange" />
<Button @click="submit">Upload</Button>
<p v-if="status">{{ status }}</p>
<p v-if="error" class="text-red-500">{{ error }}</p>
</div>
</template>

View File

@@ -1,28 +0,0 @@
<script setup>
import Button from "@/components/input/Button.vue";
import { ref, onMounted, computed } from "vue";
import { useAuthStore } from "@/stores/auth";
const auth = useAuthStore();
const username = ref("");
const password = ref("");
function handleLogin() {
auth.createUser(username.value, password.value);
}
</script>
<template>
<div v-if="auth.loggedIn" class="flex flex-col">
<h1>Logged in</h1>
<p>{{ auth.user.id }}</p>
<p>{{ auth.user.username }}</p>
<p>{{ auth.user.admin }}</p>
</div>
<div v-else class="flex flex-col">
<h1>Create User</h1>
<input type="text" v-model="username" placeholder="Username" />
<input type="password" v-model="password" placeholder="Password" />
<Button @click="handleLogin">Create Account</Button>
</div>
</template>

View File

@@ -1,76 +0,0 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import { Transition } from "vue";
import Header from "@/components/text/Header.vue";
const images = [
{ url: "/img/memes/pidgeon.gif", comment: "鸟" },
// { url: "/img/memes/no_slip.png" },
//{ url: "/img/memes/epic.jpeg" },
// { url: "/img/bedroom/img2.png", comment: "办公桌" },
// { url: "/img/bedroom/img1.png", comment: "床" },
];
const currentIndex = ref(0);
const currentComment = computed(() => images[currentIndex.value].comment);
const currentUrl = computed(() => images[currentIndex.value].url);
let nextId;
function nextImage() {
clearTimeout(nextId);
currentIndex.value = (currentIndex.value + 1) % images.length;
nextId = setTimeout(nextImage, 10000);
}
function nextRandomImage() {
clearTimeout(nextId);
let newIndex;
do {
newIndex = Math.floor(Math.random() * images.length);
} while (newIndex === currentIndex.value);
currentIndex.value = newIndex;
nextId = setTimeout(nextImage, 10000);
}
onMounted(() => {
nextId = setTimeout(nextImage, 10000);
});
onUnmounted(() => {
clearTimeout(nextId);
});
</script>
<template>
<Transition name="fade" mode="out-in">
<div class="image-viewer" @click="nextImage" :key="currentIndex">
<Header v-if="currentComment">
{{ currentComment }}
</Header>
<img :src="currentUrl" alt="Image Viewer" />
</div>
</Transition>
</template>
<style scoped>
.image-viewer {
width: 100%;
overflow: hidden;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -1,143 +0,0 @@
<script setup>
import Timer from "@/components/util/Timer.vue";
import Time from "@/components/util/Time.vue";
import Radio from "@/components/util/Radio.vue";
import Elle from "@/components/elle/Elle.vue";
import Chat from "@/components/util/Chat.vue";
import MusicPlayer from "@/components/util/MusicPlayer.vue";
import CommitHistory from "@/components/util/CommitHistory.vue";
import Intro from "./Intro.vue";
import Intro2 from "./Intro2.vue";
import BadApple from "./BadApple.vue";
import Stamps from "./Stamps.vue";
import Listening from "./Listening.vue";
import Links from "./Links.vue";
import Feed from "./Feed.vue";
import Collage from "./Collage.vue";
import Favorites from "./Favorites.vue";
import Gym from "./Gym.vue";
import Consumption from "./Consumption.vue";
</script>
<template>
<main class="halftone justify-center flex flex-row w-full h-full">
<div class="h-fit flex flex-row">
<div class="a4page-portrait homeGrid relative bdr-1">
<!-- <Intro class="intro" /> -->
<Intro2 class="intro" />
<!-- <BadApple class="intro" /> -->
<Listening class="listening" />
<Stamps class="stamps" />
<Feed class="feed" />
<Links class="links" />
<Collage class="collage" />
<Consumption class="consumption" />
<Favorites class="favorites" />
<Gym class="gym" />
</div>
<div
class="sidebar border-quaternary place-content-between flex-1 flex flex-col m-10 w-60"
>
<div class="flex flex-col flex-1 gap-2">
<Time
class="bg-bg_primary border-primary border text-center"
/>
<Timer class="border-primary border bg-bg_primary" />
<Radio
class="border-primary border bg-bg_primary text-center"
/>
<CommitHistory
class="border-primary border bg-bg_primary text-center"
/>
<!-- <Elle class="flex-1" /> -->
<!-- <Chat class="bdr-2 bg-bg_primary" /> -->
<!-- <MusicPlayer /> -->
</div>
<div>
<img
src="/img/memes/fire-woman.gif"
class="border-tertiary border"
/>
</div>
</div>
</div>
</main>
</template>
<style scoped>
.homeGrid > * {
border: 2px solid var(--quaternary);
border-color: var(--quaternary);
background-color: var(--bg_primary);
}
.homeGrid {
display: grid;
grid-gap: 5px;
grid-template-columns: repeat(10, 1fr);
grid-template-rows: repeat(10, 1fr);
}
@media (max-width: 850px) {
.homeGrid {
width: 100%;
display: flex;
flex-direction: column;
}
}
@media (max-width: 1200px) {
.tr,
.br,
.sidebar {
display: none;
}
}
.intro {
grid-column: 1 / span 6;
grid-row: 1 / span 4;
}
.listening {
grid-column: 7 / span 4;
grid-row: 1 / span 3;
}
.stamps {
grid-column: 7 / span 4;
grid-row: 4 / span 1;
}
.feed {
grid-column: 1 / span 3;
grid-row: 5 / span 4;
}
.links {
grid-column: 4 / span 2;
grid-row: 5 / span 4;
}
.collage {
grid-column: 6 / span 5;
grid-row: 5 / span 4;
}
.consumption {
grid-column: span 4;
grid-row: span 2;
}
.gym {
grid-column: span 3;
grid-row: span 2;
}
.favorites {
grid-column: span 3;
grid-row: span 2;
}
</style>

View File

@@ -1,91 +0,0 @@
<script setup lang="ts">
import { rand } from "@vueuse/core";
import { ref, onMounted, onUnmounted, nextTick } from "vue";
interface Item {
x: number;
y: number;
dx: number;
dy: number;
content: string;
}
const container = ref<HTMLDivElement | null>(null);
const itemEls = ref<HTMLDivElement[]>([]);
const phrases = [
"Welcome to my website",
"I'm looking to do a big revamp",
"A4 sheets of paper are what I'm used to",
"I'd love to know your recommendations",
"for very short books",
"Message me by any means necessary",
"I like anime, all kinds of music and sci fic",
];
const items = ref<Item[]>(
phrases.map((text, i) => ({
x: i * 20,
y: i * 20,
dx: rand(0, 30) / 100,
dy: 0.5,
content: text,
})),
);
let rafId = 0;
function animate() {
const c = container.value;
if (!c) return;
const cw = c.clientWidth;
const ch = c.clientHeight;
items.value.forEach((item, i) => {
const el = itemEls.value[i];
if (!el) return;
const ew = el.offsetWidth;
const eh = el.offsetHeight;
item.x += item.dx;
item.y += item.dy;
if (item.x < 0 || item.x > cw - ew) item.dx *= -1;
if (item.y < 0 || item.y > ch - eh) item.dy *= -1;
});
rafId = requestAnimationFrame(animate);
}
onMounted(async () => {
await nextTick();
rafId = requestAnimationFrame(animate);
});
onUnmounted(() => {
cancelAnimationFrame(rafId);
});
</script>
<template>
<div
ref="container"
class="w-full h-full min-h-125 relative overflow-hidden"
>
<div
v-for="(item, i) in items"
:key="i"
ref="itemEls"
class="absolute w-fit h-fit"
:style="{
transform: `translate(${item.x}px, ${item.y}px)`,
}"
>
<h1>
{{ item.content }}
</h1>
</div>
</div>
</template>

View File

@@ -1,49 +0,0 @@
<script setup>
import { ref } from "vue";
import Touchscreen from "@/components/util/Touchscreen.vue";
import { shuffleArray } from "@/js/utils.js";
let srcs = [
"/img/stamps/portal.gif",
"/img/stamps/miku.gif",
"/img/stamps/utau.gif",
"/img/stamps/teto.webp",
"/img/stamps/3ds.jpg",
"/img/stamps/fry.png",
"/img/stamps/ai.png",
"/img/stamps/rei.png",
"/img/stamps/tetris.gif",
"/img/stamps/tf2.gif",
"/img/stamps/demo.gif",
];
shuffleArray(srcs);
</script>
<template>
<Touchscreen>
<div class="flex flex-wrap tst">
<a href="https://www.adam-french.co.uk">
<img src="https://www.adam-french.co.uk/img/stamps/mine.gif" />
</a>
<a href="https://jacobbarron.xyz">
<img
src="https://jacobbarron.xyz/Banneh.gif"
alt="jacobbarron.xyz"
/>
</a>
<img v-for="src in srcs" :src="src" />
</div>
</Touchscreen>
</template>
<style scoped>
img {
width: 89px;
height: 59px;
}
.tst {
min-width: calc(89px * 4);
}
</style>

View File

@@ -1,13 +0,0 @@
<script setup>
import Wip from "@/components/util/Wip.vue";
</script>
<template>
<main class="items-center flex flex-col">
<div
class="a4page-portrait items-center bdr-1 flex flex-col relative overflow-scroll"
>
<Wip />
</div>
</main>
</template>

View File

@@ -1,14 +1,83 @@
# Introduction
# My Web
![screenshot](nginx/vue/public/img/screenshot.png)
## Important TODO
- Get a new background
![screenshot](vue/public/img/screenshot.png)
Welcome to the source code for my website! Please contact me if you would like to collaborate and thank you for visiting.
This website is currently self hosted on my Rasberry PI. Any interference and the killswitch will activate and stop the UK national grid power system so please don't tamper with my domain :).
This website is currently self hosted on my Raspberry Pi. Any interference and the killswitch will activate and stop the UK national grid power system so please don't tamper with my domain :).
# Future ideas
## Architecture
- Rust to wasm
All services run in Docker containers orchestrated by Docker Compose:
```
nginx (80, 443) ── Frontend SPA + Reverse Proxy
backend (8080) ── Go API
db (5432) ── PostgreSQL 16
icecast2 (8000) ── Audio Streaming
gitea (3000) ── Self-Hosted Git
gitea-runner ── CI/CD Runner
certbot ── SSL Certificate Management
```
## Tech Stack
**Frontend** - Vue 3, Vite, Tailwind CSS v4, Pinia, Vue Router, markdown-it (wikilinks + KaTeX), Rust/WASM
**Backend** - Go (Gin), gqlgen (GraphQL), GORM, PostgreSQL, JWT auth, WebSockets
**Integrations** - Spotify API, Anthropic Claude API, Icecast2
**Infrastructure** - Docker Compose, Nginx, Let's Encrypt (Certbot), Gitea + Act Runner
## Features
- Spotify integration (currently playing, recently played)
- Obsidian note viewer with wikilink and LaTeX support
- Live radio streaming via Icecast2
- Real-time chat over WebSockets
- Blog with admin panel (CRUD)
- Activity and rowing session tracking
- Fan shrines (GTO, Evangelion, Demoman, Skip Skip Benben)
- Self-hosted Git (Gitea) with CI/CD and commit feed on homepage
- Claude AI integration
## Pages
| Route | Description |
| -------------- | ------------------------------------- |
| `/` | Home dashboard with grid layout |
| `/admin` | Admin panel (authenticated) |
| `/cv` | Curriculum Vitae |
| `/bookmarks` | Bookmarks |
| `/notes/:path` | Obsidian note viewer |
| `/shrines` | Fan shrine index + individual shrines |
## API
The primary API is **GraphQL** at `POST /api/graphql` (with a playground at `GET /api/graphql`). Queries cover posts, users, favorites, activities, rowing sessions, Spotify (currently playing, recently played), Gitea feed, and messages.
REST endpoints handle auth (`/auth/*`), Spotify OAuth (`/spotify/*`), file uploads (`/messages/upload`), note serving (`/notes/*`), and WebSocket chat (`/api/ws`).
Protected endpoints require JWT authentication via `/auth/login` (tokens set as HTTP-only cookies).
## Local Testing (Dev Mode)
Run the full stack over plain HTTP without SSL certificates:
```
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
```
This uses an HTTP-only nginx config with all routing (SPA, backend proxy, radio, gitea) and disables certbot. Visit `http://localhost` to test.
## Future Ideas
- More Rust to WASM
- ML for chatboards
- Cache requests
- Design more webpages
@@ -17,12 +86,11 @@ This website is currently self hosted on my Rasberry PI. Any interference and th
- Design shrines
- Redis (not really but practical experience)
# .env
## .env
These environment variables are found in the `.env` file. The use of environment variables can be found by reading the code so the security of the variable names are not significant.
```
POSTGRES_USER=
POSTGRES_PASSWORD=
POSTGRES_DB=
@@ -42,6 +110,10 @@ BACKEND_HOST=
BACKEND_SECRET=
BACKEND_ENDPOINT=
CLAUDE_API_KEY=
SEED_DB=
OBSIDIAN_DIR=
SPOTIFY_CLIENT_ID=
@@ -60,4 +132,7 @@ ICECAST_MOUNT=
DOMAIN=
EMAIL=
GITEA_LFS_JWT_SECRET=
GITEA_INTERNAL_TOKEN=
GITEA_OAUTH2_JWT_SECRET=
```

5
vue/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.vite
dist
**/.git
**/.DS_Store

7
vue/Dockerfile Normal file
View File

@@ -0,0 +1,7 @@
FROM node:22-slim
RUN apt-get update && apt-get install -y make git && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
CMD ["sh", "-c", "npm run build -- --outDir /output --emptyOutDir"]

View File

@@ -1,6 +1,31 @@
# my-vue-app
# My Web - Frontend
This template should help get you started developing with Vue 3 in Vite.
Vue 3 SPA for [adam-french.co.uk](https://adam-french.co.uk). Built with Vite, Tailwind CSS v4, Pinia, and Vue Router.
## Setup
```sh
npm install
```
## Development
```sh
npm run dev
```
The Vite dev server proxies API requests:
- `/api` -> `http://localhost:8080` (Go backend)
- `/gitea` -> `http://localhost:3000` (Gitea)
- `/radio` -> `http://localhost:8000` (Icecast2)
## Production Build
```sh
npm run build
```
In production, the built `dist/` is served by Nginx inside a Docker container (see `../Dockerfile`).
## Recommended IDE Setup
@@ -9,30 +34,8 @@ This template should help get you started developing with Vue 3 in Vite.
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```

136
vue/crates/stp_wasm/Cargo.lock generated Normal file
View File

@@ -0,0 +1,136 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bumpalo"
version = "3.19.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "js-sys"
version = "0.3.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
dependencies = [
"once_cell",
"wasm-bindgen",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustversion"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "stp_wasm"
version = "0.1.0"
dependencies = [
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "syn"
version = "2.0.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasm-bindgen"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
dependencies = [
"unicode-ident",
]
[[package]]
name = "web-sys"
version = "0.3.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
dependencies = [
"js-sys",
"wasm-bindgen",
]

View File

@@ -0,0 +1,15 @@
[package]
name = "stp_wasm"
version = "0.1.0"
edition = "2024"
[dependencies]
js-sys = "0.3.85"
wasm-bindgen = "0.2.108"
web-sys = { version = "0.3.85", features = [
"console",
"Document",
"Element",
"Window",
"Animation",
] }

View File

@@ -0,0 +1,6 @@
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct BadApplePlayer {
is_playing: bool,
}

17
vue/index.html Normal file
View File

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

View File

@@ -1024,9 +1024,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
"integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
@@ -1037,9 +1037,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz",
"integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
@@ -1050,9 +1050,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz",
"integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
@@ -1063,9 +1063,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz",
"integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
@@ -1076,9 +1076,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz",
"integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
@@ -1089,9 +1089,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz",
"integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
@@ -1102,9 +1102,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz",
"integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
@@ -1115,9 +1115,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz",
"integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
@@ -1128,9 +1128,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz",
"integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
@@ -1141,9 +1141,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz",
"integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
@@ -1154,9 +1154,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz",
"integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
@@ -1167,9 +1167,9 @@
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz",
"integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
@@ -1180,9 +1180,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz",
"integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
@@ -1193,9 +1193,9 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz",
"integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
@@ -1206,9 +1206,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz",
"integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
@@ -1219,9 +1219,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz",
"integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
@@ -1232,9 +1232,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz",
"integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
@@ -1245,9 +1245,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz",
"integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
@@ -1258,9 +1258,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz",
"integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
@@ -1271,9 +1271,9 @@
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz",
"integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
@@ -1284,9 +1284,9 @@
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz",
"integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
@@ -1297,9 +1297,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz",
"integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
@@ -1310,9 +1310,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz",
"integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
@@ -1323,9 +1323,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz",
"integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
@@ -1336,9 +1336,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz",
"integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
@@ -2982,9 +2982,9 @@
}
},
"node_modules/markdown-it": {
"version": "14.1.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
"version": "14.1.1",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
@@ -3245,9 +3245,9 @@
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.55.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@@ -3260,31 +3260,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.55.1",
"@rollup/rollup-android-arm64": "4.55.1",
"@rollup/rollup-darwin-arm64": "4.55.1",
"@rollup/rollup-darwin-x64": "4.55.1",
"@rollup/rollup-freebsd-arm64": "4.55.1",
"@rollup/rollup-freebsd-x64": "4.55.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.55.1",
"@rollup/rollup-linux-arm-musleabihf": "4.55.1",
"@rollup/rollup-linux-arm64-gnu": "4.55.1",
"@rollup/rollup-linux-arm64-musl": "4.55.1",
"@rollup/rollup-linux-loong64-gnu": "4.55.1",
"@rollup/rollup-linux-loong64-musl": "4.55.1",
"@rollup/rollup-linux-ppc64-gnu": "4.55.1",
"@rollup/rollup-linux-ppc64-musl": "4.55.1",
"@rollup/rollup-linux-riscv64-gnu": "4.55.1",
"@rollup/rollup-linux-riscv64-musl": "4.55.1",
"@rollup/rollup-linux-s390x-gnu": "4.55.1",
"@rollup/rollup-linux-x64-gnu": "4.55.1",
"@rollup/rollup-linux-x64-musl": "4.55.1",
"@rollup/rollup-openbsd-x64": "4.55.1",
"@rollup/rollup-openharmony-arm64": "4.55.1",
"@rollup/rollup-win32-arm64-msvc": "4.55.1",
"@rollup/rollup-win32-ia32-msvc": "4.55.1",
"@rollup/rollup-win32-x64-gnu": "4.55.1",
"@rollup/rollup-win32-x64-msvc": "4.55.1",
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},

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