Compare commits
142 Commits
rowing_fro
...
619692687f
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -17,3 +17,6 @@ jobs:
|
||||
env:
|
||||
DOCKER_API_VERSION: "1.41"
|
||||
run: docker compose up -d --build --remove-orphans
|
||||
|
||||
- name: Prune unused Docker resources
|
||||
run: docker image prune -f
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,13 +1,16 @@
|
||||
icecast2/fallback_music/*
|
||||
!icecast2/fallback_music/.gitkeep
|
||||
certbot/conf
|
||||
certbot/www
|
||||
backend/token/
|
||||
.env
|
||||
|
||||
gitea/config/app.ini
|
||||
gitea/data/*
|
||||
gitea-runner/data/*
|
||||
|
||||
# Will add in future (webpack)
|
||||
nginx/vue/crates/
|
||||
# Rust build artifacts
|
||||
**/target/
|
||||
|
||||
# Logs
|
||||
logs
|
||||
@@ -47,5 +50,6 @@ coverage
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
|
||||
.deploy
|
||||
*.xcf
|
||||
|
||||
70
CLAUDE.md
Normal file
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
|
||||
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
module adam-french.co.uk/backend
|
||||
|
||||
go 1.24.0
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/99designs/gqlgen v0.17.88
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
|
||||
github.com/vektah/gqlparser/v2 v2.5.32
|
||||
github.com/zmb3/spotify/v2 v2.4.3
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect
|
||||
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
@@ -22,10 +27,11 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
@@ -41,7 +47,7 @@ require (
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
||||
github.com/sosodev/duration v1.4.0 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
@@ -50,12 +56,11 @@ require (
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/mod v0.29.0 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
google.golang.org/appengine v1.6.7 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
@@ -31,10 +31,22 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
|
||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc=
|
||||
github.com/99designs/gqlgen v0.17.88/go.mod h1:qeqYFEgOeSKqWedOjogPizimp2iu4E23bdPvl4jTYic=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
||||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
@@ -50,6 +62,10 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
|
||||
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
@@ -71,10 +87,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
@@ -102,10 +120,7 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
@@ -129,12 +144,16 @@ github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hf
|
||||
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
@@ -178,6 +197,10 @@ github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQ
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
|
||||
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
@@ -203,6 +226,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
|
||||
github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
|
||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
@@ -226,8 +251,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -260,8 +285,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -294,14 +319,13 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5 h1:Ati8dO7+U7mxpkPSxBZQEvzHVUYB/MqCklCN8ig5w/o=
|
||||
golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
@@ -315,8 +339,8 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
|
||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -351,8 +375,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
@@ -368,8 +392,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -415,8 +439,8 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc
|
||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -443,7 +467,6 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
|
||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
@@ -499,12 +522,14 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
59
backend/gqlgen.yml
Normal file
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,11 +1,11 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"adam-french.co.uk/backend/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -27,6 +27,28 @@ func (store *Store) AuthMiddlewear(ctx *gin.Context) {
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
func (store *Store) AdminMiddleware(ctx *gin.Context) {
|
||||
claims, exists := ctx.Get("userClaims")
|
||||
if !exists {
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
|
||||
mapClaims, ok := claims.(*jwt.MapClaims)
|
||||
if !ok {
|
||||
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid claims"})
|
||||
return
|
||||
}
|
||||
|
||||
admin, ok := (*mapClaims)["admin"].(bool)
|
||||
if !ok || !admin {
|
||||
ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Next()
|
||||
}
|
||||
|
||||
func (store *Store) CheckToken(ctx *gin.Context) {
|
||||
access_token, err := ctx.Cookie("access_token")
|
||||
if err != nil {
|
||||
@@ -68,10 +90,9 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
|
||||
claims, err := store.Auth.VerifyJWT(refreshToken)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusUnauthorized, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("claims: %v\n", claims)
|
||||
|
||||
userIDF, ok := (*claims)["id"].(float64)
|
||||
if !ok {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid token claims"})
|
||||
@@ -93,6 +114,7 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||
ctx.SetCookie(
|
||||
"access_token",
|
||||
tokens.AccessToken,
|
||||
@@ -122,12 +144,12 @@ func (store *Store) Login(ctx *gin.Context) {
|
||||
|
||||
user := models.User{}
|
||||
if err := store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
|
||||
ctx.JSON(http.StatusNotFound, err.Error())
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil {
|
||||
ctx.JSON(http.StatusUnauthorized, err.Error())
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -137,6 +159,7 @@ func (store *Store) Login(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||
ctx.SetCookie(
|
||||
"access_token",
|
||||
tokens.AccessToken,
|
||||
@@ -164,6 +187,7 @@ func (store *Store) Logout(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
func removeCookies(ctx *gin.Context) {
|
||||
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||
ctx.SetCookie(
|
||||
"access_token",
|
||||
"",
|
||||
|
||||
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})
|
||||
}
|
||||
@@ -67,11 +67,6 @@ func (store *Store) CreatePost(ctx *gin.Context) {
|
||||
}
|
||||
userID := uint(userIDF)
|
||||
|
||||
if !(*claims)["admin"].(bool) {
|
||||
ctx.JSON(http.StatusForbidden, gin.H{"error": "you are not admin :("})
|
||||
return
|
||||
}
|
||||
|
||||
// Create post
|
||||
post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID}
|
||||
tx := store.DB.Create(&post)
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
"strings"
|
||||
|
||||
"github.com/rwcarlsen/goexif/exif"
|
||||
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
)
|
||||
|
||||
type ExtractedRowingData struct {
|
||||
TimeMinutes float64 `json:"timeMinutes"`
|
||||
TimeSeconds float64 `json:"timeSeconds"`
|
||||
Distance float64 `json:"distance"`
|
||||
TimeMinutes uint64 `json:"timeMinutes"`
|
||||
TimeSeconds uint64 `json:"timeSeconds"`
|
||||
Distance uint64 `json:"distance"`
|
||||
}
|
||||
|
||||
func (store *Store) GetRowing(ctx *gin.Context) {
|
||||
@@ -82,6 +82,13 @@ func (store *Store) CreateRowing(ctx *gin.Context) {
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString(data)
|
||||
|
||||
// Reject duplicates: same EXIF datetime already recorded
|
||||
var existing models.Rowing
|
||||
if err := store.DB.Where("date = ?", dateTaken).First(&existing).Error; err == nil {
|
||||
ctx.JSON(http.StatusConflict, gin.H{"error": "duplicate entry for this date"})
|
||||
return
|
||||
}
|
||||
|
||||
// Build the message with an image + text prompt
|
||||
message, err := store.ClaudeClient.Messages.New(context.Background(), anthropic.MessageNewParams{
|
||||
Model: anthropic.ModelClaudeHaiku4_5,
|
||||
@@ -110,21 +117,38 @@ No text, no markdown, no explanation. Just the JSON object.`),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to analyze image"})
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{
|
||||
"raw": message.Content[0].Text,
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(message.Content) == 0 {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "empty response from Claude"})
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{
|
||||
"raw": message.Content[0].Text,
|
||||
"error": "empty response from Claude",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
extractedData := ExtractedRowingData{}
|
||||
err = json.Unmarshal([]byte(message.Content[0].Text), &extractedData)
|
||||
raw := message.Content[0].Text
|
||||
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimPrefix(raw, "```json")
|
||||
raw = strings.TrimPrefix(raw, "```")
|
||||
raw = strings.TrimSuffix(raw, "```")
|
||||
raw = strings.TrimSpace(raw)
|
||||
|
||||
err = json.Unmarshal([]byte(raw), &extractedData)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse JSON response"})
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{
|
||||
"error": "failed to parse JSON response",
|
||||
"detail": err.Error(),
|
||||
"raw": raw,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -134,15 +158,39 @@ No text, no markdown, no explanation. Just the JSON object.`),
|
||||
}
|
||||
|
||||
totalSeconds := extractedData.TimeMinutes*60 + extractedData.TimeSeconds
|
||||
totalDuration := time.Duration(totalSeconds * float64(time.Second))
|
||||
per500m := time.Duration(totalSeconds / extractedData.Distance * 500 * float64(time.Second))
|
||||
|
||||
// Validate for anomalous values
|
||||
const (
|
||||
minDistance = 100 // metres
|
||||
maxDistance = 100000 // metres
|
||||
minTotalSecs = 30 // 30 seconds
|
||||
maxTotalSecs = 7200 // 2 hours
|
||||
minPacePer500m = 80 // ~1:20 /500m (faster than any human)
|
||||
maxPacePer500m = 150 // ~2:30 /500m (slow, not important)
|
||||
)
|
||||
if extractedData.Distance < minDistance || extractedData.Distance > maxDistance {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous distance value"})
|
||||
return
|
||||
}
|
||||
if totalSeconds < minTotalSecs || totalSeconds > maxTotalSecs {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous time value"})
|
||||
return
|
||||
}
|
||||
|
||||
per500m := float64(totalSeconds) / float64(extractedData.Distance) * 500.0
|
||||
if per500m < minPacePer500m || per500m > maxPacePer500m {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous pace value"})
|
||||
return
|
||||
}
|
||||
|
||||
calories := float64(extractedData.Distance) / 7500.0 * 500.0
|
||||
|
||||
rowing := models.Rowing{
|
||||
Date: dateTaken,
|
||||
Time: totalDuration,
|
||||
Time: totalSeconds,
|
||||
TimePer500m: per500m,
|
||||
Distance: extractedData.Distance,
|
||||
Calories: extractedData.Distance / 7500.0 * 500.0,
|
||||
Calories: calories,
|
||||
}
|
||||
|
||||
if err := store.DB.Create(&rowing).Error; err != nil {
|
||||
|
||||
@@ -53,10 +53,16 @@ func (store *Store) ListeningTo(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
func (store *Store) RecentlyPlayed(ctx *gin.Context) {
|
||||
if store.SpotifyClient == nil {
|
||||
ctx.JSON(500, gin.H{"error": "Spotify not authenticated"})
|
||||
return
|
||||
}
|
||||
|
||||
opts := spotify.RecentlyPlayedOptions{Limit: 3}
|
||||
|
||||
if store.RecentSongsFresh() {
|
||||
ctx.JSON(200, *store.RecentSongs)
|
||||
return
|
||||
}
|
||||
|
||||
played, err := store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts)
|
||||
|
||||
@@ -14,6 +14,10 @@ type UserCredentials struct {
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type SetAdminInput struct {
|
||||
Admin *bool `json:"admin" binding:"required"`
|
||||
}
|
||||
|
||||
func (store *Store) CreateUser(ctx *gin.Context) {
|
||||
var input UserCredentials
|
||||
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
||||
@@ -31,32 +35,9 @@ func (store *Store) CreateUser(ctx *gin.Context) {
|
||||
tx := store.DB.Create(&user)
|
||||
if tx.Error != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
|
||||
}
|
||||
|
||||
// Generate JWT token
|
||||
tokens, err := store.Auth.GenerateJWT(&user)
|
||||
if err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetCookie(
|
||||
"access_token",
|
||||
tokens.AccessToken,
|
||||
int(store.Auth.Config.AccessTokenLifetime.Seconds()),
|
||||
store.Auth.Config.Endpoint,
|
||||
store.Auth.Config.Domain,
|
||||
true, true,
|
||||
)
|
||||
ctx.SetCookie(
|
||||
"refresh_token",
|
||||
tokens.RefreshToken,
|
||||
int(store.Auth.Config.RefreshTokenLifetime.Seconds()),
|
||||
store.Auth.Config.Endpoint,
|
||||
store.Auth.Config.Domain,
|
||||
true, true,
|
||||
)
|
||||
|
||||
ctx.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
@@ -109,6 +90,52 @@ func (store *Store) UpdateUser(ctx *gin.Context) {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "will be implemented"})
|
||||
}
|
||||
|
||||
func (store *Store) SetUserAdmin(ctx *gin.Context) {
|
||||
claimsVal, ok := ctx.Get("userClaims")
|
||||
if !ok {
|
||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"})
|
||||
return
|
||||
}
|
||||
claims, ok := claimsVal.(*jwt.MapClaims)
|
||||
if !ok {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
|
||||
return
|
||||
}
|
||||
callerIDF, ok := (*claims)["id"].(float64)
|
||||
if !ok {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"})
|
||||
return
|
||||
}
|
||||
callerID := uint(callerIDF)
|
||||
|
||||
targetID := ctx.Param("id")
|
||||
|
||||
var input SetAdminInput
|
||||
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var user models.User
|
||||
if err := store.DB.First(&user, targetID).Error; err != nil {
|
||||
ctx.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if user.ID == callerID {
|
||||
ctx.JSON(http.StatusBadRequest, gin.H{"error": "cannot change your own admin status"})
|
||||
return
|
||||
}
|
||||
|
||||
user.Admin = *input.Admin
|
||||
if err := store.DB.Save(&user).Error; err != nil {
|
||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, user)
|
||||
}
|
||||
|
||||
func (store *Store) DeleteUser(ctx *gin.Context) {
|
||||
claimsVal, ok := ctx.Get("userClaims")
|
||||
if !ok {
|
||||
@@ -141,6 +168,7 @@ func (store *Store) DeleteUser(ctx *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||
ctx.SetCookie(
|
||||
"access_token",
|
||||
"",
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
func (store *Store) ConnectWebSocket(ctx *gin.Context) {
|
||||
conn, err := services.Upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
|
||||
if err != nil {
|
||||
ctx.JSON(500, gin.H{"error": err.Error()})
|
||||
// Upgrader already wrote the HTTP error response, so just return
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -20,4 +20,29 @@ type Store struct {
|
||||
|
||||
RecentSongs *[]spotify.RecentlyPlayedItem
|
||||
RecentSongsFetchedAt time.Time
|
||||
|
||||
GiteaHost string
|
||||
GiteaPort string
|
||||
GiteaFeed *services.GiteaFeedResponse
|
||||
GiteaFeedFetchedAt time.Time
|
||||
|
||||
SteamAPIKey string
|
||||
SteamID string
|
||||
SteamRecentGames []services.SteamRecentGame
|
||||
SteamOnline bool
|
||||
SteamFetchedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) GiteaFeedFresh() bool {
|
||||
if s.GiteaFeed == nil {
|
||||
return false
|
||||
}
|
||||
return time.Since(s.GiteaFeedFetchedAt) < time.Minute
|
||||
}
|
||||
|
||||
func (s *Store) SteamFresh() bool {
|
||||
if s.SteamRecentGames == nil {
|
||||
return false
|
||||
}
|
||||
return time.Since(s.SteamFetchedAt) < 5*time.Minute
|
||||
}
|
||||
|
||||
@@ -7,15 +7,18 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/99designs/gqlgen/graphql/handler"
|
||||
"github.com/99designs/gqlgen/graphql/playground"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"adam-french.co.uk/backend/graph"
|
||||
"adam-french.co.uk/backend/handlers"
|
||||
"adam-french.co.uk/backend/services"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logsDir := "/backend/logs"
|
||||
logFile, err := os.OpenFile(logsDir+"/go.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
logFile, err := os.OpenFile(logsDir+"/go.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -38,6 +41,11 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if os.Getenv("SEED_DB") == "true" {
|
||||
services.SeedDatabase(db)
|
||||
}
|
||||
domainName := os.Getenv("DOMAIN")
|
||||
services.InitWebSocket(db, domainName)
|
||||
|
||||
// SPOTIFY
|
||||
spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE")
|
||||
@@ -53,7 +61,6 @@ func main() {
|
||||
claudeClient := services.InitClaude(&claudeConfig)
|
||||
|
||||
authSecret := os.Getenv("BACKEND_SECRET")
|
||||
domainName := os.Getenv("DOMAIN")
|
||||
backendEndpoint := os.Getenv("BACKEND_ENDPOINT")
|
||||
accessTokenLifetime := 24 * time.Hour
|
||||
refreshTokenLifetime := 365 * 24 * time.Hour
|
||||
@@ -64,35 +71,43 @@ func main() {
|
||||
notesConfig := services.NotesConfig{Dir: notesDir}
|
||||
notes := services.InitNotes(¬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)
|
||||
admin := r.Group("/", store.AuthMiddlewear, store.AdminMiddleware)
|
||||
|
||||
// FAVORITES
|
||||
r.GET("/favorites", store.GetFavorites)
|
||||
protected.POST("/favorites", store.CreateFavorite)
|
||||
admin.POST("/favorites", store.CreateFavorite)
|
||||
|
||||
// ROWING
|
||||
r.GET("/rowing", store.GetRowing)
|
||||
protected.POST("/rowing", store.CreateRowing)
|
||||
admin.POST("/rowing", store.CreateRowing)
|
||||
|
||||
// ACTIVITIES
|
||||
r.GET("/activity", store.GetActivity)
|
||||
protected.POST("/activity", store.CreateActivity)
|
||||
admin.POST("/activity", store.CreateActivity)
|
||||
|
||||
// POSTS
|
||||
r.GET("/posts", store.GetPosts)
|
||||
protected.POST("/posts", store.CreatePost)
|
||||
admin.POST("/posts", store.CreatePost)
|
||||
r.GET("/posts/:id", store.GetPost)
|
||||
protected.PUT("/posts/:id", store.UpdatePost)
|
||||
protected.DELETE("/posts/:id", store.DeletePost)
|
||||
admin.PUT("/posts/:id", store.UpdatePost)
|
||||
admin.DELETE("/posts/:id", store.DeletePost)
|
||||
|
||||
// USERS
|
||||
r.GET("/user/:id", store.GetUser)
|
||||
protected.PUT("/user/:id", store.UpdateUser)
|
||||
protected.DELETE("/user/:id", store.DeleteUser)
|
||||
admin.PUT("/user/:id", store.UpdateUser)
|
||||
admin.DELETE("/user/:id", store.DeleteUser)
|
||||
r.GET("/user", store.GetUsers)
|
||||
r.POST("/user", store.CreateUser)
|
||||
admin.POST("/user", store.CreateUser)
|
||||
admin.PATCH("/user/:id/admin", store.SetUserAdmin)
|
||||
|
||||
// AUTH
|
||||
r.POST("/auth/login", store.Login)
|
||||
@@ -108,10 +123,22 @@ func main() {
|
||||
|
||||
// MESSAGES
|
||||
r.GET("/ws", store.ConnectWebSocket)
|
||||
protected.POST("/messages/upload", store.UploadMessageFile)
|
||||
|
||||
// NOTES
|
||||
r.GET("/notes/*path", store.GetNoteFile)
|
||||
|
||||
// GRAPHQL
|
||||
gqlSrv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{
|
||||
Resolvers: &graph.Resolver{Store: &store},
|
||||
}))
|
||||
r.POST("/graphql", graph.AuthContextMiddleware(auth), func(c *gin.Context) {
|
||||
gqlSrv.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
r.GET("/graphql", func(c *gin.Context) {
|
||||
playground.Handler("GraphQL Playground", "/graphql").ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
|
||||
// HELLO WORLD
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"message": "Hello World"})
|
||||
|
||||
@@ -30,8 +30,8 @@ type Post struct {
|
||||
type Message struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
Content string `json:"text"`
|
||||
AuthorID uint `json:"-"`
|
||||
Author *User `gorm:"foreignKey:AuthorID" json:"author"`
|
||||
AuthorID uint `json:"authorId"`
|
||||
FileURL string `json:"fileUrl,omitempty"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
@@ -61,8 +61,8 @@ type Rowing struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
Date time.Time `json:"date"`
|
||||
Time time.Duration `json:"time"`
|
||||
TimePer500m time.Duration `json:"timePer500m"`
|
||||
Distance float64 `json:"distance"`
|
||||
Time uint64 `json:"time"`
|
||||
Distance uint64 `json:"distance"`
|
||||
TimePer500m float64 `json:"timePer500m"`
|
||||
Calories float64 `json:"calories"`
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ func migrateDatabase(db *gorm.DB) error {
|
||||
&models.Activity{},
|
||||
&models.Favorite{},
|
||||
&models.Rowing{},
|
||||
&models.Message{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
72
backend/services/gitea.go
Normal file
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")
|
||||
}
|
||||
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
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"adam-french.co.uk/backend/models"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const maxMessages = 50
|
||||
|
||||
var allowedDomain string
|
||||
|
||||
var Upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
origin = strings.TrimPrefix(origin, "https://")
|
||||
origin = strings.TrimPrefix(origin, "http://")
|
||||
// Strip port for localhost comparisons (e.g. "localhost:80")
|
||||
host := strings.Split(origin, ":")[0]
|
||||
return origin == allowedDomain || origin == "www."+allowedDomain || host == "localhost"
|
||||
},
|
||||
}
|
||||
|
||||
var (
|
||||
clients = make(map[*websocket.Conn]bool)
|
||||
messages = make([]models.Message, 0)
|
||||
mu sync.Mutex
|
||||
clients = make(map[*websocket.Conn]bool)
|
||||
mu sync.Mutex
|
||||
wsDB *gorm.DB
|
||||
nextAuthorID uint
|
||||
)
|
||||
|
||||
const (
|
||||
rateLimitWindow = time.Second
|
||||
rateLimitMaxMsgs = 10
|
||||
)
|
||||
|
||||
func InitWebSocket(database *gorm.DB, domain string) {
|
||||
wsDB = database
|
||||
allowedDomain = domain
|
||||
}
|
||||
|
||||
func HandleWebSocket(conn *websocket.Conn) {
|
||||
defer conn.Close()
|
||||
|
||||
mu.Lock()
|
||||
clients[conn] = true
|
||||
nextAuthorID++
|
||||
authorID := nextAuthorID
|
||||
|
||||
// Send existing message history to new client
|
||||
for _, msg := range messages {
|
||||
var history []models.Message
|
||||
wsDB.Order("created_at ASC").Limit(maxMessages).Find(&history)
|
||||
|
||||
for _, msg := range history {
|
||||
if err := conn.WriteJSON(msg); err != nil {
|
||||
mu.Unlock()
|
||||
return
|
||||
@@ -35,17 +68,32 @@ func HandleWebSocket(conn *websocket.Conn) {
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
msgCount := 0
|
||||
windowStart := time.Now()
|
||||
|
||||
for {
|
||||
var incoming models.Message
|
||||
if err := conn.ReadJSON(&incoming); err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
incoming.CreatedAt = time.Now()
|
||||
now := time.Now()
|
||||
if now.Sub(windowStart) > rateLimitWindow {
|
||||
msgCount = 0
|
||||
windowStart = now
|
||||
}
|
||||
msgCount++
|
||||
if msgCount > rateLimitMaxMsgs {
|
||||
continue
|
||||
}
|
||||
|
||||
incoming.AuthorID = authorID
|
||||
|
||||
// Store and broadcast
|
||||
mu.Lock()
|
||||
messages = append(messages, incoming)
|
||||
wsDB.Create(&incoming)
|
||||
wsDB.Where("id NOT IN (?)",
|
||||
wsDB.Model(&models.Message{}).Select("id").Order("created_at DESC").Limit(maxMessages),
|
||||
).Delete(&models.Message{})
|
||||
|
||||
for client := range clients {
|
||||
if err := client.WriteJSON(incoming); err != nil {
|
||||
@@ -56,7 +104,6 @@ func HandleWebSocket(conn *websocket.Conn) {
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// Cleanup on disconnect
|
||||
mu.Lock()
|
||||
delete(clients, conn)
|
||||
mu.Unlock()
|
||||
|
||||
17
docker-compose.dev.yml
Normal file
17
docker-compose.dev.yml
Normal file
@@ -0,0 +1,17 @@
|
||||
services:
|
||||
vue:
|
||||
command: ["npm", "run", "dev"]
|
||||
volumes:
|
||||
- ./vue:/app
|
||||
- /app/node_modules
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
nginx:
|
||||
environment:
|
||||
- DEV_MODE=true
|
||||
- SEED_DB=true
|
||||
ports:
|
||||
- 80:80
|
||||
certbot:
|
||||
profiles:
|
||||
- disabled
|
||||
@@ -4,8 +4,20 @@ networks:
|
||||
|
||||
volumes:
|
||||
dbdata:
|
||||
uploads:
|
||||
vue_dist:
|
||||
|
||||
services:
|
||||
vue:
|
||||
build:
|
||||
context: ./vue
|
||||
dockerfile: Dockerfile
|
||||
container_name: vue
|
||||
volumes:
|
||||
- vue_dist:/output
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
nginx:
|
||||
build:
|
||||
context: ./nginx
|
||||
@@ -14,6 +26,7 @@ services:
|
||||
env_file: ./.env
|
||||
restart: always
|
||||
depends_on:
|
||||
- vue
|
||||
- backend
|
||||
- icecast2
|
||||
- gitea
|
||||
@@ -25,6 +38,8 @@ services:
|
||||
volumes:
|
||||
- ./certbot/conf:/etc/letsencrypt
|
||||
- ./certbot/www:/var/www/certbot
|
||||
- uploads:/uploads
|
||||
- vue_dist:/etc/nginx/html
|
||||
|
||||
certbot:
|
||||
image: certbot/certbot
|
||||
@@ -55,6 +70,7 @@ services:
|
||||
- ./backend/token/:/backend/token
|
||||
- ${OBSIDIAN_DIR}:/backend/notes
|
||||
- ./logs:/backend/logs
|
||||
- uploads:/backend/uploads
|
||||
|
||||
db:
|
||||
image: postgres:16
|
||||
@@ -66,8 +82,6 @@ services:
|
||||
- app-network
|
||||
volumes:
|
||||
- dbdata:/var/lib/postgresql/data
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
icecast2:
|
||||
build:
|
||||
@@ -79,12 +93,16 @@ services:
|
||||
- app-network
|
||||
env_file:
|
||||
- ./.env
|
||||
volumes:
|
||||
- ./icecast2/fallback_music:/music:ro
|
||||
ports:
|
||||
- "${ICECAST_PORT}:${ICECAST_PORT}"
|
||||
- "${LIQUIDSOAP_HARBOR_PORT:-8001}:${LIQUIDSOAP_HARBOR_PORT:-8001}"
|
||||
|
||||
gitea-runner:
|
||||
image: gitea/act_runner:latest
|
||||
container_name: "${GITEA_RUNNER_HOST}"
|
||||
profiles:
|
||||
- disabled
|
||||
environment:
|
||||
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME}
|
||||
CONFIG_FILE: /config.yaml
|
||||
@@ -94,7 +112,7 @@ services:
|
||||
volumes:
|
||||
- ./gitea-runner/config.yaml:/config.yaml
|
||||
- ./gitea-runner/data:/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- /var/run/docker.sock:/var/run/docker.sock # WARNING: Docker socket mount gives container host-level access. Runner is in 'disabled' profile to mitigate risk.
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
@@ -110,6 +128,11 @@ services:
|
||||
- GITEA__database__NAME=${POSTGRES_GITEA_DB}
|
||||
- GITEA__database__USER=${POSTGRES_USER}
|
||||
- GITEA__database__PASSWD=${POSTGRES_PASSWORD}
|
||||
- GITEA__server__LFS_JWT_SECRET=${GITEA_LFS_JWT_SECRET}
|
||||
- GITEA__security__INTERNAL_TOKEN=${GITEA_INTERNAL_TOKEN}
|
||||
- GITEA__oauth2__JWT_SECRET=${GITEA_OAUTH2_JWT_SECRET}
|
||||
- USER_UID=1000
|
||||
- USER_GID=1000
|
||||
restart: always
|
||||
volumes:
|
||||
- ./gitea/data:/var/lib/gitea
|
||||
@@ -117,7 +140,7 @@ services:
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "2222:2222"
|
||||
- "3000:3000"
|
||||
depends_on:
|
||||
- db
|
||||
|
||||
@@ -25,7 +25,7 @@ SSH_LISTEN_PORT = 2222
|
||||
BUILTIN_SSH_SERVER_USER = git
|
||||
LFS_START_SERVER = true
|
||||
DOMAIN = stppi.local
|
||||
LFS_JWT_SECRET = XHIJprS_aMv0tizioZpUD38GGqTtNMFXMz1R6LuPvjU
|
||||
LFS_JWT_SECRET =
|
||||
OFFLINE_MODE = true
|
||||
|
||||
[database]
|
||||
@@ -34,7 +34,7 @@ DB_TYPE = postgres
|
||||
HOST = db
|
||||
NAME = gitea
|
||||
USER = postgres
|
||||
PASSWD = password
|
||||
PASSWD =
|
||||
SCHEMA =
|
||||
SSL_MODE = disable
|
||||
LOG_SQL = false
|
||||
@@ -60,7 +60,7 @@ INSTALL_LOCK = true
|
||||
SECRET_KEY =
|
||||
REVERSE_PROXY_LIMIT = 1
|
||||
REVERSE_PROXY_TRUSTED_PROXIES = *
|
||||
INTERNAL_TOKEN = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE3NzEyNDMyMTd9.yHsgFcEwDNWmZebftpe8tpWRFa5aR5tkpQuVYybeVaY
|
||||
INTERNAL_TOKEN =
|
||||
PASSWORD_HASH_ALGO = pbkdf2
|
||||
|
||||
[service]
|
||||
@@ -95,4 +95,4 @@ DEFAULT_MERGE_STYLE = merge
|
||||
DEFAULT_TRUST_MODEL = committer
|
||||
|
||||
[oauth2]
|
||||
JWT_SECRET = pYiwW8xxGi23gysl2pa-02Cf567Z5ERvR6DDFGIn2iQ
|
||||
JWT_SECRET =
|
||||
1
icecast2/.dockerignore
Normal file
1
icecast2/.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
fallback_music/
|
||||
@@ -1,19 +1,13 @@
|
||||
FROM debian:latest as builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
FROM savonet/liquidsoap:v2.3.2
|
||||
USER root
|
||||
RUN apt-get update \
|
||||
&& apt-get install --yes icecast2 gettext media-types
|
||||
# RUN apt-get install --yes liquidsoap
|
||||
|
||||
&& apt-get install --yes icecast2 gettext media-types \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN useradd radio
|
||||
RUN chown -R radio:radio /etc/icecast2 /var/log/icecast2
|
||||
# RUN chown -R radio:radio /etc/liquidsoap /var/log/liquidsoap
|
||||
RUN mkdir -p /music /etc/liquidsoap
|
||||
RUN chown -R radio:radio /etc/icecast2 /var/log/icecast2 /music /etc/liquidsoap
|
||||
USER radio
|
||||
|
||||
COPY icecast.xml.template /etc/icecast2/icecast.xml.template
|
||||
# COPY stream.liq.template /etc/liquidsoap/stream.liq.template
|
||||
COPY stream.liq.template /etc/liquidsoap/stream.liq.template
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
ENTRYPOINT [ "/entrypoint.sh" ]
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
#!/bin/sh
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Substitute environment variables into template
|
||||
envsubst < /etc/icecast2/icecast.xml.template > /etc/icecast2/icecast.xml
|
||||
# envsubst < /etc/liquidsoap/stream.liq.template > /etc/liquidsoap/stream.liq
|
||||
envsubst < /etc/liquidsoap/stream.liq.template > /etc/liquidsoap/stream.liq
|
||||
|
||||
# Run icecast with the generated config
|
||||
exec icecast2 -c /etc/icecast2/icecast.xml
|
||||
# exec liquidsoap /etc/liquidsoap/stream.liq
|
||||
# wait -n
|
||||
icecast2 -c /etc/icecast2/icecast.xml &
|
||||
sleep 2
|
||||
liquidsoap /etc/liquidsoap/stream.liq &
|
||||
wait -n
|
||||
kill $(jobs -p) 2>/dev/null || true
|
||||
exit 1
|
||||
|
||||
@@ -1,24 +1,17 @@
|
||||
[general]
|
||||
duration = 0 # 0 = run forever
|
||||
bufferSecs = 5 # buffer size in seconds
|
||||
reconnect = yes # reconnect on failure
|
||||
reconnectDelay = 5
|
||||
settings.server.telnet := false
|
||||
|
||||
[input]
|
||||
device = pulse # PulseAudio input
|
||||
sampleRate = 44100 # in Hz
|
||||
bitsPerSample = 16
|
||||
channel = 2
|
||||
music = playlist("/music", mode="randomize", reload_mode="watch")
|
||||
|
||||
[icecast2-0]
|
||||
bitrateMode = cbr
|
||||
bitrate = 128 # kbps
|
||||
format = mp3
|
||||
server = ${ICECAST_HOST}
|
||||
port = ${ICECAST_PORT}
|
||||
password = ${ICECAST_SOURCE_PASSWORD}
|
||||
mountPoint = ${ICECAST_MOUNT}
|
||||
name = "Live DJ stream"
|
||||
description = "Live microphone stream"
|
||||
genre = "Various"
|
||||
public = yes
|
||||
live = input.harbor("${LIQUIDSOAP_HARBOR_MOUNT}", port=${LIQUIDSOAP_HARBOR_PORT}, password="${ICECAST_SOURCE_PASSWORD}")
|
||||
|
||||
radio = amplify(0.7, fallback(track_sensitive=false, [live, music, blank()]))
|
||||
|
||||
output.icecast(
|
||||
%mp3,
|
||||
host="localhost",
|
||||
port=${ICECAST_PORT},
|
||||
password="${ICECAST_SOURCE_PASSWORD}",
|
||||
mount="${ICECAST_MOUNT}",
|
||||
fallible=true,
|
||||
radio
|
||||
)
|
||||
|
||||
2
nginx/.dockerignore
Normal file
2
nginx/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
**/.git
|
||||
**/.DS_Store
|
||||
@@ -1,32 +1,9 @@
|
||||
FROM nginx:latest
|
||||
RUN rm -rf /etc/nginx/html/*
|
||||
|
||||
# Install dependencies needed to add NodeSource repo
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
build-essential \
|
||||
git \
|
||||
gettext-base \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Node.js LTS + npm
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
|
||||
&& apt-get install -y nodejs \
|
||||
&& npm install -g npm@latest
|
||||
|
||||
|
||||
WORKDIR /app
|
||||
COPY vue/ ./
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
|
||||
|
||||
RUN mkdir -p /etc/nginx/html \
|
||||
&& cp -r ./dist/* /etc/nginx/html/
|
||||
|
||||
RUN rm -rf /etc/nginx/html/* && \
|
||||
apt-get update && apt-get install -y gettext-base && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
COPY nginx.conf.template /etc/nginx/nginx.conf.template
|
||||
COPY nginx_setup.conf.template /etc/nginx/nginx_setup.conf.template
|
||||
COPY robots.txt /etc/nginx/html/robots.txt
|
||||
COPY nginx_dev.conf.template /etc/nginx/nginx_dev.conf.template
|
||||
COPY entrypoint.sh /entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
@@ -1,15 +1,38 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Check if certificate exists
|
||||
if [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then
|
||||
echo "Certificates found. Using production nginx config."
|
||||
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT}' \
|
||||
< /etc/nginx/nginx.conf.template \
|
||||
> /etc/nginx/nginx.conf
|
||||
# Check if dev mode, certificate exists, or setup mode
|
||||
if [ "$DEV_MODE" = "true" ]; then
|
||||
echo "Dev mode. Using HTTP-only nginx config."
|
||||
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT}' \
|
||||
</etc/nginx/nginx_dev.conf.template \
|
||||
>/etc/nginx/nginx.conf
|
||||
elif [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then
|
||||
echo "Certificates found. Using production nginx config."
|
||||
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT}' \
|
||||
</etc/nginx/nginx.conf.template \
|
||||
>/etc/nginx/nginx.conf
|
||||
else
|
||||
echo "Certificates NOT found. Using setup nginx config."
|
||||
envsubst '${DOMAIN}' < /etc/nginx/nginx_setup.conf.template > /etc/nginx/nginx.conf
|
||||
echo "Certificates NOT found. Using setup nginx config."
|
||||
envsubst '${DOMAIN}' </etc/nginx/nginx_setup.conf.template >/etc/nginx/nginx.conf
|
||||
fi
|
||||
|
||||
# Ensure upload directory is traversable by nginx worker
|
||||
chmod 755 /uploads 2>/dev/null || true
|
||||
|
||||
# Wait for Vue assets in production mode
|
||||
if [ "$DEV_MODE" != "true" ]; then
|
||||
echo "Waiting for Vue assets..."
|
||||
elapsed=0
|
||||
while [ ! -f /etc/nginx/html/index.html ] && [ $elapsed -lt 120 ]; do
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
if [ ! -f /etc/nginx/html/index.html ]; then
|
||||
echo "WARNING: Vue assets not found after 120s, starting nginx anyway"
|
||||
else
|
||||
echo "Vue assets ready."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start nginx
|
||||
|
||||
@@ -9,6 +9,12 @@ http {
|
||||
server_tokens off;
|
||||
charset utf-8;
|
||||
|
||||
client_max_body_size 10M;
|
||||
|
||||
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
|
||||
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
|
||||
|
||||
log_format compact
|
||||
'$remote_addr "$request" $status rt=$request_time';
|
||||
|
||||
@@ -18,6 +24,22 @@ http {
|
||||
text/javascript mjs;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/xml
|
||||
image/svg+xml
|
||||
font/woff2
|
||||
application/font-woff2;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name $DOMAIN www.$DOMAIN;
|
||||
@@ -55,6 +77,31 @@ http {
|
||||
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
|
||||
|
||||
# Vite hashed assets - immutable, cache 1 year
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Fonts - cache 30 days
|
||||
location /fonts/ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
# Images - cache 7 days
|
||||
location /img/ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
location /uploads/ {
|
||||
alias /uploads/;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Content-Disposition "inline" always;
|
||||
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'none'; script-src 'none'" always;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
@@ -71,13 +118,53 @@ http {
|
||||
|
||||
location = /img/stamps/mine.gif {
|
||||
add_header Access-Control-Allow-Origin *;
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
location /sound/ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
location $BACKEND_ENDPOINT {
|
||||
return 301 $BACKEND_ENDPOINT/;
|
||||
}
|
||||
|
||||
location $BACKEND_ENDPOINT/ws {
|
||||
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location $BACKEND_ENDPOINT/auth/login {
|
||||
limit_req zone=login burst=3 nodelay;
|
||||
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location $BACKEND_ENDPOINT/messages/upload {
|
||||
limit_req zone=upload burst=3 nodelay;
|
||||
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location $BACKEND_ENDPOINT/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
134
nginx/nginx_dev.conf.template
Normal file
134
nginx/nginx_dev.conf.template
Normal file
@@ -0,0 +1,134 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
server_tokens off;
|
||||
charset utf-8;
|
||||
|
||||
client_max_body_size 10M;
|
||||
|
||||
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
|
||||
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
|
||||
|
||||
log_format compact
|
||||
'$remote_addr "$request" $status rt=$request_time';
|
||||
|
||||
access_log /var/log/nginx/access.log compact;
|
||||
|
||||
types {
|
||||
text/javascript mjs;
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/xml
|
||||
image/svg+xml
|
||||
font/woff2
|
||||
application/font-woff2;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name $DOMAIN www.$DOMAIN;
|
||||
|
||||
location /uploads/ {
|
||||
alias /uploads/;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Content-Disposition "inline" always;
|
||||
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'none'; script-src 'none'" always;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://vue:5173;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location $BACKEND_ENDPOINT {
|
||||
return 301 $BACKEND_ENDPOINT/;
|
||||
}
|
||||
|
||||
location $BACKEND_ENDPOINT/ws {
|
||||
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location $BACKEND_ENDPOINT/auth/login {
|
||||
limit_req zone=login burst=3 nodelay;
|
||||
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location $BACKEND_ENDPOINT/messages/upload {
|
||||
limit_req zone=upload burst=3 nodelay;
|
||||
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location $BACKEND_ENDPOINT/ {
|
||||
limit_req zone=api burst=20 nodelay;
|
||||
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /radio {
|
||||
return 301 /radio/;
|
||||
}
|
||||
|
||||
location /radio/ {
|
||||
proxy_pass http://$ICECAST_HOST:$ICECAST_PORT/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /gitea {
|
||||
return 301 /gitea/;
|
||||
}
|
||||
|
||||
location /gitea/ {
|
||||
proxy_pass http://$GITEA_HOST:$GITEA_PORT/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,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,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>
|
||||
89
readme.md
89
readme.md
@@ -1,14 +1,83 @@
|
||||
# Introduction
|
||||
# My Web
|
||||
|
||||

|
||||
## Important TODO
|
||||
|
||||
- Get a new background
|
||||
|
||||

|
||||
|
||||
Welcome to the source code for my website! Please contact me if you would like to collaborate and thank you for visiting.
|
||||
|
||||
This website is currently self hosted on my Rasberry PI. Any interference and the killswitch will activate and stop the UK national grid power system so please don't tamper with my domain :).
|
||||
This website is currently self hosted on my Raspberry Pi. Any interference and the killswitch will activate and stop the UK national grid power system so please don't tamper with my domain :).
|
||||
|
||||
# Future ideas
|
||||
## Architecture
|
||||
|
||||
- Rust to wasm
|
||||
All services run in Docker containers orchestrated by Docker Compose:
|
||||
|
||||
```
|
||||
nginx (80, 443) ── Frontend SPA + Reverse Proxy
|
||||
backend (8080) ── Go API
|
||||
db (5432) ── PostgreSQL 16
|
||||
icecast2 (8000) ── Audio Streaming
|
||||
gitea (3000) ── Self-Hosted Git
|
||||
gitea-runner ── CI/CD Runner
|
||||
certbot ── SSL Certificate Management
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
**Frontend** - Vue 3, Vite, Tailwind CSS v4, Pinia, Vue Router, markdown-it (wikilinks + KaTeX), Rust/WASM
|
||||
|
||||
**Backend** - Go (Gin), gqlgen (GraphQL), GORM, PostgreSQL, JWT auth, WebSockets
|
||||
|
||||
**Integrations** - Spotify API, Anthropic Claude API, Icecast2
|
||||
|
||||
**Infrastructure** - Docker Compose, Nginx, Let's Encrypt (Certbot), Gitea + Act Runner
|
||||
|
||||
## Features
|
||||
|
||||
- Spotify integration (currently playing, recently played)
|
||||
- Obsidian note viewer with wikilink and LaTeX support
|
||||
- Live radio streaming via Icecast2
|
||||
- Real-time chat over WebSockets
|
||||
- Blog with admin panel (CRUD)
|
||||
- Activity and rowing session tracking
|
||||
- Fan shrines (GTO, Evangelion, Demoman, Skip Skip Benben)
|
||||
- Self-hosted Git (Gitea) with CI/CD and commit feed on homepage
|
||||
- Claude AI integration
|
||||
|
||||
## Pages
|
||||
|
||||
| Route | Description |
|
||||
| -------------- | ------------------------------------- |
|
||||
| `/` | Home dashboard with grid layout |
|
||||
| `/admin` | Admin panel (authenticated) |
|
||||
| `/cv` | Curriculum Vitae |
|
||||
| `/bookmarks` | Bookmarks |
|
||||
| `/notes/:path` | Obsidian note viewer |
|
||||
| `/shrines` | Fan shrine index + individual shrines |
|
||||
|
||||
## API
|
||||
|
||||
The primary API is **GraphQL** at `POST /api/graphql` (with a playground at `GET /api/graphql`). Queries cover posts, users, favorites, activities, rowing sessions, Spotify (currently playing, recently played), Gitea feed, and messages.
|
||||
|
||||
REST endpoints handle auth (`/auth/*`), Spotify OAuth (`/spotify/*`), file uploads (`/messages/upload`), note serving (`/notes/*`), and WebSocket chat (`/api/ws`).
|
||||
|
||||
Protected endpoints require JWT authentication via `/auth/login` (tokens set as HTTP-only cookies).
|
||||
|
||||
## Local Testing (Dev Mode)
|
||||
|
||||
Run the full stack over plain HTTP without SSL certificates:
|
||||
|
||||
```
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
This uses an HTTP-only nginx config with all routing (SPA, backend proxy, radio, gitea) and disables certbot. Visit `http://localhost` to test.
|
||||
|
||||
## Future Ideas
|
||||
|
||||
- More Rust to WASM
|
||||
- ML for chatboards
|
||||
- Cache requests
|
||||
- Design more webpages
|
||||
@@ -17,12 +86,11 @@ This website is currently self hosted on my Rasberry PI. Any interference and th
|
||||
- Design shrines
|
||||
- Redis (not really but practical experience)
|
||||
|
||||
# .env
|
||||
## .env
|
||||
|
||||
These environment variables are found in the `.env` file. The use of environment variables can be found by reading the code so the security of the variable names are not significant.
|
||||
|
||||
```
|
||||
|
||||
POSTGRES_USER=
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_DB=
|
||||
@@ -42,6 +110,10 @@ BACKEND_HOST=
|
||||
BACKEND_SECRET=
|
||||
BACKEND_ENDPOINT=
|
||||
|
||||
CLAUDE_API_KEY=
|
||||
|
||||
SEED_DB=
|
||||
|
||||
OBSIDIAN_DIR=
|
||||
|
||||
SPOTIFY_CLIENT_ID=
|
||||
@@ -60,4 +132,7 @@ ICECAST_MOUNT=
|
||||
DOMAIN=
|
||||
EMAIL=
|
||||
|
||||
GITEA_LFS_JWT_SECRET=
|
||||
GITEA_INTERNAL_TOKEN=
|
||||
GITEA_OAUTH2_JWT_SECRET=
|
||||
```
|
||||
|
||||
5
vue/.dockerignore
Normal file
5
vue/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.vite
|
||||
dist
|
||||
**/.git
|
||||
**/.DS_Store
|
||||
7
vue/Dockerfile
Normal file
7
vue/Dockerfile
Normal file
@@ -0,0 +1,7 @@
|
||||
FROM node:22-slim
|
||||
RUN apt-get update && apt-get install -y make git && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
CMD ["sh", "-c", "npm run build -- --outDir /output --emptyOutDir"]
|
||||
@@ -1,6 +1,31 @@
|
||||
# my-vue-app
|
||||
# My Web - Frontend
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
Vue 3 SPA for [adam-french.co.uk](https://adam-french.co.uk). Built with Vite, Tailwind CSS v4, Pinia, and Vue Router.
|
||||
|
||||
## Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The Vite dev server proxies API requests:
|
||||
- `/api` -> `http://localhost:8080` (Go backend)
|
||||
- `/gitea` -> `http://localhost:3000` (Gitea)
|
||||
- `/radio` -> `http://localhost:8000` (Icecast2)
|
||||
|
||||
## Production Build
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
In production, the built `dist/` is served by Nginx inside a Docker container (see `../Dockerfile`).
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
@@ -9,30 +34,8 @@ This template should help get you started developing with Vue 3 in Vite.
|
||||
## Recommended Browser Setup
|
||||
|
||||
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||
- Firefox:
|
||||
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
136
vue/crates/stp_wasm/Cargo.lock
generated
Normal file
136
vue/crates/stp_wasm/Cargo.lock
generated
Normal file
@@ -0,0 +1,136 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.85"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "stp_wasm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.108"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.85"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
15
vue/crates/stp_wasm/Cargo.toml
Normal file
15
vue/crates/stp_wasm/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "stp_wasm"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
js-sys = "0.3.85"
|
||||
wasm-bindgen = "0.2.108"
|
||||
web-sys = { version = "0.3.85", features = [
|
||||
"console",
|
||||
"Document",
|
||||
"Element",
|
||||
"Window",
|
||||
"Animation",
|
||||
] }
|
||||
6
vue/crates/stp_wasm/src/lib.rs
Normal file
6
vue/crates/stp_wasm/src/lib.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub struct BadApplePlayer {
|
||||
is_playing: bool,
|
||||
}
|
||||
17
vue/index.html
Normal file
17
vue/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="Adam French's personal website" />
|
||||
<title>AF</title>
|
||||
<link rel="preconnect" href="https://i.scdn.co" crossorigin />
|
||||
<link rel="preconnect" href="https://cdn.akamai.steamstatic.com" crossorigin />
|
||||
<link rel="icon" type="/img/x-icon" href="/img/favicon.ico" />
|
||||
<link rel="preload" href="/fonts/big_noodle_titling.woff2" as="font" type="font/woff2" crossorigin />
|
||||
<link rel="preload" href="/fonts/CreatoDisplay-Bold.woff2" as="font" type="font/woff2" crossorigin />
|
||||
</head>
|
||||
<body id="app">
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
212
nginx/vue/package-lock.json → vue/package-lock.json
generated
212
nginx/vue/package-lock.json → vue/package-lock.json
generated
@@ -1024,9 +1024,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
|
||||
"integrity": "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1037,9 +1037,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.55.1.tgz",
|
||||
"integrity": "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1050,9 +1050,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.55.1.tgz",
|
||||
"integrity": "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1063,9 +1063,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.55.1.tgz",
|
||||
"integrity": "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1076,9 +1076,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.55.1.tgz",
|
||||
"integrity": "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1089,9 +1089,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.55.1.tgz",
|
||||
"integrity": "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1102,9 +1102,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.55.1.tgz",
|
||||
"integrity": "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1115,9 +1115,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.55.1.tgz",
|
||||
"integrity": "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1128,9 +1128,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1141,9 +1141,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1154,9 +1154,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1167,9 +1167,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -1180,9 +1180,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1193,9 +1193,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -1206,9 +1206,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1219,9 +1219,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -1232,9 +1232,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -1245,9 +1245,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1258,9 +1258,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.55.1.tgz",
|
||||
"integrity": "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1271,9 +1271,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.55.1.tgz",
|
||||
"integrity": "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1284,9 +1284,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.55.1.tgz",
|
||||
"integrity": "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1297,9 +1297,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.55.1.tgz",
|
||||
"integrity": "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1310,9 +1310,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.55.1.tgz",
|
||||
"integrity": "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1323,9 +1323,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.55.1.tgz",
|
||||
"integrity": "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1336,9 +1336,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.55.1.tgz",
|
||||
"integrity": "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2982,9 +2982,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.0",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz",
|
||||
"integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==",
|
||||
"version": "14.1.1",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
@@ -3245,9 +3245,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.55.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
|
||||
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
@@ -3260,31 +3260,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.55.1",
|
||||
"@rollup/rollup-android-arm64": "4.55.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.55.1",
|
||||
"@rollup/rollup-darwin-x64": "4.55.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.55.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.55.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.55.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.55.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.55.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.55.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.55.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.55.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.55.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.55.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.55.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.55.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.55.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.55.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.55.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.55.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||
"@rollup/rollup-android-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user