Compare commits
185 Commits
rowing_fro
...
74f606459f
| Author | SHA1 | Date | |
|---|---|---|---|
| 74f606459f | |||
| ce1a1ee757 | |||
| 68b9985d99 | |||
| a967a249c2 | |||
| 8a6e34dd69 | |||
| f027506c87 | |||
| 54ab64c67d | |||
| 108f58e527 | |||
| e62424368b | |||
| e17a7a9807 | |||
| 67f3895a1e | |||
| 058ae3b3f1 | |||
| 282454140f | |||
| 6029066a94 | |||
| 01adee7941 | |||
| 1f6c540c1c | |||
| fa79fe9cdb | |||
| 83c130b5c3 | |||
| b6623de23a | |||
| 7e8e50f80a | |||
| a44011bf0b | |||
| d215333128 | |||
| 179f52d1d7 | |||
| 08c29a77a0 | |||
| e69942a7e8 | |||
| d268fea4be | |||
| d7178ac60a | |||
| b48a273916 | |||
| 24fd4dd00c | |||
| 75cede3b1b | |||
| 091bfcaef6 | |||
| 24bb0195e9 | |||
| ce091d3918 | |||
| 461729809e | |||
| 89119c1702 | |||
| 0f9695b8aa | |||
| 7007f8292d | |||
| f7d69f048e | |||
| 31d4b4c268 | |||
| 7f01b1a296 | |||
| 89d3d8eefb | |||
| 31a8c93c86 | |||
| 932e257152 | |||
| 619692687f | |||
| f1750a8b3e | |||
| 3c9d19d185 | |||
| 474f14b1e5 | |||
| 7798b54391 | |||
| d4a6343d5e | |||
| 264df132df | |||
| 747563c6c9 | |||
| fabd92bf36 | |||
| ac5f47fcaa | |||
| a8ef10498e | |||
| 0f801a864c | |||
| f9a8127714 | |||
| 8f57c15c24 | |||
| b2042ffe78 | |||
| 4e7377d9f0 | |||
| 8406582b2b | |||
| 283e02657e | |||
| 7a737f6d10 | |||
| 29350af2e0 | |||
| d3d3269d49 | |||
| 2b84730126 | |||
| 8c2e9ba9a5 | |||
| 6a6b9536ba | |||
| d3e948d558 | |||
| bbb493b544 | |||
| 3afcee2011 | |||
| 7d74a2fc07 | |||
| 570a823426 | |||
| 6dddcd4d7a | |||
| 69e158b871 | |||
| d857cce5dc | |||
| c2bbd7ad88 | |||
| 8627a7945e | |||
| 08125204c5 | |||
| a0215f7810 | |||
| c1ce3c31ba | |||
| 2becda2bd8 | |||
| 7381cda7b8 | |||
| 5999eccc21 | |||
| 7155255733 | |||
| 6ff30a37f7 | |||
| a4514ad98d | |||
| 84e18dddfa | |||
| b4ddb4d402 | |||
| 0360b1f7f1 | |||
| 36817277f9 | |||
| a03ce26824 | |||
| a10706506e | |||
| f29e937307 | |||
| 81cb2bc4b5 | |||
| 8b5ed9abec | |||
| 8cdab593ae | |||
| b63cc911a7 | |||
| e1fe281586 | |||
| 887d23af5b | |||
| 36aa7ed907 | |||
| d5065d19e0 | |||
| 15c721ea56 | |||
| b47d1a3df3 | |||
| 5b3cd267b6 | |||
| 6033a952af | |||
| 0ad7f4e009 | |||
| 6bf773487a | |||
| 2916afe206 | |||
| 17deec23ba | |||
| ad4d02228d | |||
| d5fbc0ee74 | |||
| 857f66cb37 | |||
| 5b041d7364 | |||
| 4be7e60394 | |||
| 27f74f6c2a | |||
| 5a19f09e17 | |||
| 469a225860 | |||
| cd1bcc7f39 | |||
| 14cacec1f5 | |||
| 7991c80176 | |||
| bad44a6ddd | |||
| 0b256863d6 | |||
| cb326ff8bf | |||
| 78d6c3d4f0 | |||
| c7dbf5b778 | |||
| a8d1b879be | |||
| f82389225c | |||
| 165852e738 | |||
| c58c19cc1e | |||
| 26ea0108e0 | |||
| 604576b46a | |||
| 33d72fd20a | |||
| d3cbc687d5 | |||
| d7b76e4742 | |||
| 64c2ba5562 | |||
| 6796367dbe | |||
| c2580c984d | |||
| 68db930049 | |||
| 63da086da2 | |||
| 6326a438dc | |||
| 7c980f1b1f | |||
| 141ceab7e6 | |||
| d03f9668ad | |||
| 41d6cf0dac | |||
| 1e3c6adf5e | |||
| 99ddd7d494 | |||
| 8e50537333 | |||
| 85a2325683 | |||
| 0a8a752433 | |||
| 4c396ef30f | |||
| 77e2c272cb | |||
| 1578a05762 | |||
| a6bc1d5126 | |||
| 2737b4f0d0 | |||
| 9fa953c969 | |||
| 5a45f1f427 | |||
| 4458844029 | |||
| 3200ef5bee | |||
| 0da6d3f0ed | |||
| 88ce32abeb | |||
| adcf1bda48 | |||
| 7450b5a624 | |||
| ab2b0a1e3d | |||
| ff82b8bdf9 | |||
| 1429a6a5cb | |||
| 7a71484ecc | |||
| e1563b55f4 | |||
| 4fbeabc3ae | |||
| a83b98eb2b | |||
| 5346b24999 | |||
| 3779a1cbcc | |||
| 3f39f6327c | |||
| 9dc9a3a063 | |||
| a6b543cf65 | |||
| 4a65836210 | |||
| 95635c86b3 | |||
| 3056b23b50 | |||
| 72013f5cdd | |||
| 7aa62659e5 | |||
| aa3f0a189d | |||
| 646f93136d | |||
| 54852eba82 | |||
| e43c07b30a | |||
| 190bc6076b | |||
| 88884121ab |
@@ -10,10 +10,15 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Pull changes
|
- name: Pull changes
|
||||||
working-directory: /home/adamf/deploy/web_server
|
working-directory: /home/adamf/deploy/web_server
|
||||||
run: git pull gitea main
|
run: |
|
||||||
|
git config --global --add safe.directory /home/adamf/deploy/web_server
|
||||||
|
git pull http://gitea:3000/adamf/web_server.git main
|
||||||
|
|
||||||
- name: Run docker compose up
|
- name: Run docker compose up
|
||||||
working-directory: /home/adamf/deploy/web_server
|
working-directory: /home/adamf/deploy/web_server
|
||||||
env:
|
env:
|
||||||
DOCKER_API_VERSION: "1.41"
|
DOCKER_API_VERSION: "1.41"
|
||||||
run: docker compose up -d --build --remove-orphans
|
run: docker compose up -d --build --remove-orphans
|
||||||
|
|
||||||
|
- name: Prune unused Docker resources
|
||||||
|
run: docker image prune -f
|
||||||
|
|||||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -1,13 +1,18 @@
|
|||||||
|
icecast2/fallback_music/*
|
||||||
|
!icecast2/fallback_music/.gitkeep
|
||||||
|
searxng/settings.yml
|
||||||
certbot/conf
|
certbot/conf
|
||||||
certbot/www
|
certbot/www
|
||||||
backend/token/
|
backend/token/
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
gitea/config/app.ini
|
||||||
gitea/data/*
|
gitea/data/*
|
||||||
|
|
||||||
gitea-runner/data/*
|
gitea-runner/data/*
|
||||||
|
|
||||||
# Will add in future (webpack)
|
# Rust build artifacts
|
||||||
nginx/vue/crates/
|
**/target/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
@@ -47,5 +52,6 @@ coverage
|
|||||||
# Vitest
|
# Vitest
|
||||||
__screenshots__/
|
__screenshots__/
|
||||||
|
|
||||||
|
|
||||||
.deploy
|
.deploy
|
||||||
*.xcf
|
*.xcf
|
||||||
|
|||||||
70
CLAUDE.md
Normal file
70
CLAUDE.md
Normal 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).
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.24
|
FROM golang:1.25
|
||||||
|
|
||||||
WORKDIR /backend
|
WORKDIR /backend
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
module adam-french.co.uk/backend
|
module adam-french.co.uk/backend
|
||||||
|
|
||||||
go 1.24.0
|
go 1.25
|
||||||
|
|
||||||
require (
|
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/gin-gonic/gin v1.11.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.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
|
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
|
golang.org/x/oauth2 v0.30.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
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 v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
@@ -22,10 +27,11 @@ require (
|
|||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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-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-json v0.10.2 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.6.0 // 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/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.54.0 // 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/gjson v1.18.0 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
@@ -50,12 +56,11 @@ require (
|
|||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/mod v0.29.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/net v0.46.0 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.37.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.30.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/tools v0.38.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
google.golang.org/appengine v1.6.7 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
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=
|
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/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/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 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
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 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
@@ -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/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 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
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 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
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.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
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 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
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=
|
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.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
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.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.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 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/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
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-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/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/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.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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.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 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/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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
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/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 h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
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.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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
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/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 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
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.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.27/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=
|
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.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.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.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
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-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-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
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.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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
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-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-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
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.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
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-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-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-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-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-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.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 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
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-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.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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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-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-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
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.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.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.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
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-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-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/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.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.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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
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-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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/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.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
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.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/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-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
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-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.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
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.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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 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/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/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.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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
59
backend/gqlgen.yml
Normal file
59
backend/gqlgen.yml
Normal 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
|
||||||
22
backend/graph/activity.resolvers.go
Normal file
22
backend/graph/activity.resolvers.go
Normal 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
52
backend/graph/context.go
Normal 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
|
||||||
|
}
|
||||||
22
backend/graph/favorite.resolvers.go
Normal file
22
backend/graph/favorite.resolvers.go
Normal 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
8489
backend/graph/generated.go
Normal file
File diff suppressed because it is too large
Load Diff
17
backend/graph/gitea_helpers.go
Normal file
17
backend/graph/gitea_helpers.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
27
backend/graph/message.resolvers.go
Normal file
27
backend/graph/message.resolvers.go
Normal 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 }
|
||||||
25
backend/graph/middleware.go
Normal file
25
backend/graph/middleware.go
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
102
backend/graph/model/models_gen.go
Normal file
102
backend/graph/model/models_gen.go
Normal 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"`
|
||||||
|
}
|
||||||
22
backend/graph/post.resolvers.go
Normal file
22
backend/graph/post.resolvers.go
Normal 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
12
backend/graph/resolver.go
Normal 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
|
||||||
|
}
|
||||||
32
backend/graph/rowing.resolvers.go
Normal file
32
backend/graph/rowing.resolvers.go
Normal 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 }
|
||||||
514
backend/graph/schema.resolvers.go
Normal file
514
backend/graph/schema.resolvers.go
Normal 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 }
|
||||||
14
backend/graph/schema/activity.graphql
Normal file
14
backend/graph/schema/activity.graphql
Normal 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
|
||||||
|
}
|
||||||
8
backend/graph/schema/auth.graphql
Normal file
8
backend/graph/schema/auth.graphql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
input LoginInput {
|
||||||
|
username: String!
|
||||||
|
password: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthPayload {
|
||||||
|
user: User!
|
||||||
|
}
|
||||||
14
backend/graph/schema/favorite.graphql
Normal file
14
backend/graph/schema/favorite.graphql
Normal 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
|
||||||
|
}
|
||||||
8
backend/graph/schema/gitea.graphql
Normal file
8
backend/graph/schema/gitea.graphql
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
type GiteaFeedItem {
|
||||||
|
avatarUrl: String!
|
||||||
|
repoUrl: String!
|
||||||
|
repoName: String!
|
||||||
|
opType: String!
|
||||||
|
commitMessage: String!
|
||||||
|
createdAt: Time!
|
||||||
|
}
|
||||||
7
backend/graph/schema/message.graphql
Normal file
7
backend/graph/schema/message.graphql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
type Message {
|
||||||
|
id: ID!
|
||||||
|
content: String!
|
||||||
|
authorId: Int!
|
||||||
|
fileUrl: String
|
||||||
|
createdAt: Time!
|
||||||
|
}
|
||||||
18
backend/graph/schema/post.graphql
Normal file
18
backend/graph/schema/post.graphql
Normal 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!
|
||||||
|
}
|
||||||
9
backend/graph/schema/rowing.graphql
Normal file
9
backend/graph/schema/rowing.graphql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
type Rowing {
|
||||||
|
id: ID!
|
||||||
|
createdAt: Time!
|
||||||
|
date: Time!
|
||||||
|
time: Int!
|
||||||
|
distance: Int!
|
||||||
|
timePer500m: Float!
|
||||||
|
calories: Float!
|
||||||
|
}
|
||||||
31
backend/graph/schema/schema.graphql
Normal file
31
backend/graph/schema/schema.graphql
Normal 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!
|
||||||
|
}
|
||||||
28
backend/graph/schema/spotify.graphql
Normal file
28
backend/graph/schema/spotify.graphql
Normal 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!
|
||||||
|
}
|
||||||
12
backend/graph/schema/steam.graphql
Normal file
12
backend/graph/schema/steam.graphql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
type SteamGame {
|
||||||
|
appId: Int!
|
||||||
|
name: String!
|
||||||
|
playtime2Weeks: Int!
|
||||||
|
playtimeForever: Int!
|
||||||
|
headerImageUrl: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type SteamStatus {
|
||||||
|
online: Boolean!
|
||||||
|
recentGames: [SteamGame!]!
|
||||||
|
}
|
||||||
12
backend/graph/schema/user.graphql
Normal file
12
backend/graph/schema/user.graphql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
type User {
|
||||||
|
id: ID!
|
||||||
|
createdAt: Time!
|
||||||
|
updatedAt: Time!
|
||||||
|
username: String!
|
||||||
|
admin: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateUserInput {
|
||||||
|
username: String!
|
||||||
|
password: String!
|
||||||
|
}
|
||||||
51
backend/graph/spotify_helpers.go
Normal file
51
backend/graph/spotify_helpers.go
Normal 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
|
||||||
|
}
|
||||||
22
backend/graph/steam_helpers.go
Normal file
22
backend/graph/steam_helpers.go
Normal 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
|
||||||
|
}
|
||||||
22
backend/graph/user.resolvers.go
Normal file
22
backend/graph/user.resolvers.go
Normal 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 }
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"adam-french.co.uk/backend/models"
|
"adam-french.co.uk/backend/models"
|
||||||
@@ -16,7 +17,8 @@ type CreateActivityInput struct {
|
|||||||
func (store *Store) GetActivity(ctx *gin.Context) {
|
func (store *Store) GetActivity(ctx *gin.Context) {
|
||||||
var activitys []models.Activity
|
var activitys []models.Activity
|
||||||
if err := store.DB.Order("Created_At DESC").Find(&activitys).Error; err != nil {
|
if err := store.DB.Order("Created_At DESC").Find(&activitys).Error; err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, err.Error())
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.JSON(http.StatusOK, activitys)
|
ctx.JSON(http.StatusOK, activitys)
|
||||||
@@ -25,14 +27,15 @@ func (store *Store) GetActivity(ctx *gin.Context) {
|
|||||||
func (store *Store) CreateActivity(ctx *gin.Context) {
|
func (store *Store) CreateActivity(ctx *gin.Context) {
|
||||||
var input CreateActivityInput
|
var input CreateActivityInput
|
||||||
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
||||||
ctx.JSON(http.StatusBadRequest, err.Error())
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
activity := models.Activity{Type: input.Type, Name: input.Name, Link: input.Link}
|
activity := models.Activity{Type: input.Type, Name: input.Name, Link: input.Link}
|
||||||
tx := store.DB.Create(&activity)
|
tx := store.DB.Create(&activity)
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
|
log.Println(tx.Error)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,25 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"adam-french.co.uk/backend/models"
|
"adam-french.co.uk/backend/models"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (store *Store) AuthMiddlewear(ctx *gin.Context) {
|
func (store *Store) AuthMiddlewear(ctx *gin.Context) {
|
||||||
access_token, err := ctx.Cookie("access_token")
|
access_token, err := ctx.Cookie("access_token")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.AbortWithStatusJSON(401, err.Error())
|
ctx.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, err := store.Auth.VerifyJWT(access_token)
|
claims, err := store.Auth.VerifyJWT(access_token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.AbortWithStatusJSON(401, err.Error())
|
ctx.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +28,28 @@ func (store *Store) AuthMiddlewear(ctx *gin.Context) {
|
|||||||
ctx.Next()
|
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) {
|
func (store *Store) CheckToken(ctx *gin.Context) {
|
||||||
access_token, err := ctx.Cookie("access_token")
|
access_token, err := ctx.Cookie("access_token")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -36,13 +59,13 @@ func (store *Store) CheckToken(ctx *gin.Context) {
|
|||||||
|
|
||||||
claims, err := store.Auth.VerifyJWT(access_token)
|
claims, err := store.Auth.VerifyJWT(access_token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(401, err.Error())
|
ctx.JSON(401, gin.H{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userIDF, ok := (*claims)["id"].(float64)
|
userIDF, ok := (*claims)["id"].(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
ctx.JSON(401, gin.H{"error": "claims does not contain id"})
|
ctx.JSON(401, gin.H{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID := uint(userIDF)
|
userID := uint(userIDF)
|
||||||
@@ -50,8 +73,9 @@ func (store *Store) CheckToken(ctx *gin.Context) {
|
|||||||
user := models.User{ID: userID}
|
user := models.User{ID: userID}
|
||||||
tx := store.DB.First(&user)
|
tx := store.DB.First(&user)
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
ctx.JSON(http.StatusNotFound, tx.Error.Error())
|
log.Println(tx.Error)
|
||||||
removeCookies(ctx)
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
store.removeCookies(ctx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,17 +85,16 @@ func (store *Store) CheckToken(ctx *gin.Context) {
|
|||||||
func (store *Store) RefreshToken(ctx *gin.Context) {
|
func (store *Store) RefreshToken(ctx *gin.Context) {
|
||||||
refreshToken, err := ctx.Cookie("refresh_token")
|
refreshToken, err := ctx.Cookie("refresh_token")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusUnauthorized, err.Error())
|
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, err := store.Auth.VerifyJWT(refreshToken)
|
claims, err := store.Auth.VerifyJWT(refreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusUnauthorized, err.Error())
|
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("claims: %v\n", claims)
|
|
||||||
|
|
||||||
userIDF, ok := (*claims)["id"].(float64)
|
userIDF, ok := (*claims)["id"].(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid token claims"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid token claims"})
|
||||||
@@ -82,17 +105,20 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
|
|||||||
user := models.User{ID: userID}
|
user := models.User{ID: userID}
|
||||||
tx := store.DB.First(&user)
|
tx := store.DB.First(&user)
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
ctx.JSON(http.StatusNotFound, tx.Error.Error())
|
log.Println(tx.Error)
|
||||||
removeCookies(ctx)
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
store.removeCookies(ctx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens, err := store.Auth.GenerateJWT(&user)
|
tokens, err := store.Auth.GenerateJWT(&user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, err.Error())
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
ctx.SetCookie(
|
ctx.SetCookie(
|
||||||
"access_token",
|
"access_token",
|
||||||
tokens.AccessToken,
|
tokens.AccessToken,
|
||||||
@@ -116,27 +142,29 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
|
|||||||
func (store *Store) Login(ctx *gin.Context) {
|
func (store *Store) Login(ctx *gin.Context) {
|
||||||
var input UserCredentials
|
var input UserCredentials
|
||||||
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
||||||
ctx.JSON(http.StatusBadRequest, err.Error())
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := models.User{}
|
user := models.User{}
|
||||||
if err := store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens, err := store.Auth.GenerateJWT(&user)
|
tokens, err := store.Auth.GenerateJWT(&user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, err.Error())
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
ctx.SetCookie(
|
ctx.SetCookie(
|
||||||
"access_token",
|
"access_token",
|
||||||
tokens.AccessToken,
|
tokens.AccessToken,
|
||||||
@@ -158,26 +186,27 @@ func (store *Store) Login(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) Logout(ctx *gin.Context) {
|
func (store *Store) Logout(ctx *gin.Context) {
|
||||||
removeCookies(ctx)
|
store.removeCookies(ctx)
|
||||||
|
|
||||||
ctx.Status(http.StatusOK)
|
ctx.Status(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeCookies(ctx *gin.Context) {
|
func (store *Store) removeCookies(ctx *gin.Context) {
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
ctx.SetCookie(
|
ctx.SetCookie(
|
||||||
"access_token",
|
"access_token",
|
||||||
"",
|
"",
|
||||||
-1,
|
-1,
|
||||||
"",
|
store.Auth.Config.Endpoint,
|
||||||
"",
|
store.Auth.Config.Domain,
|
||||||
true, true,
|
true, true,
|
||||||
)
|
)
|
||||||
ctx.SetCookie(
|
ctx.SetCookie(
|
||||||
"refresh_token",
|
"refresh_token",
|
||||||
"",
|
"",
|
||||||
-1,
|
-1,
|
||||||
"",
|
store.Auth.Config.Endpoint,
|
||||||
"",
|
store.Auth.Config.Domain,
|
||||||
true, true,
|
true, true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"adam-french.co.uk/backend/models"
|
"adam-french.co.uk/backend/models"
|
||||||
@@ -16,7 +17,8 @@ type CreateFavoriteInput struct {
|
|||||||
func (store *Store) GetFavorites(ctx *gin.Context) {
|
func (store *Store) GetFavorites(ctx *gin.Context) {
|
||||||
var favorites []models.Favorite
|
var favorites []models.Favorite
|
||||||
if err := store.DB.Order("Created_At DESC").Find(&favorites).Error; err != nil {
|
if err := store.DB.Order("Created_At DESC").Find(&favorites).Error; err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, err.Error())
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.JSON(http.StatusOK, favorites)
|
ctx.JSON(http.StatusOK, favorites)
|
||||||
@@ -25,14 +27,15 @@ func (store *Store) GetFavorites(ctx *gin.Context) {
|
|||||||
func (store *Store) CreateFavorite(ctx *gin.Context) {
|
func (store *Store) CreateFavorite(ctx *gin.Context) {
|
||||||
var input CreateFavoriteInput
|
var input CreateFavoriteInput
|
||||||
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
||||||
ctx.JSON(http.StatusBadRequest, err.Error())
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
favorite := models.Favorite{Type: input.Type, Name: input.Name, Link: input.Link}
|
favorite := models.Favorite{Type: input.Type, Name: input.Name, Link: input.Link}
|
||||||
tx := store.DB.Create(&favorite)
|
tx := store.DB.Create(&favorite)
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
|
log.Println(tx.Error)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
97
backend/handlers/handle_message_upload.go
Normal file
97
backend/handlers/handle_message_upload.go
Normal 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})
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -17,7 +18,8 @@ type CreatePostInput struct {
|
|||||||
func (store *Store) GetPosts(ctx *gin.Context) {
|
func (store *Store) GetPosts(ctx *gin.Context) {
|
||||||
var posts []models.Post
|
var posts []models.Post
|
||||||
if err := store.DB.Preload("Author").Order("Created_At DESC").Find(&posts).Error; err != nil {
|
if err := store.DB.Preload("Author").Order("Created_At DESC").Find(&posts).Error; err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, err.Error())
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.JSON(http.StatusOK, posts)
|
ctx.JSON(http.StatusOK, posts)
|
||||||
@@ -34,7 +36,8 @@ func (store *Store) GetPost(ctx *gin.Context) {
|
|||||||
|
|
||||||
post := models.Post{ID: uint(postID)}
|
post := models.Post{ID: uint(postID)}
|
||||||
if err := store.DB.First(&post).Error; err != nil {
|
if err := store.DB.First(&post).Error; err != nil {
|
||||||
ctx.JSON(http.StatusNotFound, err.Error())
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,39 +47,35 @@ func (store *Store) GetPost(ctx *gin.Context) {
|
|||||||
func (store *Store) CreatePost(ctx *gin.Context) {
|
func (store *Store) CreatePost(ctx *gin.Context) {
|
||||||
var input CreatePostInput
|
var input CreatePostInput
|
||||||
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
||||||
ctx.JSON(http.StatusBadRequest, err.Error())
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claimsVal, ok := ctx.Get("userClaims")
|
claimsVal, ok := ctx.Get("userClaims")
|
||||||
if !ok {
|
if !ok {
|
||||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"})
|
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, ok := claimsVal.(*jwt.MapClaims)
|
claims, ok := claimsVal.(*jwt.MapClaims)
|
||||||
if !ok {
|
if !ok {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userIDF, ok := (*claims)["id"].(float64)
|
userIDF, ok := (*claims)["id"].(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID := uint(userIDF)
|
userID := uint(userIDF)
|
||||||
|
|
||||||
if !(*claims)["admin"].(bool) {
|
|
||||||
ctx.JSON(http.StatusForbidden, gin.H{"error": "you are not admin :("})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create post
|
// Create post
|
||||||
post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID}
|
post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID}
|
||||||
tx := store.DB.Create(&post)
|
tx := store.DB.Create(&post)
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
|
log.Println(tx.Error)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,36 +86,38 @@ func (store *Store) UpdatePost(ctx *gin.Context) {
|
|||||||
postID := ctx.Param("id")
|
postID := ctx.Param("id")
|
||||||
var post models.Post
|
var post models.Post
|
||||||
if err := store.DB.First(&post, postID).Error; err != nil {
|
if err := store.DB.First(&post, postID).Error; err != nil {
|
||||||
ctx.JSON(http.StatusNotFound, err.Error())
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claimsVal, ok := ctx.Get("userClaims")
|
claimsVal, ok := ctx.Get("userClaims")
|
||||||
if !ok {
|
if !ok {
|
||||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"})
|
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, ok := claimsVal.(*jwt.MapClaims)
|
claims, ok := claimsVal.(*jwt.MapClaims)
|
||||||
if !ok {
|
if !ok {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userIDF, ok := (*claims)["id"].(float64)
|
userIDF, ok := (*claims)["id"].(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID := uint(userIDF)
|
userID := uint(userIDF)
|
||||||
|
|
||||||
if !(userID == post.AuthorID) {
|
if !(userID == post.AuthorID) {
|
||||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user and post author id missmatch"})
|
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var input CreatePostInput
|
var input CreatePostInput
|
||||||
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
||||||
ctx.JSON(http.StatusBadRequest, err.Error())
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +125,8 @@ func (store *Store) UpdatePost(ctx *gin.Context) {
|
|||||||
post.Content = input.Content
|
post.Content = input.Content
|
||||||
tx := store.DB.Save(&post)
|
tx := store.DB.Save(&post)
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
|
log.Println(tx.Error)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,31 +137,33 @@ func (store *Store) DeletePost(ctx *gin.Context) {
|
|||||||
postID := ctx.Param("id")
|
postID := ctx.Param("id")
|
||||||
var post models.Post
|
var post models.Post
|
||||||
if err := store.DB.First(&post, postID).Error; err != nil {
|
if err := store.DB.First(&post, postID).Error; err != nil {
|
||||||
ctx.JSON(http.StatusNotFound, err.Error())
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claimsVal, ok := ctx.Get("userClaims")
|
claimsVal, ok := ctx.Get("userClaims")
|
||||||
if !ok {
|
if !ok {
|
||||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"})
|
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, ok := claimsVal.(*jwt.MapClaims)
|
claims, ok := claimsVal.(*jwt.MapClaims)
|
||||||
if !ok {
|
if !ok {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userIDF, ok := (*claims)["id"].(float64)
|
userIDF, ok := (*claims)["id"].(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID := uint(userIDF)
|
userID := uint(userIDF)
|
||||||
|
|
||||||
if !(userID == post.AuthorID) {
|
if !(userID == post.AuthorID) {
|
||||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user and post author id missmatch"})
|
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
store.DB.Delete(&post)
|
store.DB.Delete(&post)
|
||||||
|
|||||||
178
backend/handlers/handle_radio.go
Normal file
178
backend/handlers/handle_radio.go
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fallbackMusicDir = "/backend/fallback_music"
|
||||||
|
|
||||||
|
var allowedAudioExtensions = map[string]bool{
|
||||||
|
".mp3": true, ".ogg": true, ".flac": true, ".wav": true, ".m4a": true, ".opus": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) UploadRadioSong(ctx *gin.Context) {
|
||||||
|
file, err := ctx.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSize = 50 << 20 // 50MB
|
||||||
|
if file.Size > maxSize {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file too large (max 50MB)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||||
|
if !allowedAudioExtensions[ext] {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file type not allowed (accepted: .mp3, .ogg, .flac, .wav, .m4a, .opus)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := filepath.Base(file.Filename)
|
||||||
|
dest := filepath.Join(fallbackMusicDir, filename)
|
||||||
|
|
||||||
|
if _, err := os.Stat(dest); err == nil {
|
||||||
|
ctx.JSON(http.StatusConflict, gin.H{"error": "file already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.SaveUploadedFile(file, dest); err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"name": filename})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) ListRadioSongs(ctx *gin.Context) {
|
||||||
|
type songInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Modified int64 `json:"modified"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
songs := []songInfo{}
|
||||||
|
|
||||||
|
// Read enabled songs
|
||||||
|
entries, err := os.ReadDir(fallbackMusicDir)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read music directory"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
songs = append(songs, songInfo{
|
||||||
|
Name: entry.Name(),
|
||||||
|
Size: info.Size(),
|
||||||
|
Modified: info.ModTime().Unix(),
|
||||||
|
Disabled: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read disabled songs
|
||||||
|
disabledDir := filepath.Join(fallbackMusicDir, "disabled")
|
||||||
|
disabledEntries, err := os.ReadDir(disabledDir)
|
||||||
|
if err == nil {
|
||||||
|
for _, entry := range disabledEntries {
|
||||||
|
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
songs = append(songs, songInfo{
|
||||||
|
Name: entry.Name(),
|
||||||
|
Size: info.Size(),
|
||||||
|
Modified: info.ModTime().Unix(),
|
||||||
|
Disabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"songs": songs})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) DisableRadioSong(ctx *gin.Context) {
|
||||||
|
filename := filepath.Base(ctx.Param("filename"))
|
||||||
|
if filename == "." || filename == "/" {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
src := filepath.Join(fallbackMusicDir, filename)
|
||||||
|
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||||
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
disabledDir := filepath.Join(fallbackMusicDir, "disabled")
|
||||||
|
if err := os.MkdirAll(disabledDir, 0o755); err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create disabled directory"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := filepath.Join(disabledDir, filename)
|
||||||
|
if err := os.Rename(src, dst); err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to disable song"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"disabled": filename})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) EnableRadioSong(ctx *gin.Context) {
|
||||||
|
filename := filepath.Base(ctx.Param("filename"))
|
||||||
|
if filename == "." || filename == "/" {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
src := filepath.Join(fallbackMusicDir, "disabled", filename)
|
||||||
|
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||||
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "file not found in disabled directory"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := filepath.Join(fallbackMusicDir, filename)
|
||||||
|
if err := os.Rename(src, dst); err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to enable song"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"enabled": filename})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) DeleteRadioSong(ctx *gin.Context) {
|
||||||
|
filename := filepath.Base(ctx.Param("filename"))
|
||||||
|
if filename == "." || filename == "/" {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(fallbackMusicDir, filename)
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"deleted": filename})
|
||||||
|
}
|
||||||
@@ -5,8 +5,9 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"strings"
|
||||||
|
|
||||||
"github.com/rwcarlsen/goexif/exif"
|
"github.com/rwcarlsen/goexif/exif"
|
||||||
|
|
||||||
@@ -16,15 +17,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ExtractedRowingData struct {
|
type ExtractedRowingData struct {
|
||||||
TimeMinutes float64 `json:"timeMinutes"`
|
TimeMinutes uint64 `json:"timeMinutes"`
|
||||||
TimeSeconds float64 `json:"timeSeconds"`
|
TimeSeconds uint64 `json:"timeSeconds"`
|
||||||
Distance float64 `json:"distance"`
|
Distance uint64 `json:"distance"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) GetRowing(ctx *gin.Context) {
|
func (store *Store) GetRowing(ctx *gin.Context) {
|
||||||
var rowing []models.Rowing
|
var rowing []models.Rowing
|
||||||
if err := store.DB.Order("Created_At DESC").Find(&rowing).Error; err != nil {
|
if err := store.DB.Order("Created_At DESC").Find(&rowing).Error; err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, err.Error())
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.JSON(http.StatusOK, rowing)
|
ctx.JSON(http.StatusOK, rowing)
|
||||||
@@ -82,6 +84,13 @@ func (store *Store) CreateRowing(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
encoded := base64.StdEncoding.EncodeToString(data)
|
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
|
// Build the message with an image + text prompt
|
||||||
message, err := store.ClaudeClient.Messages.New(context.Background(), anthropic.MessageNewParams{
|
message, err := store.ClaudeClient.Messages.New(context.Background(), anthropic.MessageNewParams{
|
||||||
Model: anthropic.ModelClaudeHaiku4_5,
|
Model: anthropic.ModelClaudeHaiku4_5,
|
||||||
@@ -110,21 +119,30 @@ No text, no markdown, no explanation. Just the JSON object.`),
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to analyze image"})
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process image"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(message.Content) == 0 {
|
if len(message.Content) == 0 {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "empty response from Claude"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "empty response from image processor"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
extractedData := ExtractedRowingData{}
|
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 {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse JSON response"})
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse image data"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,15 +152,39 @@ No text, no markdown, no explanation. Just the JSON object.`),
|
|||||||
}
|
}
|
||||||
|
|
||||||
totalSeconds := extractedData.TimeMinutes*60 + extractedData.TimeSeconds
|
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{
|
rowing := models.Rowing{
|
||||||
Date: dateTaken,
|
Date: dateTaken,
|
||||||
Time: totalDuration,
|
Time: totalSeconds,
|
||||||
TimePer500m: per500m,
|
TimePer500m: per500m,
|
||||||
Distance: extractedData.Distance,
|
Distance: extractedData.Distance,
|
||||||
Calories: extractedData.Distance / 7500.0 * 500.0,
|
Calories: calories,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.DB.Create(&rowing).Error; err != nil {
|
if err := store.DB.Create(&rowing).Error; err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,11 +18,16 @@ func (store *Store) CompleteSpotifyAuth(ctx *gin.Context) {
|
|||||||
|
|
||||||
token, err := store.SpotifyAuth.Token(c, state, ctx.Request)
|
token, err := store.SpotifyAuth.Token(c, state, ctx.Request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.String(http.StatusInternalServerError, "Couldn't get token: %v", err)
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "authentication failed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
services.SaveSpotifyToken(services.SPOTIFY_TOKEN_JSON_PATH, token)
|
if err := services.SaveSpotifyToken(services.SPOTIFY_TOKEN_JSON_PATH, token); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
client := spotify.New(store.SpotifyAuth.Client(c, token))
|
client := spotify.New(store.SpotifyAuth.Client(c, token))
|
||||||
|
|
||||||
@@ -29,9 +35,6 @@ func (store *Store) CompleteSpotifyAuth(ctx *gin.Context) {
|
|||||||
|
|
||||||
ctx.JSON(http.StatusOK, gin.H{
|
ctx.JSON(http.StatusOK, gin.H{
|
||||||
"message": "Authentication successful",
|
"message": "Authentication successful",
|
||||||
"token": token.AccessToken,
|
|
||||||
"type": token.TokenType,
|
|
||||||
"expiry": token.Expiry,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +48,8 @@ func (store *Store) ListeningTo(ctx *gin.Context) {
|
|||||||
|
|
||||||
playing, err := store.SpotifyClient.PlayerCurrentlyPlaying(c)
|
playing, err := store.SpotifyClient.PlayerCurrentlyPlaying(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(500, gin.H{"error": err.Error()})
|
log.Println(err)
|
||||||
|
ctx.JSON(500, gin.H{"error": "failed to fetch currently playing"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,15 +57,22 @@ func (store *Store) ListeningTo(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) RecentlyPlayed(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}
|
opts := spotify.RecentlyPlayedOptions{Limit: 3}
|
||||||
|
|
||||||
if store.RecentSongsFresh() {
|
if store.RecentSongsFresh() {
|
||||||
ctx.JSON(200, *store.RecentSongs)
|
ctx.JSON(200, *store.RecentSongs)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
played, err := store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts)
|
played, err := store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(500, gin.H{"error": err.Error()})
|
log.Println(err)
|
||||||
|
ctx.JSON(500, gin.H{"error": "failed to fetch recently played"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ type UserCredentials struct {
|
|||||||
Password string `json:"password" binding:"required"`
|
Password string `json:"password" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SetAdminInput struct {
|
||||||
|
Admin *bool `json:"admin" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
func (store *Store) CreateUser(ctx *gin.Context) {
|
func (store *Store) CreateUser(ctx *gin.Context) {
|
||||||
var input UserCredentials
|
var input UserCredentials
|
||||||
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
||||||
@@ -31,32 +35,9 @@ func (store *Store) CreateUser(ctx *gin.Context) {
|
|||||||
tx := store.DB.Create(&user)
|
tx := store.DB.Create(&user)
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
|
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
|
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)
|
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"})
|
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) {
|
func (store *Store) DeleteUser(ctx *gin.Context) {
|
||||||
claimsVal, ok := ctx.Get("userClaims")
|
claimsVal, ok := ctx.Get("userClaims")
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -141,6 +168,7 @@ func (store *Store) DeleteUser(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
ctx.SetCookie(
|
ctx.SetCookie(
|
||||||
"access_token",
|
"access_token",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
func (store *Store) ConnectWebSocket(ctx *gin.Context) {
|
func (store *Store) ConnectWebSocket(ctx *gin.Context) {
|
||||||
conn, err := services.Upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
|
conn, err := services.Upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(500, gin.H{"error": err.Error()})
|
// Upgrader already wrote the HTTP error response, so just return
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,4 +20,29 @@ type Store struct {
|
|||||||
|
|
||||||
RecentSongs *[]spotify.RecentlyPlayedItem
|
RecentSongs *[]spotify.RecentlyPlayedItem
|
||||||
RecentSongsFetchedAt time.Time
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,15 +7,22 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/99designs/gqlgen/graphql/handler"
|
||||||
|
"github.com/99designs/gqlgen/graphql/handler/extension"
|
||||||
|
"github.com/99designs/gqlgen/graphql/handler/lru"
|
||||||
|
"github.com/99designs/gqlgen/graphql/handler/transport"
|
||||||
|
"github.com/99designs/gqlgen/graphql/playground"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/vektah/gqlparser/v2/ast"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/graph"
|
||||||
"adam-french.co.uk/backend/handlers"
|
"adam-french.co.uk/backend/handlers"
|
||||||
"adam-french.co.uk/backend/services"
|
"adam-french.co.uk/backend/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
logsDir := "/backend/logs"
|
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 {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -38,6 +45,11 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if os.Getenv("SEED_DB") == "true" {
|
||||||
|
services.SeedDatabase(db)
|
||||||
|
}
|
||||||
|
domainName := os.Getenv("DOMAIN")
|
||||||
|
services.InitWebSocket(db, domainName)
|
||||||
|
|
||||||
// SPOTIFY
|
// SPOTIFY
|
||||||
spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE")
|
spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE")
|
||||||
@@ -53,7 +65,6 @@ func main() {
|
|||||||
claudeClient := services.InitClaude(&claudeConfig)
|
claudeClient := services.InitClaude(&claudeConfig)
|
||||||
|
|
||||||
authSecret := os.Getenv("BACKEND_SECRET")
|
authSecret := os.Getenv("BACKEND_SECRET")
|
||||||
domainName := os.Getenv("DOMAIN")
|
|
||||||
backendEndpoint := os.Getenv("BACKEND_ENDPOINT")
|
backendEndpoint := os.Getenv("BACKEND_ENDPOINT")
|
||||||
accessTokenLifetime := 24 * time.Hour
|
accessTokenLifetime := 24 * time.Hour
|
||||||
refreshTokenLifetime := 365 * 24 * time.Hour
|
refreshTokenLifetime := 365 * 24 * time.Hour
|
||||||
@@ -64,35 +75,43 @@ func main() {
|
|||||||
notesConfig := services.NotesConfig{Dir: notesDir}
|
notesConfig := services.NotesConfig{Dir: notesDir}
|
||||||
notes := services.InitNotes(¬esConfig)
|
notes := services.InitNotes(¬esConfig)
|
||||||
|
|
||||||
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)
|
protected := r.Group("/", store.AuthMiddlewear)
|
||||||
|
admin := r.Group("/", store.AuthMiddlewear, store.AdminMiddleware)
|
||||||
|
|
||||||
// FAVORITES
|
// FAVORITES
|
||||||
r.GET("/favorites", store.GetFavorites)
|
r.GET("/favorites", store.GetFavorites)
|
||||||
protected.POST("/favorites", store.CreateFavorite)
|
admin.POST("/favorites", store.CreateFavorite)
|
||||||
|
|
||||||
// ROWING
|
// ROWING
|
||||||
r.GET("/rowing", store.GetRowing)
|
r.GET("/rowing", store.GetRowing)
|
||||||
protected.POST("/rowing", store.CreateRowing)
|
admin.POST("/rowing", store.CreateRowing)
|
||||||
|
|
||||||
// ACTIVITIES
|
// ACTIVITIES
|
||||||
r.GET("/activity", store.GetActivity)
|
r.GET("/activity", store.GetActivity)
|
||||||
protected.POST("/activity", store.CreateActivity)
|
admin.POST("/activity", store.CreateActivity)
|
||||||
|
|
||||||
// POSTS
|
// POSTS
|
||||||
r.GET("/posts", store.GetPosts)
|
r.GET("/posts", store.GetPosts)
|
||||||
protected.POST("/posts", store.CreatePost)
|
admin.POST("/posts", store.CreatePost)
|
||||||
r.GET("/posts/:id", store.GetPost)
|
r.GET("/posts/:id", store.GetPost)
|
||||||
protected.PUT("/posts/:id", store.UpdatePost)
|
admin.PUT("/posts/:id", store.UpdatePost)
|
||||||
protected.DELETE("/posts/:id", store.DeletePost)
|
admin.DELETE("/posts/:id", store.DeletePost)
|
||||||
|
|
||||||
// USERS
|
// USERS
|
||||||
r.GET("/user/:id", store.GetUser)
|
r.GET("/user/:id", store.GetUser)
|
||||||
protected.PUT("/user/:id", store.UpdateUser)
|
admin.PUT("/user/:id", store.UpdateUser)
|
||||||
protected.DELETE("/user/:id", store.DeleteUser)
|
admin.DELETE("/user/:id", store.DeleteUser)
|
||||||
r.GET("/user", store.GetUsers)
|
r.GET("/user", store.GetUsers)
|
||||||
r.POST("/user", store.CreateUser)
|
admin.POST("/user", store.CreateUser)
|
||||||
|
admin.PATCH("/user/:id/admin", store.SetUserAdmin)
|
||||||
|
|
||||||
// AUTH
|
// AUTH
|
||||||
r.POST("/auth/login", store.Login)
|
r.POST("/auth/login", store.Login)
|
||||||
@@ -106,12 +125,43 @@ func main() {
|
|||||||
r.GET("/spotify/recent", store.RecentlyPlayed)
|
r.GET("/spotify/recent", store.RecentlyPlayed)
|
||||||
// r.POST("/spotify", store.SendSong)
|
// r.POST("/spotify", store.SendSong)
|
||||||
|
|
||||||
|
// RADIO
|
||||||
|
admin.POST("/radio/upload", store.UploadRadioSong)
|
||||||
|
admin.GET("/radio/songs", store.ListRadioSongs)
|
||||||
|
admin.DELETE("/radio/songs/:filename", store.DeleteRadioSong)
|
||||||
|
admin.PATCH("/radio/songs/:filename/disable", store.DisableRadioSong)
|
||||||
|
admin.PATCH("/radio/songs/:filename/enable", store.EnableRadioSong)
|
||||||
|
|
||||||
// MESSAGES
|
// MESSAGES
|
||||||
r.GET("/ws", store.ConnectWebSocket)
|
r.GET("/ws", store.ConnectWebSocket)
|
||||||
|
protected.POST("/messages/upload", store.UploadMessageFile)
|
||||||
|
|
||||||
// NOTES
|
// NOTES
|
||||||
r.GET("/notes/*path", store.GetNoteFile)
|
r.GET("/notes/*path", store.GetNoteFile)
|
||||||
|
|
||||||
|
// GRAPHQL
|
||||||
|
gqlSrv := handler.New(graph.NewExecutableSchema(graph.Config{
|
||||||
|
Resolvers: &graph.Resolver{Store: &store},
|
||||||
|
}))
|
||||||
|
gqlSrv.AddTransport(transport.Websocket{KeepAlivePingInterval: 10 * time.Second})
|
||||||
|
gqlSrv.AddTransport(transport.Options{})
|
||||||
|
gqlSrv.AddTransport(transport.GET{})
|
||||||
|
gqlSrv.AddTransport(transport.POST{})
|
||||||
|
gqlSrv.AddTransport(transport.MultipartForm{})
|
||||||
|
gqlSrv.SetQueryCache(lru.New[*ast.QueryDocument](1000))
|
||||||
|
gqlSrv.Use(extension.FixedComplexityLimit(200))
|
||||||
|
if os.Getenv("GQL_INTROSPECTION") == "true" {
|
||||||
|
gqlSrv.Use(extension.Introspection{})
|
||||||
|
}
|
||||||
|
r.POST("/graphql", graph.AuthContextMiddleware(auth), func(c *gin.Context) {
|
||||||
|
gqlSrv.ServeHTTP(c.Writer, c.Request)
|
||||||
|
})
|
||||||
|
if os.Getenv("GQL_PLAYGROUND") == "true" {
|
||||||
|
r.GET("/graphql", func(c *gin.Context) {
|
||||||
|
playground.Handler("GraphQL Playground", "/graphql").ServeHTTP(c.Writer, c.Request)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// HELLO WORLD
|
// HELLO WORLD
|
||||||
r.GET("/", func(c *gin.Context) {
|
r.GET("/", func(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{"message": "Hello World"})
|
c.JSON(200, gin.H{"message": "Hello World"})
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ type Post struct {
|
|||||||
type Message struct {
|
type Message struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
Content string `json:"text"`
|
Content string `json:"text"`
|
||||||
AuthorID uint `json:"-"`
|
AuthorID uint `json:"authorId"`
|
||||||
Author *User `gorm:"foreignKey:AuthorID" json:"author"`
|
FileURL string `json:"fileUrl,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||||
}
|
}
|
||||||
@@ -61,8 +61,8 @@ type Rowing struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||||
Date time.Time `json:"date"`
|
Date time.Time `json:"date"`
|
||||||
Time time.Duration `json:"time"`
|
Time uint64 `json:"time"`
|
||||||
TimePer500m time.Duration `json:"timePer500m"`
|
Distance uint64 `json:"distance"`
|
||||||
Distance float64 `json:"distance"`
|
TimePer500m float64 `json:"timePer500m"`
|
||||||
Calories float64 `json:"calories"`
|
Calories float64 `json:"calories"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ func migrateDatabase(db *gorm.DB) error {
|
|||||||
&models.Activity{},
|
&models.Activity{},
|
||||||
&models.Favorite{},
|
&models.Favorite{},
|
||||||
&models.Rowing{},
|
&models.Rowing{},
|
||||||
|
&models.Message{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
72
backend/services/gitea.go
Normal file
72
backend/services/gitea.go
Normal 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
65
backend/services/seed.go
Normal 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")
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/zmb3/spotify/v2"
|
"github.com/zmb3/spotify/v2"
|
||||||
@@ -34,6 +35,10 @@ func SaveSpotifyToken(path string, tok *oauth2.Token) error {
|
|||||||
Expiry: tok.Expiry,
|
Expiry: tok.Expiry,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating token directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.MarshalIndent(data, "", " ")
|
jsonBytes, err := json.MarshalIndent(data, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
83
backend/services/steam.go
Normal file
83
backend/services/steam.go
Normal 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
|
||||||
|
}
|
||||||
@@ -1,33 +1,66 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"adam-french.co.uk/backend/models"
|
"adam-french.co.uk/backend/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxMessages = 50
|
||||||
|
|
||||||
|
var allowedDomain string
|
||||||
|
|
||||||
var Upgrader = websocket.Upgrader{
|
var Upgrader = websocket.Upgrader{
|
||||||
ReadBufferSize: 1024,
|
ReadBufferSize: 1024,
|
||||||
WriteBufferSize: 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 (
|
var (
|
||||||
clients = make(map[*websocket.Conn]bool)
|
clients = make(map[*websocket.Conn]bool)
|
||||||
messages = make([]models.Message, 0)
|
|
||||||
mu sync.Mutex
|
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) {
|
func HandleWebSocket(conn *websocket.Conn) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
clients[conn] = true
|
clients[conn] = true
|
||||||
|
nextAuthorID++
|
||||||
|
authorID := nextAuthorID
|
||||||
|
|
||||||
// Send existing message history to new client
|
var history []models.Message
|
||||||
for _, msg := range messages {
|
wsDB.Order("created_at ASC").Limit(maxMessages).Find(&history)
|
||||||
|
|
||||||
|
for _, msg := range history {
|
||||||
if err := conn.WriteJSON(msg); err != nil {
|
if err := conn.WriteJSON(msg); err != nil {
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
return
|
return
|
||||||
@@ -35,17 +68,32 @@ func HandleWebSocket(conn *websocket.Conn) {
|
|||||||
}
|
}
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
|
|
||||||
|
msgCount := 0
|
||||||
|
windowStart := time.Now()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
var incoming models.Message
|
var incoming models.Message
|
||||||
if err := conn.ReadJSON(&incoming); err != nil {
|
if err := conn.ReadJSON(&incoming); err != nil {
|
||||||
break
|
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()
|
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 {
|
for client := range clients {
|
||||||
if err := client.WriteJSON(incoming); err != nil {
|
if err := client.WriteJSON(incoming); err != nil {
|
||||||
@@ -56,7 +104,6 @@ func HandleWebSocket(conn *websocket.Conn) {
|
|||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup on disconnect
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
delete(clients, conn)
|
delete(clients, conn)
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
|
|||||||
27
docker-compose.dev.yml
Normal file
27
docker-compose.dev.yml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
services:
|
||||||
|
vue:
|
||||||
|
command: ["npm", "run", "dev"]
|
||||||
|
volumes:
|
||||||
|
- ./vue:/app
|
||||||
|
- /app/node_modules
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
- SPOTIFY_REDIRECT_URI=https://localhost/api/spotify/callback
|
||||||
|
- GQL_PLAYGROUND=true
|
||||||
|
- GQL_INTROSPECTION=true
|
||||||
|
nginx:
|
||||||
|
environment:
|
||||||
|
- DEV_MODE=true
|
||||||
|
- SEED_DB=true
|
||||||
|
ports:
|
||||||
|
- 80:80
|
||||||
|
- 443:443
|
||||||
|
hasura:
|
||||||
|
environment:
|
||||||
|
HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
|
||||||
|
HASURA_GRAPHQL_DEV_MODE: "true"
|
||||||
|
certbot:
|
||||||
|
profiles:
|
||||||
|
- disabled
|
||||||
@@ -4,8 +4,22 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
dbdata:
|
dbdata:
|
||||||
|
uploads:
|
||||||
|
vue_dist:
|
||||||
|
uptime_kuma_data:
|
||||||
|
searxng_data:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
vue:
|
||||||
|
build:
|
||||||
|
context: ./vue
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: vue
|
||||||
|
volumes:
|
||||||
|
- vue_dist:/output
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
build:
|
build:
|
||||||
context: ./nginx
|
context: ./nginx
|
||||||
@@ -14,9 +28,15 @@ services:
|
|||||||
env_file: ./.env
|
env_file: ./.env
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
|
- vue
|
||||||
- backend
|
- backend
|
||||||
- icecast2
|
- icecast2
|
||||||
- gitea
|
- gitea
|
||||||
|
- hasura
|
||||||
|
- quartz
|
||||||
|
- uptime-kuma
|
||||||
|
- searxng
|
||||||
|
- wallabag
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
ports:
|
ports:
|
||||||
@@ -25,9 +45,11 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./certbot/conf:/etc/letsencrypt
|
- ./certbot/conf:/etc/letsencrypt
|
||||||
- ./certbot/www:/var/www/certbot
|
- ./certbot/www:/var/www/certbot
|
||||||
|
- uploads:/uploads
|
||||||
|
- vue_dist:/etc/nginx/html
|
||||||
|
|
||||||
certbot:
|
certbot:
|
||||||
image: certbot/certbot
|
image: certbot/certbot:v3.1.0
|
||||||
container_name: certbot
|
container_name: certbot
|
||||||
volumes:
|
volumes:
|
||||||
- ./certbot/entrypoint.sh:/entrypoint.sh
|
- ./certbot/entrypoint.sh:/entrypoint.sh
|
||||||
@@ -55,6 +77,8 @@ services:
|
|||||||
- ./backend/token/:/backend/token
|
- ./backend/token/:/backend/token
|
||||||
- ${OBSIDIAN_DIR}:/backend/notes
|
- ${OBSIDIAN_DIR}:/backend/notes
|
||||||
- ./logs:/backend/logs
|
- ./logs:/backend/logs
|
||||||
|
- uploads:/backend/uploads
|
||||||
|
- ./icecast2/fallback_music:/backend/fallback_music
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
@@ -66,8 +90,21 @@ services:
|
|||||||
- app-network
|
- app-network
|
||||||
volumes:
|
volumes:
|
||||||
- dbdata:/var/lib/postgresql/data
|
- dbdata:/var/lib/postgresql/data
|
||||||
ports:
|
|
||||||
- 5432:5432
|
hasura:
|
||||||
|
image: hasura/graphql-engine:v2.44.0
|
||||||
|
container_name: "${HASURA_HOST}"
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
environment:
|
||||||
|
HASURA_GRAPHQL_DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
|
||||||
|
HASURA_GRAPHQL_ADMIN_SECRET: "${HASURA_GRAPHQL_ADMIN_SECRET}"
|
||||||
|
HASURA_GRAPHQL_ENABLE_CONSOLE: "false"
|
||||||
|
HASURA_GRAPHQL_DEV_MODE: "false"
|
||||||
|
HASURA_GRAPHQL_ENABLED_LOG_TYPES: "startup, http-log, webhook-log, websocket-log, query-log"
|
||||||
|
|
||||||
icecast2:
|
icecast2:
|
||||||
build:
|
build:
|
||||||
@@ -79,11 +116,71 @@ services:
|
|||||||
- app-network
|
- app-network
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
|
volumes:
|
||||||
|
- ./icecast2/fallback_music:/music:ro
|
||||||
ports:
|
ports:
|
||||||
- "${ICECAST_PORT}:${ICECAST_PORT}"
|
- "${LIQUIDSOAP_HARBOR_PORT:-8001}:${LIQUIDSOAP_HARBOR_PORT:-8001}"
|
||||||
|
|
||||||
|
quartz:
|
||||||
|
build:
|
||||||
|
context: ./quartz
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: "${QUARTZ_HOST}"
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
volumes:
|
||||||
|
- ${OBSIDIAN_DIR}:/quartz/content:ro
|
||||||
|
|
||||||
|
uptime-kuma:
|
||||||
|
image: louislam/uptime-kuma:1
|
||||||
|
container_name: "${UPTIMEKUMA_HOST}"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
environment:
|
||||||
|
- UPTIME_KUMA_BASE_PATH=/uptime-kuma
|
||||||
|
volumes:
|
||||||
|
- uptime_kuma_data:/app/data
|
||||||
|
|
||||||
|
searxng:
|
||||||
|
build:
|
||||||
|
context: ./searxng
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: "${SEARXNG_HOST}"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
environment:
|
||||||
|
- BASE_URL=https://www.${DOMAIN}/searxng/
|
||||||
|
- INSTANCE_NAME=searxng
|
||||||
|
- SEARXNG_SECRET_KEY=${SEARXNG_SECRET_KEY}
|
||||||
|
volumes:
|
||||||
|
- searxng_data:/etc/searxng
|
||||||
|
|
||||||
|
wallabag:
|
||||||
|
image: wallabag/wallabag:latest
|
||||||
|
container_name: "${WALLABAG_HOST}"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
environment:
|
||||||
|
- SYMFONY__ENV__DOMAIN_NAME=https://www.${DOMAIN}/wallabag
|
||||||
|
- SYMFONY__ENV__DATABASE_DRIVER=pdo_pgsql
|
||||||
|
- SYMFONY__ENV__DATABASE_HOST=${POSTGRES_HOST}
|
||||||
|
- SYMFONY__ENV__DATABASE_PORT=${POSTGRES_PORT}
|
||||||
|
- SYMFONY__ENV__DATABASE_NAME=wallabag
|
||||||
|
- SYMFONY__ENV__DATABASE_USER=${POSTGRES_USER}
|
||||||
|
- SYMFONY__ENV__DATABASE_PASSWORD=${POSTGRES_PASSWORD}
|
||||||
|
|
||||||
gitea-runner:
|
gitea-runner:
|
||||||
image: gitea/act_runner:latest
|
build:
|
||||||
|
context: ./gitea-runner
|
||||||
|
dockerfile: Dockerfile
|
||||||
container_name: "${GITEA_RUNNER_HOST}"
|
container_name: "${GITEA_RUNNER_HOST}"
|
||||||
environment:
|
environment:
|
||||||
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME}
|
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME}
|
||||||
@@ -94,7 +191,8 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./gitea-runner/config.yaml:/config.yaml
|
- ./gitea-runner/config.yaml:/config.yaml
|
||||||
- ./gitea-runner/data:/data
|
- ./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.
|
||||||
|
- /home/adamf/deploy/web_server:/home/adamf/deploy/web_server # Same path on host and container so docker compose bind mounts resolve correctly
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
@@ -110,6 +208,11 @@ services:
|
|||||||
- GITEA__database__NAME=${POSTGRES_GITEA_DB}
|
- GITEA__database__NAME=${POSTGRES_GITEA_DB}
|
||||||
- GITEA__database__USER=${POSTGRES_USER}
|
- GITEA__database__USER=${POSTGRES_USER}
|
||||||
- GITEA__database__PASSWD=${POSTGRES_PASSWORD}
|
- 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
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- ./gitea/data:/var/lib/gitea
|
- ./gitea/data:/var/lib/gitea
|
||||||
@@ -117,7 +220,7 @@ services:
|
|||||||
- /etc/timezone:/etc/timezone:ro
|
- /etc/timezone:/etc/timezone:ro
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
|
||||||
- "2222:2222"
|
- "2222:2222"
|
||||||
|
- "3000:3000"
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|||||||
3
gitea-runner/Dockerfile
Normal file
3
gitea-runner/Dockerfile
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
FROM gitea/act_runner:latest
|
||||||
|
|
||||||
|
RUN apk add --no-cache docker-cli docker-cli-compose
|
||||||
@@ -36,13 +36,14 @@ runner:
|
|||||||
# It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github,
|
# It works when something like `uses: actions/checkout@v4` is used and DEFAULT_ACTIONS_URL is set to github,
|
||||||
# and github_mirror is not empty. In this case,
|
# and github_mirror is not empty. In this case,
|
||||||
# it replaces https://github.com with the value here, which is useful for some special network environments.
|
# it replaces https://github.com with the value here, which is useful for some special network environments.
|
||||||
github_mirror: ''
|
github_mirror: ""
|
||||||
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
|
# The labels of a runner are used to determine which jobs the runner can run, and how to run them.
|
||||||
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
# Like: "macos-arm64:host" or "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
||||||
# Find more images provided by Gitea at https://gitea.com/docker.gitea.com/runner-images .
|
# Find more images provided by Gitea at https://gitea.com/docker.gitea.com/runner-images .
|
||||||
# If it's empty when registering, it will ask for inputting labels.
|
# If it's empty when registering, it will ask for inputting labels.
|
||||||
# If it's empty when execute `daemon`, will use labels in `.runner` file.
|
# If it's empty when execute `daemon`, will use labels in `.runner` file.
|
||||||
labels:
|
labels:
|
||||||
|
- "self-hosted:host"
|
||||||
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
- "ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest"
|
||||||
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
|
- "ubuntu-22.04:docker://docker.gitea.com/runner-images:ubuntu-22.04"
|
||||||
- "ubuntu-20.04:docker://docker.gitea.com/runner-images:ubuntu-20.04"
|
- "ubuntu-20.04:docker://docker.gitea.com/runner-images:ubuntu-20.04"
|
||||||
@@ -89,7 +90,8 @@ container:
|
|||||||
# If you want to allow any volume, please use the following configuration:
|
# If you want to allow any volume, please use the following configuration:
|
||||||
# valid_volumes:
|
# valid_volumes:
|
||||||
# - '**'
|
# - '**'
|
||||||
valid_volumes: []
|
valid_volumes:
|
||||||
|
- "**"
|
||||||
# overrides the docker client host with the specified one.
|
# overrides the docker client host with the specified one.
|
||||||
# If it's empty, act_runner will find an available docker host automatically.
|
# If it's empty, act_runner will find an available docker host automatically.
|
||||||
# If it's "-", act_runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers.
|
# If it's "-", act_runner will find an available docker host automatically, but the docker host won't be mounted to the job containers and service containers.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ SSH_LISTEN_PORT = 2222
|
|||||||
BUILTIN_SSH_SERVER_USER = git
|
BUILTIN_SSH_SERVER_USER = git
|
||||||
LFS_START_SERVER = true
|
LFS_START_SERVER = true
|
||||||
DOMAIN = stppi.local
|
DOMAIN = stppi.local
|
||||||
LFS_JWT_SECRET = XHIJprS_aMv0tizioZpUD38GGqTtNMFXMz1R6LuPvjU
|
LFS_JWT_SECRET =
|
||||||
OFFLINE_MODE = true
|
OFFLINE_MODE = true
|
||||||
|
|
||||||
[database]
|
[database]
|
||||||
@@ -34,7 +34,7 @@ DB_TYPE = postgres
|
|||||||
HOST = db
|
HOST = db
|
||||||
NAME = gitea
|
NAME = gitea
|
||||||
USER = postgres
|
USER = postgres
|
||||||
PASSWD = password
|
PASSWD =
|
||||||
SCHEMA =
|
SCHEMA =
|
||||||
SSL_MODE = disable
|
SSL_MODE = disable
|
||||||
LOG_SQL = false
|
LOG_SQL = false
|
||||||
@@ -60,7 +60,7 @@ INSTALL_LOCK = true
|
|||||||
SECRET_KEY =
|
SECRET_KEY =
|
||||||
REVERSE_PROXY_LIMIT = 1
|
REVERSE_PROXY_LIMIT = 1
|
||||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
REVERSE_PROXY_TRUSTED_PROXIES = *
|
||||||
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3NzEyNDMyMTd9.yHsgFcEwDNWmZebftpe8tpWRFa5aR5tkpQuVYybeVaY
|
INTERNAL_TOKEN =
|
||||||
PASSWORD_HASH_ALGO = pbkdf2
|
PASSWORD_HASH_ALGO = pbkdf2
|
||||||
|
|
||||||
[service]
|
[service]
|
||||||
@@ -95,4 +95,4 @@ DEFAULT_MERGE_STYLE = merge
|
|||||||
DEFAULT_TRUST_MODEL = committer
|
DEFAULT_TRUST_MODEL = committer
|
||||||
|
|
||||||
[oauth2]
|
[oauth2]
|
||||||
JWT_SECRET = pYiwW8xxGi23gysl2pa-02Cf567Z5ERvR6DDFGIn2iQ
|
JWT_SECRET =
|
||||||
1
icecast2/.dockerignore
Normal file
1
icecast2/.dockerignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
fallback_music/
|
||||||
@@ -1,19 +1,13 @@
|
|||||||
FROM debian:latest as builder
|
FROM savonet/liquidsoap:v2.3.2
|
||||||
|
USER root
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install --yes icecast2 gettext media-types
|
&& apt-get install --yes icecast2 gettext media-types \
|
||||||
# RUN apt-get install --yes liquidsoap
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN useradd radio
|
RUN useradd radio
|
||||||
RUN chown -R radio:radio /etc/icecast2 /var/log/icecast2
|
RUN mkdir -p /music /etc/liquidsoap
|
||||||
# RUN chown -R radio:radio /etc/liquidsoap /var/log/liquidsoap
|
RUN chown -R radio:radio /etc/icecast2 /var/log/icecast2 /music /etc/liquidsoap
|
||||||
USER radio
|
USER radio
|
||||||
|
|
||||||
COPY icecast.xml.template /etc/icecast2/icecast.xml.template
|
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
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT [ "/entrypoint.sh" ]
|
ENTRYPOINT [ "/entrypoint.sh" ]
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Substitute environment variables into template
|
|
||||||
envsubst < /etc/icecast2/icecast.xml.template > /etc/icecast2/icecast.xml
|
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
|
icecast2 -c /etc/icecast2/icecast.xml &
|
||||||
exec icecast2 -c /etc/icecast2/icecast.xml
|
sleep 2
|
||||||
# exec liquidsoap /etc/liquidsoap/stream.liq
|
liquidsoap /etc/liquidsoap/stream.liq &
|
||||||
# wait -n
|
wait -n
|
||||||
|
kill $(jobs -p) 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
|||||||
@@ -1,24 +1,17 @@
|
|||||||
[general]
|
settings.server.telnet := false
|
||||||
duration = 0 # 0 = run forever
|
|
||||||
bufferSecs = 5 # buffer size in seconds
|
|
||||||
reconnect = yes # reconnect on failure
|
|
||||||
reconnectDelay = 5
|
|
||||||
|
|
||||||
[input]
|
music = playlist("/music", mode="randomize", reload_mode="watch")
|
||||||
device = pulse # PulseAudio input
|
|
||||||
sampleRate = 44100 # in Hz
|
|
||||||
bitsPerSample = 16
|
|
||||||
channel = 2
|
|
||||||
|
|
||||||
[icecast2-0]
|
live = input.harbor("${LIQUIDSOAP_HARBOR_MOUNT}", port=${LIQUIDSOAP_HARBOR_PORT}, password="${ICECAST_SOURCE_PASSWORD}")
|
||||||
bitrateMode = cbr
|
|
||||||
bitrate = 128 # kbps
|
radio = amplify(0.7, fallback(track_sensitive=false, [live, music, blank()]))
|
||||||
format = mp3
|
|
||||||
server = ${ICECAST_HOST}
|
output.icecast(
|
||||||
port = ${ICECAST_PORT}
|
%mp3,
|
||||||
password = ${ICECAST_SOURCE_PASSWORD}
|
host="localhost",
|
||||||
mountPoint = ${ICECAST_MOUNT}
|
port=${ICECAST_PORT},
|
||||||
name = "Live DJ stream"
|
password="${ICECAST_SOURCE_PASSWORD}",
|
||||||
description = "Live microphone stream"
|
mount="${ICECAST_MOUNT}",
|
||||||
genre = "Various"
|
fallible=true,
|
||||||
public = yes
|
radio
|
||||||
|
)
|
||||||
|
|||||||
2
nginx/.dockerignore
Normal file
2
nginx/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
**/.git
|
||||||
|
**/.DS_Store
|
||||||
@@ -1,32 +1,9 @@
|
|||||||
FROM nginx:latest
|
FROM nginx:1.27
|
||||||
RUN rm -rf /etc/nginx/html/*
|
RUN rm -rf /etc/nginx/html/* && \
|
||||||
|
apt-get update && apt-get install -y gettext-base openssl && \
|
||||||
# Install dependencies needed to add NodeSource repo
|
rm -rf /var/lib/apt/lists/*
|
||||||
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/
|
|
||||||
|
|
||||||
COPY nginx.conf.template /etc/nginx/nginx.conf.template
|
COPY nginx.conf.template /etc/nginx/nginx.conf.template
|
||||||
COPY nginx_setup.conf.template /etc/nginx/nginx_setup.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
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -1,15 +1,46 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Check if certificate exists
|
# Check if dev mode, certificate exists, or setup mode
|
||||||
if [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then
|
if [ "$DEV_MODE" = "true" ]; then
|
||||||
|
echo "Dev mode. Generating self-signed certificate for HTTPS."
|
||||||
|
CERT_DIR="/etc/letsencrypt/live/localhost"
|
||||||
|
if [ ! -f "$CERT_DIR/fullchain.pem" ]; then
|
||||||
|
mkdir -p "$CERT_DIR"
|
||||||
|
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||||
|
-keyout "$CERT_DIR/privkey.pem" \
|
||||||
|
-out "$CERT_DIR/fullchain.pem" \
|
||||||
|
-subj "/CN=localhost" 2>/dev/null
|
||||||
|
fi
|
||||||
|
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT} ${HASURA_HOST} ${HASURA_PORT} ${QUARTZ_HOST} ${QUARTZ_PORT} ${UPTIMEKUMA_HOST} ${UPTIMEKUMA_PORT} ${SEARXNG_HOST} ${SEARXNG_PORT} ${WALLABAG_HOST} ${WALLABAG_PORT}' \
|
||||||
|
</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."
|
echo "Certificates found. Using production nginx config."
|
||||||
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT}' \
|
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT} ${HASURA_HOST} ${HASURA_PORT} ${QUARTZ_HOST} ${QUARTZ_PORT} ${UPTIMEKUMA_HOST} ${UPTIMEKUMA_PORT} ${SEARXNG_HOST} ${SEARXNG_PORT} ${WALLABAG_HOST} ${WALLABAG_PORT}' \
|
||||||
< /etc/nginx/nginx.conf.template \
|
</etc/nginx/nginx.conf.template \
|
||||||
> /etc/nginx/nginx.conf
|
>/etc/nginx/nginx.conf
|
||||||
else
|
else
|
||||||
echo "Certificates NOT found. Using setup nginx config."
|
echo "Certificates NOT found. Using setup nginx config."
|
||||||
envsubst '${DOMAIN}' < /etc/nginx/nginx_setup.conf.template > /etc/nginx/nginx.conf
|
envsubst '${DOMAIN}' </etc/nginx/nginx_setup.conf.template >/etc/nginx/nginx.conf
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 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
|
fi
|
||||||
|
|
||||||
# Start nginx
|
# Start nginx
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ http {
|
|||||||
server_tokens off;
|
server_tokens off;
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
|
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
|
||||||
|
|
||||||
log_format compact
|
log_format compact
|
||||||
'$remote_addr "$request" $status rt=$request_time';
|
'$remote_addr "$request" $status rt=$request_time';
|
||||||
|
|
||||||
@@ -18,6 +24,22 @@ http {
|
|||||||
text/javascript mjs;
|
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 {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name $DOMAIN www.$DOMAIN;
|
server_name $DOMAIN www.$DOMAIN;
|
||||||
@@ -55,6 +77,36 @@ http {
|
|||||||
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
|
||||||
|
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||||
|
|
||||||
|
# Vite hashed assets - immutable, cache 1 year
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fonts - cache 30 days
|
||||||
|
location /fonts/ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Images - cache 7 days
|
||||||
|
location /img/ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /uploads/ {
|
||||||
|
alias /uploads/;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header Content-Disposition "inline" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'none'; script-src 'none'" always;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
@@ -70,14 +122,54 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location = /img/stamps/mine.gif {
|
location = /img/stamps/mine.gif {
|
||||||
add_header Access-Control-Allow-Origin *;
|
add_header Access-Control-Allow-Origin "https://www.$DOMAIN";
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /sound/ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
}
|
}
|
||||||
|
|
||||||
location $BACKEND_ENDPOINT {
|
location $BACKEND_ENDPOINT {
|
||||||
return 301 $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/ {
|
location $BACKEND_ENDPOINT/ {
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
@@ -110,6 +202,75 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /hasura {
|
||||||
|
return 301 /hasura/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /hasura/ {
|
||||||
|
proxy_pass http://$HASURA_HOST:$HASURA_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;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /notes {
|
||||||
|
return 301 /notes/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /notes/ {
|
||||||
|
proxy_pass http://$QUARTZ_HOST:$QUARTZ_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 /uptime-kuma {
|
||||||
|
return 301 /uptime-kuma/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /uptime-kuma/ {
|
||||||
|
proxy_pass http://$UPTIMEKUMA_HOST:$UPTIMEKUMA_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 /searxng {
|
||||||
|
return 301 /searxng/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /searxng/ {
|
||||||
|
proxy_pass http://$SEARXNG_HOST:$SEARXNG_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 /wallabag {
|
||||||
|
return 301 /wallabag/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /wallabag/ {
|
||||||
|
proxy_pass http://$WALLABAG_HOST:$WALLABAG_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;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
366
nginx/nginx_dev.conf.template
Normal file
366
nginx/nginx_dev.conf.template
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
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 50M;
|
||||||
|
|
||||||
|
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
|
||||||
|
|
||||||
|
log_format compact
|
||||||
|
'$remote_addr "$request" $status rt=$request_time';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log compact;
|
||||||
|
|
||||||
|
types {
|
||||||
|
text/javascript mjs;
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_min_length 256;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/javascript
|
||||||
|
application/javascript
|
||||||
|
application/json
|
||||||
|
application/xml
|
||||||
|
image/svg+xml
|
||||||
|
font/woff2
|
||||||
|
application/font-woff2;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name $DOMAIN www.$DOMAIN;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /hasura {
|
||||||
|
return 301 /hasura/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /hasura/ {
|
||||||
|
proxy_pass http://$HASURA_HOST:$HASURA_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;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /notes {
|
||||||
|
return 301 /notes/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /notes/ {
|
||||||
|
proxy_pass http://$QUARTZ_HOST:$QUARTZ_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 /uptime-kuma {
|
||||||
|
return 301 /uptime-kuma/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /uptime-kuma/ {
|
||||||
|
proxy_pass http://$UPTIMEKUMA_HOST:$UPTIMEKUMA_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 /searxng {
|
||||||
|
return 301 /searxng/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /searxng/ {
|
||||||
|
proxy_pass http://$SEARXNG_HOST:$SEARXNG_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 /wallabag {
|
||||||
|
return 301 /wallabag/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /wallabag/ {
|
||||||
|
proxy_pass http://$WALLABAG_HOST:$WALLABAG_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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name $DOMAIN www.$DOMAIN;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/localhost/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/localhost/privkey.pem;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /hasura {
|
||||||
|
return 301 /hasura/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /hasura/ {
|
||||||
|
proxy_pass http://$HASURA_HOST:$HASURA_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;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /notes {
|
||||||
|
return 301 /notes/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /notes/ {
|
||||||
|
proxy_pass http://$QUARTZ_HOST:$QUARTZ_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 /uptime-kuma {
|
||||||
|
return 301 /uptime-kuma/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /uptime-kuma/ {
|
||||||
|
proxy_pass http://$UPTIMEKUMA_HOST:$UPTIMEKUMA_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 /searxng {
|
||||||
|
return 301 /searxng/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /searxng/ {
|
||||||
|
proxy_pass http://$SEARXNG_HOST:$SEARXNG_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 /wallabag {
|
||||||
|
return 301 /wallabag/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /wallabag/ {
|
||||||
|
proxy_pass http://$WALLABAG_HOST:$WALLABAG_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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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>
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<template>
|
|
||||||
<footer></footer>
|
|
||||||
</template>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="w-full border-b border-primary">
|
|
||||||
<h1 class="pl-2 m-0">
|
|
||||||
<slot />
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import Project from "./Project.vue";
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<main>
|
|
||||||
<div class="a4page">
|
|
||||||
<div class="flex flex-row justify-between">
|
|
||||||
<h1 class="name">Adam French</h1>
|
|
||||||
<!-- <a href="covers.html"><img width=25 height=50 src="img/rune.png"></a> -->
|
|
||||||
<div class="contact-details text-right">
|
|
||||||
<p>+447563266931</p>
|
|
||||||
<p>adam.a.french@outlook.com</p>
|
|
||||||
<a href="https://www.adam-french.co.uk">
|
|
||||||
www.adam-french.co.uk
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2>Profile</h2>
|
|
||||||
<p>
|
|
||||||
Recent Computer Science with Mathematics (International)
|
|
||||||
graduate from the University of Leeds, awarded First Class
|
|
||||||
Honours (81.1%). Strong foundation in full-stack software
|
|
||||||
development, CI/CD workflows, and modern programming languages.
|
|
||||||
Experienced in creating scalable, maintainable systems and
|
|
||||||
motivated by solving complex technical problems. Enthusiastic
|
|
||||||
about working within organisations that promote innovation,
|
|
||||||
collaboration, and positive social impact.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h2>Projects</h2>
|
|
||||||
|
|
||||||
<Project class="border-b border-dotted">
|
|
||||||
<template v-slot:left>
|
|
||||||
<a
|
|
||||||
href="https://www.adam-french.co.uk/gitea/adamf/web_server.git"
|
|
||||||
>
|
|
||||||
web_server.git
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
<template v-slot:top>
|
|
||||||
<small>
|
|
||||||
Nginx, Vue, Postgres, Docker, Go, Python, Rust -> Wasm,
|
|
||||||
Git Actions, JWT Auth
|
|
||||||
</small>
|
|
||||||
<small>2025</small>
|
|
||||||
</template>
|
|
||||||
<p>
|
|
||||||
Developed and self-hosted a personal website with a fully
|
|
||||||
automated maintenance CI/CD pipeline. Experimented with
|
|
||||||
diverse tech stacks including Svelte, React/Redux, SQLite,
|
|
||||||
Rust Actix, and Deno.
|
|
||||||
</p>
|
|
||||||
</Project>
|
|
||||||
<Project class="border-b border-dotted">
|
|
||||||
<template v-slot:left>
|
|
||||||
<a
|
|
||||||
href="https://www.adam-french.co.uk/gitea/adamf/tour.git"
|
|
||||||
>
|
|
||||||
tour.git
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
<template v-slot:top>
|
|
||||||
<small>Rust</small>
|
|
||||||
<small>2026</small>
|
|
||||||
</template>
|
|
||||||
<p>
|
|
||||||
Created a command-line tool for building and viewing
|
|
||||||
interactive code tutorials. Designed functionality analogous
|
|
||||||
to Git for intuitive version traversal and educational use.
|
|
||||||
</p>
|
|
||||||
</Project>
|
|
||||||
<Project class="border-b border-dotted">
|
|
||||||
<template v-slot:left>
|
|
||||||
<a
|
|
||||||
href="https://www.adam-french.co.uk/gitea/adamf/rust-raytracer.git"
|
|
||||||
>
|
|
||||||
rust-raytracer.git
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
<template v-slot:top>
|
|
||||||
<small>Rust, Linear Algebra, Multithreading</small>
|
|
||||||
<small>2023</small>
|
|
||||||
</template>
|
|
||||||
<p>
|
|
||||||
Built a parallelised, recursive ray tracer for realistic 3D
|
|
||||||
rendering as part of a university module. Focused on
|
|
||||||
algorithmic efficiency and low-level memory management in
|
|
||||||
Rust.
|
|
||||||
</p>
|
|
||||||
</Project>
|
|
||||||
<Project>
|
|
||||||
<template #left>
|
|
||||||
<p>
|
|
||||||
<a
|
|
||||||
class="text-center w-full"
|
|
||||||
href="https://community.wolfram.com/groups/-/m/t/3210947"
|
|
||||||
>
|
|
||||||
Wolfram Summer School
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
<template #top>
|
|
||||||
<small>Wolfram Mathematica</small>
|
|
||||||
<small>2024</small>
|
|
||||||
</template>
|
|
||||||
<p>
|
|
||||||
Designed and implemented a research project on Mobile
|
|
||||||
Automata, including data visualisation and presentation of
|
|
||||||
findings. Completed the project within a short deadline and
|
|
||||||
collaborated with academic mentors to refine outcomes.
|
|
||||||
</p>
|
|
||||||
</Project>
|
|
||||||
<h2>University & Modules</h2>
|
|
||||||
<div class="w-full h-fit flex-row flex gap-5">
|
|
||||||
<div class="flex-1 border-r border-dotted pr-3">
|
|
||||||
<h3>University of Leeds</h3>
|
|
||||||
<div
|
|
||||||
class="flex-row flex place-content-between m-auto place-items-center"
|
|
||||||
>
|
|
||||||
<small> 81.1% (First Class Honours)</small>
|
|
||||||
<small> 2021-2025 </small>
|
|
||||||
</div>
|
|
||||||
<small>BSc Computer Science with Mathematics </small>
|
|
||||||
<ul>
|
|
||||||
<li>Procedural & Object Oriented Programming,</li>
|
|
||||||
<li></li>
|
|
||||||
<li>Algorithms and Data Structures I & II</li>
|
|
||||||
|
|
||||||
<li>Databases</li>
|
|
||||||
<li>Computer Processors</li>
|
|
||||||
<li>Compiler Design and Construction</li>
|
|
||||||
|
|
||||||
<li>Formal Languages and Finite Automata</li>
|
|
||||||
<li>Probability and Statistics I</li>
|
|
||||||
<li>Machine Learning</li>
|
|
||||||
<li>Graph Algorithms & Complexity Theory</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1 pl-3">
|
|
||||||
<h3>The University of Waterloo</h3>
|
|
||||||
<div
|
|
||||||
class="flex-row flex place-content-between m-auto place-items-center"
|
|
||||||
>
|
|
||||||
<small>---</small>
|
|
||||||
<small> 2023-2024 </small>
|
|
||||||
</div>
|
|
||||||
<div class="flex-row flex place-content-between"></div>
|
|
||||||
<ul>
|
|
||||||
<li>Applied Cryptography</li>
|
|
||||||
<li>Introduction to Computer Graphics</li>
|
|
||||||
<li>
|
|
||||||
Introduction to Rings and Fields with Applications
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- <div class="a4page"> -->
|
|
||||||
<!-- <h2>Experience</h2> -->
|
|
||||||
<!-- <Project> -->
|
|
||||||
<!-- <template #left> -->
|
|
||||||
<!-- <p>Hospitality</p> -->
|
|
||||||
<!-- </template> -->
|
|
||||||
<!-- <template #top> -->
|
|
||||||
<!-- <small>Cashier, Bartender, Waiter</small> -->
|
|
||||||
<!-- <small>2018-2023</small> -->
|
|
||||||
<!-- </template> -->
|
|
||||||
<!-- <p> -->
|
|
||||||
<!-- Worked at venues including: -->
|
|
||||||
<!-- <em>Belgrave Music Hall</em>, -->
|
|
||||||
<!-- <em>The Crown and Anchor Eastbourne</em>, -->
|
|
||||||
<!-- <em>To The Rise Bakery</em>, -->
|
|
||||||
<!-- <em>BFI Riverfront Kitchen</em>. -->
|
|
||||||
<!-- </p> -->
|
|
||||||
<!-- </Project> -->
|
|
||||||
<!-- <h2>Commitments</h2> -->
|
|
||||||
<!-- <ul> -->
|
|
||||||
<!-- <li>Gym</li> -->
|
|
||||||
<!-- <li>Climbing</li> -->
|
|
||||||
<!-- <li>Meetup.com</li> -->
|
|
||||||
<!-- <li>Boardgames</li> -->
|
|
||||||
<!-- <li>Leetcode</li> -->
|
|
||||||
<!-- <li>Learning Mandarin</li> -->
|
|
||||||
<!-- </ul> -->
|
|
||||||
<!-- </div> -->
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Fonts */
|
|
||||||
@font-face {
|
|
||||||
font-family: "big_noodle_titling";
|
|
||||||
src: url("/fonts/big_noodle_titling.ttf") format("truetype");
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "CreatoDisplay";
|
|
||||||
src: url("/fonts/CreatoDisplay-Bold.otf") format("opentype");
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Variables */
|
|
||||||
* {
|
|
||||||
/* Black - White */
|
|
||||||
--primary: black;
|
|
||||||
--secondary: #0000ff;
|
|
||||||
--tertiary: #ff0000;
|
|
||||||
--quaternary: #cccccc;
|
|
||||||
--background: white;
|
|
||||||
|
|
||||||
--font-heading: big_noodle_titling;
|
|
||||||
--font-text: CreatoDisplay;
|
|
||||||
--font-size-name: 2.5em;
|
|
||||||
--font-size-text: 100%;
|
|
||||||
--font-size-small: 0.9em;
|
|
||||||
--font-size-heading: 2.1em;
|
|
||||||
--font-size-subheading: 1.7em;
|
|
||||||
--font-size-subsubheading: 1.4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A4 Page */
|
|
||||||
.a4page {
|
|
||||||
line-height: 1.6;
|
|
||||||
font-family: var(--font-text);
|
|
||||||
width: 210mm;
|
|
||||||
/* Standard A4 width */
|
|
||||||
height: 297mm;
|
|
||||||
/* Standard A4 height */
|
|
||||||
padding: 5mm;
|
|
||||||
box-sizing: border-box;
|
|
||||||
background-color: var(--background);
|
|
||||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
|
||||||
border: 1px solid var(--primary);
|
|
||||||
overflow: hidden;
|
|
||||||
/* Enables scrolling when content exceeds height */
|
|
||||||
margin: auto auto;
|
|
||||||
/* Centers the page horizontally */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Component Styling */
|
|
||||||
main {
|
|
||||||
padding: 0px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
|
||||||
height: 2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4 {
|
|
||||||
border: none;
|
|
||||||
color: var(--primary);
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: var(--font-size-heading);
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
border-bottom: 1px solid var(--primary);
|
|
||||||
font-size: var(--font-size-subheading);
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: var(--font-size-subsubheading);
|
|
||||||
}
|
|
||||||
|
|
||||||
a:hover {
|
|
||||||
color: var(--tertiary);
|
|
||||||
}
|
|
||||||
a {
|
|
||||||
background-color: transparent;
|
|
||||||
color: var(--secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-bottom: 0.2em;
|
|
||||||
color: var(--primary);
|
|
||||||
font-size: var(--font-size-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
color: var(--secondary);
|
|
||||||
border-collapse: collapse;
|
|
||||||
border: 1px solid black;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
/* border: 2px solid var(--tertiary); */
|
|
||||||
color: var(--secondary);
|
|
||||||
border-top: 1px solid var(--tertiary);
|
|
||||||
padding: 1px 10px 1px 10px;
|
|
||||||
font-size: var(--font-size-text);
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
color: var(--secondary);
|
|
||||||
border: 2px solid var(--tertiary);
|
|
||||||
padding: 1px 0px 1px 7px;
|
|
||||||
font-family: var(--font-heading);
|
|
||||||
font-size: var(--font-size-subsubheading);
|
|
||||||
background-color: var(--quaternary);
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media print {
|
|
||||||
.no-print {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
small {
|
|
||||||
font-size: var(--font-size-small);
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
ul {
|
|
||||||
font-size: var(--font-size-small);
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
font-size: var(--font-size-small);
|
|
||||||
color: var(--primary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { fileURLToPath, URL } from "node:url";
|
|
||||||
|
|
||||||
import { defineConfig } from "vite";
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
import vue from "@vitejs/plugin-vue";
|
|
||||||
import vueDevTools from "vite-plugin-vue-devtools";
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [vue(), vueDevTools(), tailwindcss()],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
19
quartz/Dockerfile
Normal file
19
quartz/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:22-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install --yes git gettext-base \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /quartz
|
||||||
|
|
||||||
|
ARG QUARTZ_VERSION=v4.4.0
|
||||||
|
RUN git clone --depth 1 --branch ${QUARTZ_VERSION} \
|
||||||
|
https://github.com/jackyzha0/quartz.git . \
|
||||||
|
&& npm ci
|
||||||
|
|
||||||
|
COPY quartz.config.ts.template /quartz/quartz.config.ts.template
|
||||||
|
COPY quartz.layout.ts /quartz/quartz.layout.ts
|
||||||
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
8
quartz/entrypoint.sh
Normal file
8
quartz/entrypoint.sh
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
envsubst '${DOMAIN}' \
|
||||||
|
</quartz/quartz.config.ts.template \
|
||||||
|
>/quartz/quartz.config.ts
|
||||||
|
|
||||||
|
exec npx quartz build --serve --port "${QUARTZ_PORT:-8080}"
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user