Compare commits
331 Commits
095cd72946
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| aa14b4c185 | |||
| 06a18eac3d | |||
| a8debe2f51 | |||
| 548f2350d2 | |||
| 3f5803d4fc | |||
| 60bd906251 | |||
| 7d888ea4cb | |||
| 3b14c3453c | |||
| 842943e7e8 | |||
| aa6de883be | |||
| 26a35719eb | |||
| 3844a32751 | |||
| b41e67fe1a | |||
| 9bcb21910d | |||
| a116ec2614 | |||
| 6f204d4164 | |||
| de803dea9f | |||
| a520944fe3 | |||
| 22a836cb95 | |||
| 12967c573b | |||
| 14233e88a8 | |||
| 2bcb47a1a1 | |||
| 8636dfedb9 | |||
| c20b1c2691 | |||
| 0b6ffedb70 | |||
| a14b78a1b9 | |||
| 254541a370 | |||
| f390bf82cc | |||
| b96b7d7a93 | |||
| 37171478b1 | |||
| 00364aca23 | |||
| 3d97ccf38c | |||
| 1e22bacdc9 | |||
| 8d10f75f2b | |||
| 68dca953f2 | |||
| c684fcb858 | |||
| 2b5745b946 | |||
| b56f8253d9 | |||
| 798c8e7f50 | |||
| 0e7f34edc7 | |||
| 7aff171ef8 | |||
| cc6a423ef0 | |||
| 759614e92d | |||
| 81c5684102 | |||
| a911e6ca69 | |||
| 66f32cdbd2 | |||
| 390f69858c | |||
| c3db00abf2 | |||
| 4a0300d4b4 | |||
| 18b50f1ce6 | |||
| 4d154ff837 | |||
| 869d9a168e | |||
| fc9d3c97bf | |||
| 0dc1c278c2 | |||
| a0f99d9fba | |||
| 8f3c369ed8 | |||
| 81f5fafb61 | |||
| c335bf14d6 | |||
| d344497393 | |||
| ee97ec9b23 | |||
| 34934e7d13 | |||
| 1d472d382b | |||
| 4ebe886579 | |||
| dd5412cb79 | |||
| 4000baf755 | |||
| a15aa040f4 | |||
| a03cce3e04 | |||
| 400d100426 | |||
| 7afd1be81b | |||
| cdcef5ba96 | |||
| 494f61f9c6 | |||
| 67771777cd | |||
| c4c642073d | |||
| 8da6364e6e | |||
| 6f2a32aabb | |||
| 75f4608d34 | |||
| 2e3d5f4dbe | |||
| 258d97757c | |||
| 4edc5b1b22 | |||
| bfefbb1d2a | |||
| 5d3c73d537 | |||
| 4c8573767d | |||
| 4d16dd8d17 | |||
| d04496ad11 | |||
| 6edca785ff | |||
| 74f606459f | |||
| ce1a1ee757 | |||
| 68b9985d99 | |||
| a967a249c2 | |||
| 8a6e34dd69 | |||
| f027506c87 | |||
| 54ab64c67d | |||
| 108f58e527 | |||
| e62424368b | |||
| e17a7a9807 | |||
| 67f3895a1e | |||
| 058ae3b3f1 | |||
| 282454140f | |||
| 6029066a94 | |||
| 01adee7941 | |||
| 1f6c540c1c | |||
| fa79fe9cdb | |||
| 83c130b5c3 | |||
| b6623de23a | |||
| 7e8e50f80a | |||
| a44011bf0b | |||
| d215333128 | |||
| 179f52d1d7 | |||
| 08c29a77a0 | |||
| e69942a7e8 | |||
| d268fea4be | |||
| d7178ac60a | |||
| b48a273916 | |||
| 24fd4dd00c | |||
| 75cede3b1b | |||
| 091bfcaef6 | |||
| 24bb0195e9 | |||
| ce091d3918 | |||
| 461729809e | |||
| 89119c1702 | |||
| 0f9695b8aa | |||
| 7007f8292d | |||
| f7d69f048e | |||
| 31d4b4c268 | |||
| 7f01b1a296 | |||
| 89d3d8eefb | |||
| 31a8c93c86 | |||
| 932e257152 | |||
| 619692687f | |||
| f1750a8b3e | |||
| 3c9d19d185 | |||
| 474f14b1e5 | |||
| 7798b54391 | |||
| d4a6343d5e | |||
| 264df132df | |||
| 747563c6c9 | |||
| fabd92bf36 | |||
| ac5f47fcaa | |||
| a8ef10498e | |||
| 0f801a864c | |||
| f9a8127714 | |||
| 8f57c15c24 | |||
| b2042ffe78 | |||
| 4e7377d9f0 | |||
| 8406582b2b | |||
| 283e02657e | |||
| 7a737f6d10 | |||
| 29350af2e0 | |||
| d3d3269d49 | |||
| 2b84730126 | |||
| 8c2e9ba9a5 | |||
| 6a6b9536ba | |||
| d3e948d558 | |||
| bbb493b544 | |||
| 3afcee2011 | |||
| 7d74a2fc07 | |||
| 570a823426 | |||
| 6dddcd4d7a | |||
| 69e158b871 | |||
| d857cce5dc | |||
| c2bbd7ad88 | |||
| 8627a7945e | |||
| 08125204c5 | |||
| a0215f7810 | |||
| c1ce3c31ba | |||
| 2becda2bd8 | |||
| 7381cda7b8 | |||
| 5999eccc21 | |||
| 7155255733 | |||
| 6ff30a37f7 | |||
| a4514ad98d | |||
| 84e18dddfa | |||
| b4ddb4d402 | |||
| 0360b1f7f1 | |||
| 36817277f9 | |||
| a03ce26824 | |||
| a10706506e | |||
| f29e937307 | |||
| 81cb2bc4b5 | |||
| 8b5ed9abec | |||
| 8cdab593ae | |||
| b63cc911a7 | |||
| e1fe281586 | |||
| 887d23af5b | |||
| 36aa7ed907 | |||
| d5065d19e0 | |||
| 15c721ea56 | |||
| b47d1a3df3 | |||
| 5b3cd267b6 | |||
| 6033a952af | |||
| 0ad7f4e009 | |||
| 6bf773487a | |||
| 2916afe206 | |||
| 17deec23ba | |||
| ad4d02228d | |||
| d5fbc0ee74 | |||
| 857f66cb37 | |||
| 5b041d7364 | |||
| 4be7e60394 | |||
| 27f74f6c2a | |||
| 5a19f09e17 | |||
| 469a225860 | |||
| cd1bcc7f39 | |||
| 14cacec1f5 | |||
| 7991c80176 | |||
| bad44a6ddd | |||
| 0b256863d6 | |||
| cb326ff8bf | |||
| 78d6c3d4f0 | |||
| c7dbf5b778 | |||
| a8d1b879be | |||
| f82389225c | |||
| 165852e738 | |||
| c58c19cc1e | |||
| 26ea0108e0 | |||
| 604576b46a | |||
| 33d72fd20a | |||
| d3cbc687d5 | |||
| d7b76e4742 | |||
| 64c2ba5562 | |||
| 6796367dbe | |||
| c2580c984d | |||
| 68db930049 | |||
| 63da086da2 | |||
| 6326a438dc | |||
| 7c980f1b1f | |||
| 141ceab7e6 | |||
| d03f9668ad | |||
| 41d6cf0dac | |||
| 1e3c6adf5e | |||
| 99ddd7d494 | |||
| 8e50537333 | |||
| 85a2325683 | |||
| 0a8a752433 | |||
| 4c396ef30f | |||
| 77e2c272cb | |||
| 1578a05762 | |||
| a6bc1d5126 | |||
| 2737b4f0d0 | |||
| 9fa953c969 | |||
| 5a45f1f427 | |||
| 4458844029 | |||
| 3200ef5bee | |||
| 0da6d3f0ed | |||
| 88ce32abeb | |||
| adcf1bda48 | |||
| 7450b5a624 | |||
| ab2b0a1e3d | |||
| ff82b8bdf9 | |||
| 1429a6a5cb | |||
| 7a71484ecc | |||
| e1563b55f4 | |||
| 4fbeabc3ae | |||
| a83b98eb2b | |||
| 5346b24999 | |||
| 3779a1cbcc | |||
| 3f39f6327c | |||
| 9dc9a3a063 | |||
| a6b543cf65 | |||
| 4a65836210 | |||
| 95635c86b3 | |||
| 3056b23b50 | |||
| 72013f5cdd | |||
| 7aa62659e5 | |||
| aa3f0a189d | |||
| 646f93136d | |||
| 54852eba82 | |||
| e43c07b30a | |||
| 190bc6076b | |||
| 88884121ab | |||
| e25fc5f1d1 | |||
| 5bcc65668e | |||
| 2c1ecce99a | |||
| f2ba3494b1 | |||
| d56bd5783d | |||
| f60636942f | |||
| b087172bb1 | |||
| 0c93c6bc27 | |||
| 48ae2f59ea | |||
| c9faa90abd | |||
| ef78974744 | |||
| 49499052b0 | |||
| dbb4914745 | |||
| 34fa96ddab | |||
| 8a9f3c373d | |||
| dc05ade798 | |||
| 1e47919a40 | |||
| 8e9734fca7 | |||
| da9a083f2d | |||
| 3c40eb9f08 | |||
| e016e3af46 | |||
| 0c91f512b4 | |||
| f63b61431b | |||
| f3ea83c477 | |||
| 4b5ed4787a | |||
| 747a403bcb | |||
| fe16ccab97 | |||
| 7bcb485fc6 | |||
| a3d73b12f4 | |||
| 47a8e6c35e | |||
| f885ff9175 | |||
| d574fa7692 | |||
| ac171f7846 | |||
| b5b86a2a37 | |||
| cfdb5b4d50 | |||
| 37580cdc42 | |||
| 711236b776 | |||
| 75454c2ed8 | |||
| 78c824c4c8 | |||
| ba3b933068 | |||
| 14c430bbad | |||
| 26c7422e34 | |||
| 470b1c79d8 | |||
| d849b606ec | |||
| 46a9da4c90 | |||
| 398a610cb2 | |||
| b506bae515 | |||
| 11ad0b5a83 | |||
| d7393e1419 | |||
| 0d32333c0c | |||
| 050a38a76f | |||
| bc43e9ed02 | |||
| 75b8b02825 | |||
| 5c69a1d0a7 | |||
| aa915e1071 | |||
| 7dc3f49273 | |||
| 21d3997a16 | |||
| c56ba217dd | |||
| 91804f1fe7 | |||
| 7e74ce5a2a | |||
| e92ac49140 |
27
.gitea/workflows/deploy.yaml
Normal file
27
.gitea/workflows/deploy.yaml
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
name: Deploy with Docker Compose
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- name: Pull changes
|
||||||
|
working-directory: /home/adamf/deploy/web_server
|
||||||
|
env:
|
||||||
|
GIT_SSH_COMMAND: "ssh -o StrictHostKeyChecking=no"
|
||||||
|
run: |
|
||||||
|
git config --global --add safe.directory /home/adamf/deploy/web_server
|
||||||
|
git fetch ssh://git@localhost:2222/adamf/web_server.git main
|
||||||
|
git reset --hard FETCH_HEAD
|
||||||
|
|
||||||
|
- name: Run docker compose up
|
||||||
|
working-directory: /home/adamf/deploy/web_server
|
||||||
|
env:
|
||||||
|
DOCKER_API_VERSION: "1.41"
|
||||||
|
run: docker compose up -d --build --remove-orphans
|
||||||
|
|
||||||
|
- name: Prune unused Docker resources
|
||||||
|
run: docker image prune -f
|
||||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -1,8 +1,25 @@
|
|||||||
|
icecast2/fallback_music/*
|
||||||
|
!icecast2/fallback_music/.gitkeep
|
||||||
|
searxng/settings.yml
|
||||||
certbot/conf
|
certbot/conf
|
||||||
certbot/www
|
certbot/www
|
||||||
backend/token/
|
backend/token/
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
gitea/data/*
|
||||||
|
gitea/config/app.ini
|
||||||
|
|
||||||
|
# Gitea runner
|
||||||
|
gitea-runner/.runner
|
||||||
|
gitea-runner/act_runner
|
||||||
|
gitea-runner/nohup.out
|
||||||
|
|
||||||
|
# Rust build artifacts
|
||||||
|
**/target/
|
||||||
|
|
||||||
|
# Generated WASM output
|
||||||
|
vue/src/wasm/
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
@@ -41,5 +58,7 @@ coverage
|
|||||||
# Vitest
|
# Vitest
|
||||||
__screenshots__/
|
__screenshots__/
|
||||||
|
|
||||||
|
|
||||||
.deploy
|
.deploy
|
||||||
*.xcf
|
*.xcf
|
||||||
|
sync-secrets.sh
|
||||||
|
|||||||
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).
|
||||||
359
README.md
Normal file
359
README.md
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
# My Web
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Welcome to the source code for my website! Please contact me if you would like to collaborate and thank you for visiting.
|
||||||
|
|
||||||
|
This website is self-hosted on my Raspberry Pi. Any interference or hax and the killswitch will activate and stop the UK national grid power system so please don't tamper with my domain :).
|
||||||
|
|
||||||
|
## The use of AI
|
||||||
|
|
||||||
|
This has been created with a heavy amount of AI. Initially, I began with articles from [medium](https://medium.com/) informing me of how to use Nginx to host static sites and reverse proxy to other services. They were incredible helpful and I wish I were able to give credit them, but sadly they were read too long ago.
|
||||||
|
|
||||||
|
After hearing all the hype on LinkedIn and feeling the pressure to keep up a high output without AI, I eventually caved in. There is an immense advantage not having to scan though documentation to find relevant functions, asking how other developers implement their infrastructure and finding what command will achieve your outcome quickly. Sure, it would be good to have that already cached in your own human memory. But my reasoning is that you _will_ have cached it after being reminded by AI enough times, similar to how flashcards work. I may not be an expert in the specific tool, though I've been able to get good enough at it for my own purposes.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
All services run in Docker containers orchestrated by Docker Compose behind Nginx as a reverse proxy on a single bridge network.
|
||||||
|
|
||||||
|
```
|
||||||
|
vue ── Frontend build (outputs dist to shared volume)
|
||||||
|
nginx (80, 443) ── Frontend SPA + Reverse Proxy
|
||||||
|
backend (8080) ── Go API (GraphQL + REST)
|
||||||
|
db (5432) ── PostgreSQL 16
|
||||||
|
icecast2 (8000) ── Audio Streaming (Icecast2 + Liquidsoap)
|
||||||
|
gitea (3000) ── Self-Hosted Git
|
||||||
|
quartz (8080) ── Obsidian Notes Publisher (Quartz v4.4.0)
|
||||||
|
searxng (8080) ── Meta Search Engine
|
||||||
|
hasura (8080) ── Hasura GraphQL Engine (Docker profile: hasura)
|
||||||
|
autoheal ── Auto-restart unhealthy containers
|
||||||
|
certbot ── SSL Certificate Management (disabled in dev)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
**Frontend** - Vue 3, Vite, Tailwind CSS v4, Pinia, Vue Router, markdown-it (wikilinks + KaTeX), Rust/WASM
|
||||||
|
|
||||||
|
**Backend** - Go (Gin), gqlgen (GraphQL), GORM, PostgreSQL, JWT auth, WebSockets
|
||||||
|
|
||||||
|
**Integrations** - Spotify API, Steam API, Anthropic Claude API, Icecast2
|
||||||
|
|
||||||
|
**Infrastructure** - Docker Compose, Nginx, Let's Encrypt (Certbot), Gitea + Act Runner
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Spotify integration (currently playing, recently played)
|
||||||
|
- Steam integration (online status, recent games)
|
||||||
|
- Obsidian note viewer via Quartz
|
||||||
|
- Live radio streaming via Icecast2 + Liquidsoap
|
||||||
|
- Real-time chat over WebSockets with image/video uploads
|
||||||
|
- Blog with admin panel (CRUD)
|
||||||
|
- Activity and rowing session tracking
|
||||||
|
- AI image processing integration to extract rowing data from images (will be superseeded by openCV scanning)
|
||||||
|
- Fan shrines (GTO, Evangelion, Demoman, Skip Skip Benben)
|
||||||
|
- Self-hosted Git (Gitea) with CI/CD and commit feed on homepage
|
||||||
|
- Printable CV with role-specific sections
|
||||||
|
- Job application tracker with status workflow and CSV export (admin-only, `/cv/jobs`)
|
||||||
|
- Database-backed bookmarks grouped by category, managed via GraphQL (admin-only)
|
||||||
|
- SearXNG meta search engine (admin-only)
|
||||||
|
- Hasura GraphQL console (admin-only)
|
||||||
|
- Admin-gated routes: `/searxng`, `/notes`, `/hasura` require admin JWT via Nginx `auth_request`
|
||||||
|
- Landing page with animated stamps section
|
||||||
|
- Route transitions (slide/fade) and performance optimizations (gzip, WOFF2 fonts, lazy loading)
|
||||||
|
- Backend healthcheck with autoheal container for automatic recovery
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
| Route | Description |
|
||||||
|
| -------------- | -------------------------------------------------- |
|
||||||
|
| `/` | Landing page |
|
||||||
|
| `/stp` | Home dashboard with grid layout |
|
||||||
|
| `/admin` | Admin login + panel (authenticated) |
|
||||||
|
| `/cv` | Curriculum Vitae (printable) |
|
||||||
|
| `/cv/jobs` | Job application tracker (admin-only, hidden print) |
|
||||||
|
| `/bookmarks` | Bookmarks (database-backed, grouped by category) |
|
||||||
|
| `/notes/:path` | Obsidian note viewer (via Quartz, admin-only) |
|
||||||
|
| `/shrines` | Fan shrine index + individual shrines |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### GraphQL
|
||||||
|
|
||||||
|
**Endpoint:** `POST /api/graphql`
|
||||||
|
**Playground:** `GET /api/graphql` (when `GQL_PLAYGROUND=true`)
|
||||||
|
|
||||||
|
| Operation | Type | Description |
|
||||||
|
| ------------------------------------------ | -------- | ----------------------- |
|
||||||
|
| `users` | Query | Get all users |
|
||||||
|
| `user(id)` | Query | Get user by ID |
|
||||||
|
| `me` | Query | Get authenticated user |
|
||||||
|
| `posts` | Query | Get all posts |
|
||||||
|
| `post(id)` | Query | Get post by ID |
|
||||||
|
| `activities` | Query | Get all activities |
|
||||||
|
| `favorites` | Query | Get all favorites |
|
||||||
|
| `rowingSessions` | Query | Get all rowing sessions |
|
||||||
|
| `post(id)` | Query | Get post by ID |
|
||||||
|
| `activities` | Query | Get all activities |
|
||||||
|
| `favorites` | Query | Get all favorites |
|
||||||
|
| `rowingSessions` | Query | Get all rowing sessions |
|
||||||
|
| `messages` | Query | Get all messages |
|
||||||
|
| `spotifyListening` | Query | Currently playing track |
|
||||||
|
| `spotifyRecent` | Query | Recently played tracks |
|
||||||
|
| `giteaFeed` | Query | Latest Gitea activity |
|
||||||
|
| `steamStatus` | Query | Steam online status |
|
||||||
|
| `login` | Mutation | Authenticate user |
|
||||||
|
| `logout` | Mutation | Logout |
|
||||||
|
| `refreshToken` | Mutation | Refresh auth token |
|
||||||
|
| `createPost` / `updatePost` / `deletePost` | Mutation | Post CRUD (admin) |
|
||||||
|
| `createUser` / `deleteUser` | Mutation | User management (admin) |
|
||||||
|
| `setUserAdmin` | Mutation | Toggle admin status |
|
||||||
|
| `createFavorite` | Mutation | Add favorite (admin) |
|
||||||
|
| `createActivity` | Mutation | Add activity (admin) |
|
||||||
|
| `bookmarks` | Query | Get all bookmarks |
|
||||||
|
| `createBookmark` / `deleteBookmark` | Mutation | Bookmark CRUD (admin) |
|
||||||
|
| `jobApplications` | Query | Get all job applications (admin) |
|
||||||
|
| `createJobApplication` / `updateJobApplication` / `deleteJobApplication` | Mutation | Job application CRUD (admin) |
|
||||||
|
|
||||||
|
### REST Endpoints
|
||||||
|
|
||||||
|
**Auth**
|
||||||
|
| Method | Path | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| POST | `/api/auth/login` | Login (rate limited: 5/min) |
|
||||||
|
| POST | `/api/auth/refresh` | Refresh token |
|
||||||
|
| GET | `/api/auth/check` | Check token validity |
|
||||||
|
| POST | `/api/auth/logout` | Logout |
|
||||||
|
| GET | `/api/auth/validate-admin` | Validate admin JWT (used by Nginx auth_request) |
|
||||||
|
|
||||||
|
Access tokens are valid for 7 days; refresh tokens for 365 days. `ValidateAdmin` also refreshes the access token if it is within 24 hours of expiry.
|
||||||
|
|
||||||
|
**Spotify**
|
||||||
|
| Method | Path | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/spotify/callback` | OAuth callback |
|
||||||
|
| GET | `/api/spotify/listening` | Currently playing |
|
||||||
|
| GET | `/api/spotify/recent` | Recently played |
|
||||||
|
|
||||||
|
**Public**
|
||||||
|
| Method | Path | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/favorites` | Get all favorites |
|
||||||
|
| GET | `/api/rowing` | Get all rowing sessions |
|
||||||
|
| GET | `/api/activity` | Get all activities |
|
||||||
|
| GET | `/api/posts` | Get all posts |
|
||||||
|
| GET | `/api/posts/:id` | Get post by ID |
|
||||||
|
| GET | `/api/user` | Get all users |
|
||||||
|
| GET | `/api/user/:id` | Get user by ID |
|
||||||
|
|
||||||
|
**Protected (auth required)**
|
||||||
|
| Method | Path | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| POST | `/api/messages/upload` | Upload message file (rate limited: 5/min) |
|
||||||
|
|
||||||
|
**Admin (auth + admin required)**
|
||||||
|
| Method | Path | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| POST | `/api/favorites` | Create favorite |
|
||||||
|
| POST | `/api/rowing` | Create rowing session |
|
||||||
|
| POST | `/api/activity` | Create activity |
|
||||||
|
| POST | `/api/posts` | Create post |
|
||||||
|
| PUT | `/api/posts/:id` | Update post |
|
||||||
|
| DELETE | `/api/posts/:id` | Delete post |
|
||||||
|
| POST | `/api/user` | Create user |
|
||||||
|
| PUT | `/api/user/:id` | Update user |
|
||||||
|
| DELETE | `/api/user/:id` | Delete user |
|
||||||
|
| PATCH | `/api/user/:id/admin` | Set/unset admin |
|
||||||
|
| POST | `/api/radio/upload` | Upload radio song |
|
||||||
|
| GET | `/api/radio/songs` | List radio songs |
|
||||||
|
| DELETE | `/api/radio/songs/:filename` | Delete radio song |
|
||||||
|
| PATCH | `/api/radio/songs/:filename/disable` | Disable radio song |
|
||||||
|
| PATCH | `/api/radio/songs/:filename/enable` | Enable radio song |
|
||||||
|
|
||||||
|
**WebSocket**
|
||||||
|
| Method | Path | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/api/ws` | WebSocket chat (10s ping keepalive) |
|
||||||
|
|
||||||
|
### Nginx Proxy Routes
|
||||||
|
|
||||||
|
| Route | Target | Notes |
|
||||||
|
| ---------- | ------------ | ------------------------------------------ |
|
||||||
|
| `/api` | backend:8080 | API (rate limited: 30r/s) |
|
||||||
|
| `/radio` | icecast:8000 | Audio streaming |
|
||||||
|
| `/gitea` | gitea:3000 | Git service |
|
||||||
|
| `/hasura` | hasura:8080 | GraphQL console + WebSocket (admin-only) |
|
||||||
|
| `/notes` | quartz:8080 | Obsidian notes (admin-only) |
|
||||||
|
| `/searxng` | searxng:8080 | Search engine (admin-only) |
|
||||||
|
| `/uploads` | local alias | User-uploaded files |
|
||||||
|
|
||||||
|
`/hasura`, `/notes`, and `/searxng` are protected by `auth_request` to `GET /api/auth/validate-admin`. Requests without a valid admin JWT are rejected with 401.
|
||||||
|
|
||||||
|
### Deprecated Endpoints
|
||||||
|
|
||||||
|
**Backend note API** (`GET /api/notes/*path`) - The backend has a REST endpoint that serves note files directly from the mounted Obsidian vault. This is superseded by the Quartz service which now handles note rendering at `/notes/`. The backend endpoint still exists in code but is no longer the primary note serving path.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
### `.env`
|
||||||
|
|
||||||
|
Create a `.env` file in the project root. All services read from this file.
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
| --------------------------------- | ------------------------------------------------------------------------------- |
|
||||||
|
| `POSTGRES_USER` | PostgreSQL username |
|
||||||
|
| `POSTGRES_PASSWORD` | PostgreSQL password |
|
||||||
|
| `POSTGRES_DB` | Main app database name |
|
||||||
|
| `POSTGRES_PORT` | PostgreSQL port (typically `5432`) |
|
||||||
|
| `POSTGRES_HOST` | PostgreSQL hostname (use `db` for Docker) |
|
||||||
|
| `GITEA_HOST` | Gitea hostname (use `gitea` for Docker) |
|
||||||
|
| `GITEA_PORT` | Gitea HTTP port (typically `3000`) |
|
||||||
|
| `GITEA_INTERNAL_TOKEN` | Gitea internal API token (generate with `gitea generate secret INTERNAL_TOKEN`) |
|
||||||
|
| `GITEA_LFS_JWT_SECRET` | Gitea LFS JWT secret (generate with `gitea generate secret LFS_JWT_SECRET`) |
|
||||||
|
| `GITEA_OAUTH2_JWT_SECRET` | Gitea OAuth2 JWT secret (generate with `gitea generate secret JWT_SECRET`) |
|
||||||
|
| `POSTGRES_GITEA_DB` | Gitea database name |
|
||||||
|
| `UPTIMEKUMA_HOST` | Uptime Kuma hostname (planned) |
|
||||||
|
| `UPTIMEKUMA_PORT` | Uptime Kuma port (planned) |
|
||||||
|
| `SEARXNG_HOST` | SearXNG hostname (use `searxng` for Docker) |
|
||||||
|
| `SEARXNG_PORT` | SearXNG port (typically `8080`) |
|
||||||
|
| `SEARXNG_SECRET_KEY` | SearXNG secret key (random hex string) |
|
||||||
|
| `WALLABAG_HOST` | Wallabag hostname (planned) |
|
||||||
|
| `WALLABAG_PORT` | Wallabag port (planned) |
|
||||||
|
| `QUARTZ_HOST` | Quartz hostname (use `quartz` for Docker) |
|
||||||
|
| `QUARTZ_PORT` | Quartz port (typically `8080`) |
|
||||||
|
| `GITEA_RUNNER_HOST` | Gitea runner hostname |
|
||||||
|
| `GITEA_RUNNER_NAME` | Gitea runner display name |
|
||||||
|
| `GITEA_RUNNER_REGISTRATION_TOKEN` | Token to register Gitea Actions runner |
|
||||||
|
| `BACKEND_PORT` | Backend port (typically `8080`) |
|
||||||
|
| `BACKEND_HOST` | Backend hostname (use `backend` for Docker) |
|
||||||
|
| `BACKEND_SECRET` | JWT signing secret |
|
||||||
|
| `BACKEND_ENDPOINT` | API path prefix (typically `/api`) |
|
||||||
|
| `OBSIDIAN_DIR` | Absolute path to Obsidian vault on host machine |
|
||||||
|
| `SPOTIFY_CLIENT_ID` | Spotify app client ID |
|
||||||
|
| `SPOTIFY_CLIENT_SECRET` | Spotify app client secret |
|
||||||
|
| `SPOTIFY_REDIRECT_URI` | Spotify OAuth redirect (e.g. `https://www.<DOMAIN>/api/spotify/callback`) |
|
||||||
|
| `SPOTIFY_AUTH_STATE` | Arbitrary state string for Spotify OAuth |
|
||||||
|
| `ICECAST_SOURCE_PASSWORD` | Icecast source connection password |
|
||||||
|
| `ICECAST_RELAY_PASSWORD` | Icecast relay password |
|
||||||
|
| `ICECAST_ADMIN_USER` | Icecast admin username |
|
||||||
|
| `ICECAST_ADMIN_PASSWORD` | Icecast admin password |
|
||||||
|
| `ICECAST_HOST` | Icecast hostname (use `icecast` for Docker) |
|
||||||
|
| `ICECAST_PORT` | Icecast port (typically `8000`) |
|
||||||
|
| `ICECAST_MOUNT` | Icecast mount point (e.g. `/stream`) |
|
||||||
|
| `LIQUIDSOAP_HARBOR_MOUNT` | Liquidsoap live input mount (e.g. `/live`) |
|
||||||
|
| `LIQUIDSOAP_HARBOR_PORT` | Liquidsoap harbor port (e.g. `8005`) |
|
||||||
|
| `DOMAIN` | Production domain name |
|
||||||
|
| `EMAIL` | Email for Let's Encrypt registration |
|
||||||
|
| `CLAUDE_API_KEY` | Anthropic Claude API key |
|
||||||
|
| `STEAM_API_KEY` | Steam Web API key |
|
||||||
|
| `STEAM_ID` | Steam user ID |
|
||||||
|
| `HASURA_GRAPHQL_ADMIN_SECRET` | Hasura admin secret |
|
||||||
|
| `HASURA_HOST` | Hasura hostname (use `hasura` for Docker) |
|
||||||
|
| `HASURA_PORT` | Hasura port (typically `8080`) |
|
||||||
|
| `SEED_DB` | Set to `true` to seed test data on startup |
|
||||||
|
|
||||||
|
### Gitea Config
|
||||||
|
|
||||||
|
Copy from the template and fill in secrets:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp gitea/config/app.ini.template gitea/config/app.ini
|
||||||
|
```
|
||||||
|
|
||||||
|
Populate `LFS_JWT_SECRET`, `SECRET_KEY`, `INTERNAL_TOKEN`, `JWT_SECRET`, and the database `PASSWD`. Alternatively, the Gitea entrypoint generates `app.ini` from the template using environment variables.
|
||||||
|
|
||||||
|
### SearXNG Config
|
||||||
|
|
||||||
|
Copy from the template:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp searxng/settings.yml.template searxng/settings.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
The Docker entrypoint handles environment variable substitution (`${BASE_URL}`, `${SEARXNG_SECRET_KEY}`) automatically, so manual setup is only needed when running outside Docker.
|
||||||
|
|
||||||
|
### Spotify Token Setup
|
||||||
|
|
||||||
|
4. After authorization, Spotify redirects to the callback endpoint which stores tokens at `/backend/token/spotify_token.json`
|
||||||
|
5. Tokens are refreshed automatically; the file persists across container restarts via volume mount
|
||||||
|
|
||||||
|
### Obsidian Notes Setup
|
||||||
|
|
||||||
|
1. Set `OBSIDIAN_DIR` in `.env` to the absolute path of your Obsidian vault on the host machine
|
||||||
|
2. The vault is mounted read-only into the Quartz container at `/quartz/content`
|
||||||
|
3. Quartz builds a static site from the vault on startup and serves it at `/notes/`
|
||||||
|
4. The backend also mounts the vault at `/backend/notes` (legacy, see deprecated endpoints above)
|
||||||
|
|
||||||
|
### SSL Certificates (Certbot)
|
||||||
|
|
||||||
|
**Initial setup (production):**
|
||||||
|
|
||||||
|
1. Set `DOMAIN` and `EMAIL` in `.env`
|
||||||
|
2. On first run, Nginx starts with `nginx_setup.conf.template` which only serves the ACME challenge route at `/.well-known/acme-challenge/`
|
||||||
|
3. Certbot requests a certificate via `certbot certonly --webroot`
|
||||||
|
4. Once the certificate is issued to `certbot/conf/live/<DOMAIN>/`, restart Nginx — it will detect the certs and switch to the full `nginx.conf.template`
|
||||||
|
5. Certbot checks for renewal every 12 hours automatically
|
||||||
|
|
||||||
|
**Dev mode:** Nginx generates a self-signed certificate for localhost automatically.
|
||||||
|
|
||||||
|
### Icecast Radio
|
||||||
|
|
||||||
|
Place at least one `.mp3` file in `icecast2/fallback_music/`. Liquidsoap plays these as fallback when no live source is connected. Connect a live source to port 8001 on the `LIQUIDSOAP_HARBOR_MOUNT`.
|
||||||
|
|
||||||
|
### Gitea Runner
|
||||||
|
|
||||||
|
1. Download the `act_runner` binary from [Gitea releases](https://gitea.com/gitea/act_runner/releases) and place in `gitea-runner/`
|
||||||
|
2. Set `GITEA_RUNNER_REGISTRATION_TOKEN` in `.env`
|
||||||
|
3. The runner registers automatically on first startup
|
||||||
|
|
||||||
|
## Dev Mode
|
||||||
|
|
||||||
|
Run the full stack over plain HTTP without SSL certificates:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
This:
|
||||||
|
|
||||||
|
- Uses an HTTP-only Nginx config with all routing (SPA, backend proxy, radio, gitea, etc.)
|
||||||
|
- Generates a self-signed certificate for localhost
|
||||||
|
- Disables certbot
|
||||||
|
- Seeds the database with test data (`SEED_DB=true`)
|
||||||
|
- Enables GraphQL playground and introspection
|
||||||
|
- Enables Hasura console and dev mode
|
||||||
|
|
||||||
|
Visit `http://localhost` to test.
|
||||||
|
|
||||||
|
### Frontend only (hot reload)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd vue && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite dev server proxies `/api` to `localhost:8080`, `/gitea` to `localhost:3000`, `/radio` to `localhost:8000`.
|
||||||
|
|
||||||
|
## Untracked Files
|
||||||
|
|
||||||
|
These files are git-ignored and must be created manually:
|
||||||
|
|
||||||
|
| File | Notes |
|
||||||
|
| ------------------------------- | --------------------------------------------------------------- |
|
||||||
|
| `.env` | See setup section above |
|
||||||
|
| `gitea/config/app.ini` | Copy from `app.ini.template` or let entrypoint generate it |
|
||||||
|
| `searxng/settings.yml` | Copy from `settings.yml.template` or let entrypoint generate it |
|
||||||
|
| `certbot/conf/`, `certbot/www/` | Created automatically by certbot; use dev mode to skip |
|
||||||
|
| `backend/token/` | Created automatically by Docker volume mount |
|
||||||
|
| `icecast2/fallback_music/*.mp3` | Place at least one MP3 file |
|
||||||
|
| `gitea-runner/act_runner` | Download from Gitea releases |
|
||||||
|
| `gitea-runner/.runner` | Generated on first runner startup |
|
||||||
|
|
||||||
|
## Future Ideas
|
||||||
|
|
||||||
|
- More Rust to WASM
|
||||||
|
- ML for chatboards
|
||||||
|
- Cache requests
|
||||||
|
- Design more webpages
|
||||||
|
- Calendar to show radio times
|
||||||
|
- Nice smooth function background and transitions
|
||||||
|
- Design shrines
|
||||||
|
- Redis (not really but practical experience)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
FROM golang:1.24
|
FROM golang:1.25
|
||||||
|
|
||||||
WORKDIR /backend
|
WORKDIR /backend
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,38 @@
|
|||||||
module adam-french.co.uk/backend
|
module adam-french.co.uk/backend
|
||||||
|
|
||||||
go 1.24.0
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/99designs/gqlgen v0.17.88
|
||||||
|
github.com/anthropics/anthropic-sdk-go v1.26.0
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||||
|
github.com/gorilla/websocket v1.5.3
|
||||||
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.32
|
||||||
github.com/zmb3/spotify/v2 v2.4.3
|
github.com/zmb3/spotify/v2 v2.4.3
|
||||||
golang.org/x/crypto v0.43.0
|
golang.org/x/crypto v0.48.0
|
||||||
golang.org/x/oauth2 v0.30.0
|
golang.org/x/oauth2 v0.30.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0 // indirect
|
github.com/agnivade/levenshtein v1.2.1 // indirect
|
||||||
github.com/bytedance/sonic v1.14.0 // indirect
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||||
@@ -41,7 +48,7 @@ require (
|
|||||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/quic-go/qpack v0.5.1 // indirect
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd // indirect
|
github.com/sosodev/duration v1.4.0 // indirect
|
||||||
github.com/tidwall/gjson v1.18.0 // indirect
|
github.com/tidwall/gjson v1.18.0 // indirect
|
||||||
github.com/tidwall/match v1.1.1 // indirect
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
github.com/tidwall/pretty v1.2.1 // indirect
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
@@ -50,12 +57,11 @@ require (
|
|||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
golang.org/x/arch v0.20.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/mod v0.29.0 // indirect
|
golang.org/x/mod v0.33.0 // indirect
|
||||||
golang.org/x/net v0.46.0 // indirect
|
golang.org/x/net v0.50.0 // indirect
|
||||||
golang.org/x/sync v0.17.0 // indirect
|
golang.org/x/sync v0.19.0 // indirect
|
||||||
golang.org/x/sys v0.37.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.30.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/tools v0.38.0 // indirect
|
golang.org/x/tools v0.42.0 // indirect
|
||||||
google.golang.org/appengine v1.6.7 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,10 +31,22 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
|
|||||||
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
|
||||||
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
|
||||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||||
|
github.com/99designs/gqlgen v0.17.88 h1:neMQDgehMwT1vYIOx/w5ZYPUU/iMNAJzRO44I5Intoc=
|
||||||
|
github.com/99designs/gqlgen v0.17.88/go.mod h1:qeqYFEgOeSKqWedOjogPizimp2iu4E23bdPvl4jTYic=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||||
|
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||||
|
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||||
|
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
|
||||||
|
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
|
||||||
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
|
||||||
|
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
|
||||||
|
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||||
|
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
||||||
|
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
|
||||||
|
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
|
||||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||||
@@ -50,6 +62,19 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
|
|||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
|
||||||
|
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
|
||||||
|
github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
|
||||||
|
github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
|
||||||
|
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
|
||||||
|
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
|
||||||
|
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
|
||||||
|
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||||
|
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||||
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||||
|
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
@@ -71,10 +96,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
|||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
@@ -102,10 +129,7 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
|
|||||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||||
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
|
||||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
|
||||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
@@ -129,12 +153,16 @@ github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hf
|
|||||||
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
@@ -178,6 +206,10 @@ github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQ
|
|||||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc=
|
||||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||||
|
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
|
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
||||||
|
github.com/sosodev/duration v1.4.0 h1:35ed0KiVFriGHHzZZJaZLgmTEEICIyt8Sx0RQfj9IjE=
|
||||||
|
github.com/sosodev/duration v1.4.0/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
@@ -203,6 +235,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
|||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.32 h1:k9QPJd4sEDTL+qB4ncPLflqTJ3MmjB9SrVzJrawpFSc=
|
||||||
|
github.com/vektah/gqlparser/v2 v2.5.32/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
|
||||||
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
@@ -226,8 +260,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
|||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
@@ -260,8 +294,8 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|||||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
@@ -294,14 +328,13 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
|||||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5 h1:Ati8dO7+U7mxpkPSxBZQEvzHVUYB/MqCklCN8ig5w/o=
|
|
||||||
golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
golang.org/x/oauth2 v0.0.0-20210810183815-faf39c7919d5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
@@ -315,8 +348,8 @@ golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJ
|
|||||||
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
@@ -351,8 +384,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
@@ -364,12 +397,13 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|||||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
@@ -415,8 +449,8 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc
|
|||||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
@@ -443,7 +477,6 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
|
|||||||
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||||
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
|
|
||||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||||
@@ -499,12 +532,14 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
|
|||||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||||
|
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
77
backend/gqlgen.yml
Normal file
77
backend/gqlgen.yml
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
schema:
|
||||||
|
- graph/schema/*.graphql
|
||||||
|
|
||||||
|
exec:
|
||||||
|
filename: graph/generated.go
|
||||||
|
package: graph
|
||||||
|
|
||||||
|
model:
|
||||||
|
filename: graph/model/models_gen.go
|
||||||
|
package: model
|
||||||
|
|
||||||
|
resolver:
|
||||||
|
layout: follow-schema
|
||||||
|
dir: graph
|
||||||
|
package: graph
|
||||||
|
filename_template: "{name}.resolvers.go"
|
||||||
|
|
||||||
|
models:
|
||||||
|
ID:
|
||||||
|
model:
|
||||||
|
- github.com/99designs/gqlgen/graphql.IntID
|
||||||
|
User:
|
||||||
|
model:
|
||||||
|
- adam-french.co.uk/backend/models.User
|
||||||
|
fields:
|
||||||
|
password:
|
||||||
|
resolver: false
|
||||||
|
deletedAt:
|
||||||
|
resolver: false
|
||||||
|
Post:
|
||||||
|
model:
|
||||||
|
- adam-french.co.uk/backend/models.Post
|
||||||
|
fields:
|
||||||
|
deletedAt:
|
||||||
|
resolver: false
|
||||||
|
Activity:
|
||||||
|
model:
|
||||||
|
- adam-french.co.uk/backend/models.Activity
|
||||||
|
fields:
|
||||||
|
deletedAt:
|
||||||
|
resolver: false
|
||||||
|
Favorite:
|
||||||
|
model:
|
||||||
|
- adam-french.co.uk/backend/models.Favorite
|
||||||
|
fields:
|
||||||
|
deletedAt:
|
||||||
|
resolver: false
|
||||||
|
Rowing:
|
||||||
|
model:
|
||||||
|
- adam-french.co.uk/backend/models.Rowing
|
||||||
|
fields:
|
||||||
|
deletedAt:
|
||||||
|
resolver: false
|
||||||
|
Message:
|
||||||
|
model:
|
||||||
|
- adam-french.co.uk/backend/models.Message
|
||||||
|
fields:
|
||||||
|
deletedAt:
|
||||||
|
resolver: false
|
||||||
|
Bookmark:
|
||||||
|
model:
|
||||||
|
- adam-french.co.uk/backend/models.Bookmark
|
||||||
|
fields:
|
||||||
|
deletedAt:
|
||||||
|
resolver: false
|
||||||
|
JobApplication:
|
||||||
|
model:
|
||||||
|
- adam-french.co.uk/backend/models.JobApplication
|
||||||
|
fields:
|
||||||
|
deletedAt:
|
||||||
|
resolver: false
|
||||||
|
JobAppReference:
|
||||||
|
model:
|
||||||
|
- adam-french.co.uk/backend/models.JobAppReference
|
||||||
|
fields:
|
||||||
|
deletedAt:
|
||||||
|
resolver: false
|
||||||
51
backend/graph/activity.resolvers.go
Normal file
51
backend/graph/activity.resolvers.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/graph/model"
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ID is the resolver for the id field.
|
||||||
|
func (r *activityResolver) ID(ctx context.Context, obj *models.Activity) (int, error) {
|
||||||
|
return int(obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateActivity is the resolver for the createActivity field.
|
||||||
|
func (r *mutationResolver) CreateActivity(ctx context.Context, input model.CreateActivityInput) (*models.Activity, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
|
||||||
|
activity := models.Activity{Type: input.Type, Name: input.Name, Link: input.Link}
|
||||||
|
if err := r.Store.DB.Create(&activity).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &activity, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activities is the resolver for the activities field.
|
||||||
|
func (r *queryResolver) Activities(ctx context.Context) ([]*models.Activity, error) {
|
||||||
|
var activities []models.Activity
|
||||||
|
if err := r.Store.DB.Order("created_at DESC").Find(&activities).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]*models.Activity, len(activities))
|
||||||
|
for i := range activities {
|
||||||
|
result[i] = &activities[i]
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity returns ActivityResolver implementation.
|
||||||
|
func (r *Resolver) Activity() ActivityResolver { return &activityResolver{r} }
|
||||||
|
|
||||||
|
type activityResolver struct{ *Resolver }
|
||||||
146
backend/graph/auth.resolvers.go
Normal file
146
backend/graph/auth.resolvers.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/graph/model"
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Login is the resolver for the login field.
|
||||||
|
func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (*model.AuthPayload, error) {
|
||||||
|
gc := GinContextFromCtx(ctx)
|
||||||
|
if gc == nil {
|
||||||
|
return nil, fmt.Errorf("could not get gin context")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !r.Store.LoginLimiter.Allow(gc.ClientIP()) {
|
||||||
|
return nil, fmt.Errorf("too many login attempts, please try again later")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
if err := r.Store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := r.Store.Auth.GenerateJWT(&user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
gc.SetSameSite(http.SameSiteLaxMode)
|
||||||
|
gc.SetCookie(
|
||||||
|
"access_token",
|
||||||
|
tokens.AccessToken,
|
||||||
|
int(r.Store.Auth.Config.AccessTokenLifetime.Seconds()),
|
||||||
|
"/",
|
||||||
|
r.Store.Auth.Config.Domain,
|
||||||
|
true, true,
|
||||||
|
)
|
||||||
|
gc.SetCookie(
|
||||||
|
"refresh_token",
|
||||||
|
tokens.RefreshToken,
|
||||||
|
int(r.Store.Auth.Config.RefreshTokenLifetime.Seconds()),
|
||||||
|
"/",
|
||||||
|
r.Store.Auth.Config.Domain,
|
||||||
|
true, true,
|
||||||
|
)
|
||||||
|
|
||||||
|
return &model.AuthPayload{User: &user}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout is the resolver for the logout field.
|
||||||
|
func (r *mutationResolver) Logout(ctx context.Context) (bool, error) {
|
||||||
|
gc := GinContextFromCtx(ctx)
|
||||||
|
if gc == nil {
|
||||||
|
return false, fmt.Errorf("could not get gin context")
|
||||||
|
}
|
||||||
|
|
||||||
|
gc.SetSameSite(http.SameSiteLaxMode)
|
||||||
|
gc.SetCookie("access_token", "", -1, "/", r.Store.Auth.Config.Domain, true, true)
|
||||||
|
gc.SetCookie("refresh_token", "", -1, "/", r.Store.Auth.Config.Domain, true, true)
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken is the resolver for the refreshToken field.
|
||||||
|
func (r *mutationResolver) RefreshToken(ctx context.Context) (*model.AuthPayload, error) {
|
||||||
|
gc := GinContextFromCtx(ctx)
|
||||||
|
if gc == nil {
|
||||||
|
return nil, fmt.Errorf("could not get gin context")
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, err := gc.Cookie("refresh_token")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := r.Store.Auth.VerifyJWT(refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDF, ok := (*claims)["id"].(float64)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("invalid token claims")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
user.ID = uint(userIDF)
|
||||||
|
if err := r.Store.DB.First(&user).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := r.Store.Auth.GenerateJWT(&user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to generate tokens")
|
||||||
|
}
|
||||||
|
|
||||||
|
gc.SetSameSite(http.SameSiteLaxMode)
|
||||||
|
gc.SetCookie(
|
||||||
|
"access_token",
|
||||||
|
tokens.AccessToken,
|
||||||
|
int(r.Store.Auth.Config.AccessTokenLifetime.Seconds()),
|
||||||
|
"/",
|
||||||
|
r.Store.Auth.Config.Domain,
|
||||||
|
true, true,
|
||||||
|
)
|
||||||
|
gc.SetCookie(
|
||||||
|
"refresh_token",
|
||||||
|
tokens.RefreshToken,
|
||||||
|
int(r.Store.Auth.Config.RefreshTokenLifetime.Seconds()),
|
||||||
|
"/",
|
||||||
|
r.Store.Auth.Config.Domain,
|
||||||
|
true, true,
|
||||||
|
)
|
||||||
|
|
||||||
|
return &model.AuthPayload{User: &user}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Me is the resolver for the me field.
|
||||||
|
func (r *queryResolver) Me(ctx context.Context) (*models.User, error) {
|
||||||
|
userID, ok := UserIDFromCtx(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
user.ID = userID
|
||||||
|
if err := r.Store.DB.First(&user).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
64
backend/graph/bookmark.resolvers.go
Normal file
64
backend/graph/bookmark.resolvers.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/graph/model"
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ID is the resolver for the id field.
|
||||||
|
func (r *bookmarkResolver) ID(ctx context.Context, obj *models.Bookmark) (int, error) {
|
||||||
|
return int(obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBookmark is the resolver for the createBookmark field.
|
||||||
|
func (r *mutationResolver) CreateBookmark(ctx context.Context, input model.CreateBookmarkInput) (*models.Bookmark, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
bookmark := models.Bookmark{Category: input.Category, Name: input.Name, Link: input.Link}
|
||||||
|
if err := r.Store.DB.Create(&bookmark).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &bookmark, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBookmark is the resolver for the deleteBookmark field.
|
||||||
|
func (r *mutationResolver) DeleteBookmark(ctx context.Context, id int) (*models.Bookmark, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
var bookmark models.Bookmark
|
||||||
|
if err := r.Store.DB.First(&bookmark, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := r.Store.DB.Delete(&bookmark).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &bookmark, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bookmarks is the resolver for the bookmarks field.
|
||||||
|
func (r *queryResolver) Bookmarks(ctx context.Context) ([]*models.Bookmark, error) {
|
||||||
|
var bookmarks []models.Bookmark
|
||||||
|
if err := r.Store.DB.Order("category ASC, created_at ASC").Find(&bookmarks).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]*models.Bookmark, len(bookmarks))
|
||||||
|
for i := range bookmarks {
|
||||||
|
result[i] = &bookmarks[i]
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bookmark returns BookmarkResolver implementation.
|
||||||
|
func (r *Resolver) Bookmark() BookmarkResolver { return &bookmarkResolver{r} }
|
||||||
|
|
||||||
|
type bookmarkResolver struct{ *Resolver }
|
||||||
52
backend/graph/context.go
Normal file
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
|
||||||
|
}
|
||||||
51
backend/graph/favorite.resolvers.go
Normal file
51
backend/graph/favorite.resolvers.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/graph/model"
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ID is the resolver for the id field.
|
||||||
|
func (r *favoriteResolver) ID(ctx context.Context, obj *models.Favorite) (int, error) {
|
||||||
|
return int(obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFavorite is the resolver for the createFavorite field.
|
||||||
|
func (r *mutationResolver) CreateFavorite(ctx context.Context, input model.CreateFavoriteInput) (*models.Favorite, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
|
||||||
|
favorite := models.Favorite{Type: input.Type, Name: input.Name, Link: input.Link}
|
||||||
|
if err := r.Store.DB.Create(&favorite).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &favorite, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorites is the resolver for the favorites field.
|
||||||
|
func (r *queryResolver) Favorites(ctx context.Context) ([]*models.Favorite, error) {
|
||||||
|
var favorites []models.Favorite
|
||||||
|
if err := r.Store.DB.Order("created_at DESC").Find(&favorites).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]*models.Favorite, len(favorites))
|
||||||
|
for i := range favorites {
|
||||||
|
result[i] = &favorites[i]
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Favorite returns FavoriteResolver implementation.
|
||||||
|
func (r *Resolver) Favorite() FavoriteResolver { return &favoriteResolver{r} }
|
||||||
|
|
||||||
|
type favoriteResolver struct{ *Resolver }
|
||||||
11120
backend/graph/generated.go
Normal file
11120
backend/graph/generated.go
Normal file
File diff suppressed because it is too large
Load Diff
34
backend/graph/gitea.resolvers.go
Normal file
34
backend/graph/gitea.resolvers.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/graph/model"
|
||||||
|
"adam-french.co.uk/backend/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GiteaFeed is the resolver for the giteaFeed field.
|
||||||
|
func (r *queryResolver) GiteaFeed(ctx context.Context) (*model.GiteaFeedItem, error) {
|
||||||
|
if r.Store.GiteaFeedFresh() {
|
||||||
|
return mapGiteaFeed(r.Store.GiteaFeed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
feed, err := services.FetchLatestFeed(r.Store.GiteaHost, r.Store.GiteaPort)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if feed == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Store.GiteaFeed = feed
|
||||||
|
r.Store.GiteaFeedFetchedAt = time.Now()
|
||||||
|
|
||||||
|
return mapGiteaFeed(feed), nil
|
||||||
|
}
|
||||||
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
93
backend/graph/job_app_reference.resolvers.go
Normal file
93
backend/graph/job_app_reference.resolvers.go
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/graph/model"
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ID is the resolver for the id field.
|
||||||
|
func (r *jobAppReferenceResolver) ID(ctx context.Context, obj *models.JobAppReference) (int, error) {
|
||||||
|
return int(obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateJobAppReference is the resolver for the createJobAppReference field.
|
||||||
|
func (r *mutationResolver) CreateJobAppReference(ctx context.Context, input model.CreateJobAppReferenceInput) (*models.JobAppReference, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
ref := models.JobAppReference{
|
||||||
|
Category: input.Category,
|
||||||
|
Label: input.Label,
|
||||||
|
Value: input.Value,
|
||||||
|
}
|
||||||
|
if input.SortOrder != nil {
|
||||||
|
ref.SortOrder = *input.SortOrder
|
||||||
|
}
|
||||||
|
if err := r.Store.DB.Create(&ref).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ref, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateJobAppReference is the resolver for the updateJobAppReference field.
|
||||||
|
func (r *mutationResolver) UpdateJobAppReference(ctx context.Context, id int, input model.UpdateJobAppReferenceInput) (*models.JobAppReference, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
var ref models.JobAppReference
|
||||||
|
if err := r.Store.DB.First(&ref, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if input.Category != nil {
|
||||||
|
ref.Category = *input.Category
|
||||||
|
}
|
||||||
|
if input.Label != nil {
|
||||||
|
ref.Label = *input.Label
|
||||||
|
}
|
||||||
|
if input.Value != nil {
|
||||||
|
ref.Value = *input.Value
|
||||||
|
}
|
||||||
|
if input.SortOrder != nil {
|
||||||
|
ref.SortOrder = *input.SortOrder
|
||||||
|
}
|
||||||
|
if err := r.Store.DB.Save(&ref).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ref, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteJobAppReference is the resolver for the deleteJobAppReference field.
|
||||||
|
func (r *mutationResolver) DeleteJobAppReference(ctx context.Context, id int) (bool, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return false, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
if err := r.Store.DB.Delete(&models.JobAppReference{}, id).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobAppReferences is the resolver for the jobAppReferences field.
|
||||||
|
func (r *queryResolver) JobAppReferences(ctx context.Context) ([]*models.JobAppReference, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
var refs []*models.JobAppReference
|
||||||
|
if err := r.Store.DB.Order("category ASC, sort_order ASC, created_at ASC").Find(&refs).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return refs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobAppReference returns JobAppReferenceResolver implementation.
|
||||||
|
func (r *Resolver) JobAppReference() JobAppReferenceResolver { return &jobAppReferenceResolver{r} }
|
||||||
|
|
||||||
|
type jobAppReferenceResolver struct{ *Resolver }
|
||||||
115
backend/graph/job_application.resolvers.go
Normal file
115
backend/graph/job_application.resolvers.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/graph/model"
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ID is the resolver for the id field.
|
||||||
|
func (r *jobApplicationResolver) ID(ctx context.Context, obj *models.JobApplication) (int, error) {
|
||||||
|
return int(obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateJobApplication is the resolver for the createJobApplication field.
|
||||||
|
func (r *mutationResolver) CreateJobApplication(ctx context.Context, input model.CreateJobApplicationInput) (*models.JobApplication, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
app := models.JobApplication{
|
||||||
|
JobTitle: input.JobTitle,
|
||||||
|
Company: input.Company,
|
||||||
|
Location: input.Location,
|
||||||
|
URL: input.URL,
|
||||||
|
Status: input.Status,
|
||||||
|
Notes: input.Notes,
|
||||||
|
AppliedAt: input.AppliedAt,
|
||||||
|
}
|
||||||
|
if err := r.Store.DB.Create(&app).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &app, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateJobApplication is the resolver for the updateJobApplication field.
|
||||||
|
func (r *mutationResolver) UpdateJobApplication(ctx context.Context, id int, input model.UpdateJobApplicationInput) (*models.JobApplication, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
var app models.JobApplication
|
||||||
|
if err := r.Store.DB.First(&app, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if input.JobTitle != nil {
|
||||||
|
app.JobTitle = *input.JobTitle
|
||||||
|
}
|
||||||
|
if input.Company != nil {
|
||||||
|
app.Company = *input.Company
|
||||||
|
}
|
||||||
|
if input.Location != nil {
|
||||||
|
app.Location = input.Location
|
||||||
|
}
|
||||||
|
if input.URL != nil {
|
||||||
|
app.URL = input.URL
|
||||||
|
}
|
||||||
|
if input.Status != nil {
|
||||||
|
app.Status = *input.Status
|
||||||
|
}
|
||||||
|
if input.Notes != nil {
|
||||||
|
app.Notes = input.Notes
|
||||||
|
}
|
||||||
|
if input.AppliedAt != nil {
|
||||||
|
app.AppliedAt = input.AppliedAt
|
||||||
|
}
|
||||||
|
if err := r.Store.DB.Save(&app).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &app, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteJobApplication is the resolver for the deleteJobApplication field.
|
||||||
|
func (r *mutationResolver) DeleteJobApplication(ctx context.Context, id int) (bool, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return false, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
if err := r.Store.DB.Delete(&models.JobApplication{}, id).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobApplications is the resolver for the jobApplications field.
|
||||||
|
func (r *queryResolver) JobApplications(ctx context.Context) ([]*models.JobApplication, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
var apps []*models.JobApplication
|
||||||
|
if err := r.Store.DB.Order("created_at desc").Find(&apps).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return apps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobApplication is the resolver for the jobApplication field.
|
||||||
|
func (r *queryResolver) JobApplication(ctx context.Context, id int) (*models.JobApplication, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
var app models.JobApplication
|
||||||
|
if err := r.Store.DB.First(&app, id).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &app, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobApplication returns JobApplicationResolver implementation.
|
||||||
|
func (r *Resolver) JobApplication() JobApplicationResolver { return &jobApplicationResolver{r} }
|
||||||
|
|
||||||
|
type jobApplicationResolver struct{ *Resolver }
|
||||||
40
backend/graph/message.resolvers.go
Normal file
40
backend/graph/message.resolvers.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ID is the resolver for the id field.
|
||||||
|
func (r *messageResolver) ID(ctx context.Context, obj *models.Message) (int, error) {
|
||||||
|
return int(obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthorID is the resolver for the authorId field.
|
||||||
|
func (r *messageResolver) AuthorID(ctx context.Context, obj *models.Message) (int, error) {
|
||||||
|
return int(obj.AuthorID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages is the resolver for the messages field.
|
||||||
|
func (r *queryResolver) Messages(ctx context.Context) ([]*models.Message, error) {
|
||||||
|
var messages []models.Message
|
||||||
|
if err := r.Store.DB.Order("created_at DESC").Find(&messages).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]*models.Message, len(messages))
|
||||||
|
for i := range messages {
|
||||||
|
result[i] = &messages[i]
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message returns MessageResolver implementation.
|
||||||
|
func (r *Resolver) Message() MessageResolver { return &messageResolver{r} }
|
||||||
|
|
||||||
|
type messageResolver struct{ *Resolver }
|
||||||
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
142
backend/graph/model/models_gen.go
Normal file
142
backend/graph/model/models_gen.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// Code generated by github.com/99designs/gqlgen, DO NOT EDIT.
|
||||||
|
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuthPayload struct {
|
||||||
|
User *models.User `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateActivityInput struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Link *string `json:"link,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateBookmarkInput struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateFavoriteInput struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Link *string `json:"link,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateJobAppReferenceInput struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Value string `json:"value"`
|
||||||
|
SortOrder *int `json:"sortOrder,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateJobApplicationInput struct {
|
||||||
|
JobTitle string `json:"jobTitle"`
|
||||||
|
Company string `json:"company"`
|
||||||
|
Location *string `json:"location,omitempty"`
|
||||||
|
URL *string `json:"url,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Notes *string `json:"notes,omitempty"`
|
||||||
|
AppliedAt *time.Time `json:"appliedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreatePostInput struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateUserInput struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GiteaFeedItem struct {
|
||||||
|
AvatarURL string `json:"avatarUrl"`
|
||||||
|
RepoURL string `json:"repoUrl"`
|
||||||
|
RepoName string `json:"repoName"`
|
||||||
|
OpType string `json:"opType"`
|
||||||
|
CommitMessage string `json:"commitMessage"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginInput struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mutation struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
type Query struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyAlbum struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Images []*SpotifyImage `json:"images"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyArtist struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyImage struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyPlaying struct {
|
||||||
|
Playing bool `json:"playing"`
|
||||||
|
Track *SpotifyTrack `json:"track,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyRecentItem struct {
|
||||||
|
Track *SpotifyTrack `json:"track"`
|
||||||
|
PlayedAt time.Time `json:"playedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyTrack struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Artists []*SpotifyArtist `json:"artists"`
|
||||||
|
Album *SpotifyAlbum `json:"album"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SteamGame struct {
|
||||||
|
AppID int `json:"appId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Playtime2Weeks int `json:"playtime2Weeks"`
|
||||||
|
PlaytimeForever int `json:"playtimeForever"`
|
||||||
|
HeaderImageURL string `json:"headerImageUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SteamStatus struct {
|
||||||
|
Online bool `json:"online"`
|
||||||
|
RecentGames []*SteamGame `json:"recentGames"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateJobAppReferenceInput struct {
|
||||||
|
Category *string `json:"category,omitempty"`
|
||||||
|
Label *string `json:"label,omitempty"`
|
||||||
|
Value *string `json:"value,omitempty"`
|
||||||
|
SortOrder *int `json:"sortOrder,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateJobApplicationInput struct {
|
||||||
|
JobTitle *string `json:"jobTitle,omitempty"`
|
||||||
|
Company *string `json:"company,omitempty"`
|
||||||
|
Location *string `json:"location,omitempty"`
|
||||||
|
URL *string `json:"url,omitempty"`
|
||||||
|
Status *string `json:"status,omitempty"`
|
||||||
|
Notes *string `json:"notes,omitempty"`
|
||||||
|
AppliedAt *time.Time `json:"appliedAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdatePostInput struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
118
backend/graph/post.resolvers.go
Normal file
118
backend/graph/post.resolvers.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/graph/model"
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreatePost is the resolver for the createPost field.
|
||||||
|
func (r *mutationResolver) CreatePost(ctx context.Context, input model.CreatePostInput) (*models.Post, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := UserIDFromCtx(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID}
|
||||||
|
if err := r.Store.DB.Create(&post).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePost is the resolver for the updatePost field.
|
||||||
|
func (r *mutationResolver) UpdatePost(ctx context.Context, id int, input model.UpdatePostInput) (*models.Post, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := UserIDFromCtx(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
var post models.Post
|
||||||
|
if err := r.Store.DB.First(&post, id).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("post not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if post.AuthorID != userID {
|
||||||
|
return nil, fmt.Errorf("you can only update your own posts")
|
||||||
|
}
|
||||||
|
|
||||||
|
post.Title = input.Title
|
||||||
|
post.Content = input.Content
|
||||||
|
if err := r.Store.DB.Save(&post).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePost is the resolver for the deletePost field.
|
||||||
|
func (r *mutationResolver) DeletePost(ctx context.Context, id int) (*models.Post, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, ok := UserIDFromCtx(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
var post models.Post
|
||||||
|
if err := r.Store.DB.First(&post, id).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("post not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if post.AuthorID != userID {
|
||||||
|
return nil, fmt.Errorf("you can only delete your own posts")
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Store.DB.Delete(&post)
|
||||||
|
return &post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID is the resolver for the id field.
|
||||||
|
func (r *postResolver) ID(ctx context.Context, obj *models.Post) (int, error) {
|
||||||
|
return int(obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Posts is the resolver for the posts field.
|
||||||
|
func (r *queryResolver) Posts(ctx context.Context) ([]*models.Post, error) {
|
||||||
|
var posts []models.Post
|
||||||
|
if err := r.Store.DB.Preload("Author").Order("created_at DESC").Find(&posts).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]*models.Post, len(posts))
|
||||||
|
for i := range posts {
|
||||||
|
result[i] = &posts[i]
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post is the resolver for the post field.
|
||||||
|
func (r *queryResolver) Post(ctx context.Context, id int) (*models.Post, error) {
|
||||||
|
var post models.Post
|
||||||
|
if err := r.Store.DB.Preload("Author").First(&post, id).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("post not found")
|
||||||
|
}
|
||||||
|
return &post, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post returns PostResolver implementation.
|
||||||
|
func (r *Resolver) Post() PostResolver { return &postResolver{r} }
|
||||||
|
|
||||||
|
type postResolver struct{ *Resolver }
|
||||||
12
backend/graph/resolver.go
Normal file
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
|
||||||
|
}
|
||||||
45
backend/graph/rowing.resolvers.go
Normal file
45
backend/graph/rowing.resolvers.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RowingSessions is the resolver for the rowingSessions field.
|
||||||
|
func (r *queryResolver) RowingSessions(ctx context.Context) ([]*models.Rowing, error) {
|
||||||
|
var rows []models.Rowing
|
||||||
|
if err := r.Store.DB.Order("created_at DESC").Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]*models.Rowing, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
result[i] = &rows[i]
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID is the resolver for the id field.
|
||||||
|
func (r *rowingResolver) ID(ctx context.Context, obj *models.Rowing) (int, error) {
|
||||||
|
return int(obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time is the resolver for the time field.
|
||||||
|
func (r *rowingResolver) Time(ctx context.Context, obj *models.Rowing) (int, error) {
|
||||||
|
return int(obj.Time), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Distance is the resolver for the distance field.
|
||||||
|
func (r *rowingResolver) Distance(ctx context.Context, obj *models.Rowing) (int, error) {
|
||||||
|
return int(obj.Distance), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rowing returns RowingResolver implementation.
|
||||||
|
func (r *Resolver) Rowing() RowingResolver { return &rowingResolver{r} }
|
||||||
|
|
||||||
|
type rowingResolver struct{ *Resolver }
|
||||||
15
backend/graph/schema.resolvers.go
Normal file
15
backend/graph/schema.resolvers.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
// Mutation returns MutationResolver implementation.
|
||||||
|
func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} }
|
||||||
|
|
||||||
|
// Query returns QueryResolver implementation.
|
||||||
|
func (r *Resolver) Query() QueryResolver { return &queryResolver{r} }
|
||||||
|
|
||||||
|
type mutationResolver struct{ *Resolver }
|
||||||
|
type queryResolver struct{ *Resolver }
|
||||||
22
backend/graph/schema/activity.graphql
Normal file
22
backend/graph/schema/activity.graphql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
type Activity {
|
||||||
|
id: ID!
|
||||||
|
createdAt: Time!
|
||||||
|
updatedAt: Time!
|
||||||
|
type: String!
|
||||||
|
name: String!
|
||||||
|
link: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateActivityInput {
|
||||||
|
type: String!
|
||||||
|
name: String!
|
||||||
|
link: String
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
activities: [Activity!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
createActivity(input: CreateActivityInput!): Activity!
|
||||||
|
}
|
||||||
18
backend/graph/schema/auth.graphql
Normal file
18
backend/graph/schema/auth.graphql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
input LoginInput {
|
||||||
|
username: String!
|
||||||
|
password: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthPayload {
|
||||||
|
user: User!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
me: User
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
login(input: LoginInput!): AuthPayload!
|
||||||
|
logout: Boolean!
|
||||||
|
refreshToken: AuthPayload!
|
||||||
|
}
|
||||||
23
backend/graph/schema/bookmark.graphql
Normal file
23
backend/graph/schema/bookmark.graphql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
type Bookmark {
|
||||||
|
id: ID!
|
||||||
|
createdAt: Time!
|
||||||
|
updatedAt: Time!
|
||||||
|
category: String!
|
||||||
|
name: String!
|
||||||
|
link: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateBookmarkInput {
|
||||||
|
category: String!
|
||||||
|
name: String!
|
||||||
|
link: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
bookmarks: [Bookmark!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
createBookmark(input: CreateBookmarkInput!): Bookmark!
|
||||||
|
deleteBookmark(id: ID!): Bookmark!
|
||||||
|
}
|
||||||
22
backend/graph/schema/favorite.graphql
Normal file
22
backend/graph/schema/favorite.graphql
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
type Favorite {
|
||||||
|
id: ID!
|
||||||
|
createdAt: Time!
|
||||||
|
updatedAt: Time!
|
||||||
|
type: String!
|
||||||
|
name: String!
|
||||||
|
link: String
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateFavoriteInput {
|
||||||
|
type: String!
|
||||||
|
name: String!
|
||||||
|
link: String
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
favorites: [Favorite!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
createFavorite(input: CreateFavoriteInput!): Favorite!
|
||||||
|
}
|
||||||
12
backend/graph/schema/gitea.graphql
Normal file
12
backend/graph/schema/gitea.graphql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
type GiteaFeedItem {
|
||||||
|
avatarUrl: String!
|
||||||
|
repoUrl: String!
|
||||||
|
repoName: String!
|
||||||
|
opType: String!
|
||||||
|
commitMessage: String!
|
||||||
|
createdAt: Time!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
giteaFeed: GiteaFeedItem
|
||||||
|
}
|
||||||
33
backend/graph/schema/job_app_reference.graphql
Normal file
33
backend/graph/schema/job_app_reference.graphql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
type JobAppReference {
|
||||||
|
id: ID!
|
||||||
|
createdAt: Time!
|
||||||
|
updatedAt: Time!
|
||||||
|
category: String!
|
||||||
|
label: String!
|
||||||
|
value: String!
|
||||||
|
sortOrder: Int!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateJobAppReferenceInput {
|
||||||
|
category: String!
|
||||||
|
label: String!
|
||||||
|
value: String!
|
||||||
|
sortOrder: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateJobAppReferenceInput {
|
||||||
|
category: String
|
||||||
|
label: String
|
||||||
|
value: String
|
||||||
|
sortOrder: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
jobAppReferences: [JobAppReference!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
createJobAppReference(input: CreateJobAppReferenceInput!): JobAppReference!
|
||||||
|
updateJobAppReference(id: ID!, input: UpdateJobAppReferenceInput!): JobAppReference!
|
||||||
|
deleteJobAppReference(id: ID!): Boolean!
|
||||||
|
}
|
||||||
43
backend/graph/schema/job_application.graphql
Normal file
43
backend/graph/schema/job_application.graphql
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
type JobApplication {
|
||||||
|
id: ID!
|
||||||
|
createdAt: Time!
|
||||||
|
updatedAt: Time!
|
||||||
|
jobTitle: String!
|
||||||
|
company: String!
|
||||||
|
location: String
|
||||||
|
url: String
|
||||||
|
status: String!
|
||||||
|
notes: String
|
||||||
|
appliedAt: Time
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateJobApplicationInput {
|
||||||
|
jobTitle: String!
|
||||||
|
company: String!
|
||||||
|
location: String
|
||||||
|
url: String
|
||||||
|
status: String!
|
||||||
|
notes: String
|
||||||
|
appliedAt: Time
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdateJobApplicationInput {
|
||||||
|
jobTitle: String
|
||||||
|
company: String
|
||||||
|
location: String
|
||||||
|
url: String
|
||||||
|
status: String
|
||||||
|
notes: String
|
||||||
|
appliedAt: Time
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
jobApplications: [JobApplication!]!
|
||||||
|
jobApplication(id: ID!): JobApplication
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
createJobApplication(input: CreateJobApplicationInput!): JobApplication!
|
||||||
|
updateJobApplication(id: ID!, input: UpdateJobApplicationInput!): JobApplication!
|
||||||
|
deleteJobApplication(id: ID!): Boolean!
|
||||||
|
}
|
||||||
11
backend/graph/schema/message.graphql
Normal file
11
backend/graph/schema/message.graphql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
type Message {
|
||||||
|
id: ID!
|
||||||
|
content: String!
|
||||||
|
authorId: Int!
|
||||||
|
fileUrl: String
|
||||||
|
createdAt: Time!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
messages: [Message!]!
|
||||||
|
}
|
||||||
29
backend/graph/schema/post.graphql
Normal file
29
backend/graph/schema/post.graphql
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
type Post {
|
||||||
|
id: ID!
|
||||||
|
createdAt: Time!
|
||||||
|
updatedAt: Time!
|
||||||
|
title: String!
|
||||||
|
author: User
|
||||||
|
content: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreatePostInput {
|
||||||
|
title: String!
|
||||||
|
content: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
input UpdatePostInput {
|
||||||
|
title: String!
|
||||||
|
content: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
posts: [Post!]!
|
||||||
|
post(id: ID!): Post
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
createPost(input: CreatePostInput!): Post!
|
||||||
|
updatePost(id: ID!, input: UpdatePostInput!): Post!
|
||||||
|
deletePost(id: ID!): Post!
|
||||||
|
}
|
||||||
13
backend/graph/schema/rowing.graphql
Normal file
13
backend/graph/schema/rowing.graphql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
type Rowing {
|
||||||
|
id: ID!
|
||||||
|
createdAt: Time!
|
||||||
|
date: Time!
|
||||||
|
time: Int!
|
||||||
|
distance: Int!
|
||||||
|
timePer500m: Float!
|
||||||
|
calories: Float!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
rowingSessions: [Rowing!]!
|
||||||
|
}
|
||||||
4
backend/graph/schema/schema.graphql
Normal file
4
backend/graph/schema/schema.graphql
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
scalar Time
|
||||||
|
|
||||||
|
type Query
|
||||||
|
type Mutation
|
||||||
33
backend/graph/schema/spotify.graphql
Normal file
33
backend/graph/schema/spotify.graphql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
type SpotifyArtist {
|
||||||
|
name: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyImage {
|
||||||
|
url: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyAlbum {
|
||||||
|
name: String!
|
||||||
|
images: [SpotifyImage!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyTrack {
|
||||||
|
name: String!
|
||||||
|
artists: [SpotifyArtist!]!
|
||||||
|
album: SpotifyAlbum!
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyPlaying {
|
||||||
|
playing: Boolean!
|
||||||
|
track: SpotifyTrack
|
||||||
|
}
|
||||||
|
|
||||||
|
type SpotifyRecentItem {
|
||||||
|
track: SpotifyTrack!
|
||||||
|
playedAt: Time!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
spotifyListening: SpotifyPlaying
|
||||||
|
spotifyRecent: [SpotifyRecentItem!]
|
||||||
|
}
|
||||||
16
backend/graph/schema/steam.graphql
Normal file
16
backend/graph/schema/steam.graphql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
type SteamGame {
|
||||||
|
appId: Int!
|
||||||
|
name: String!
|
||||||
|
playtime2Weeks: Int!
|
||||||
|
playtimeForever: Int!
|
||||||
|
headerImageUrl: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
type SteamStatus {
|
||||||
|
online: Boolean!
|
||||||
|
recentGames: [SteamGame!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
steamStatus: SteamStatus
|
||||||
|
}
|
||||||
23
backend/graph/schema/user.graphql
Normal file
23
backend/graph/schema/user.graphql
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
type User {
|
||||||
|
id: ID!
|
||||||
|
createdAt: Time!
|
||||||
|
updatedAt: Time!
|
||||||
|
username: String!
|
||||||
|
admin: Boolean!
|
||||||
|
}
|
||||||
|
|
||||||
|
input CreateUserInput {
|
||||||
|
username: String!
|
||||||
|
password: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Query {
|
||||||
|
users: [User!]!
|
||||||
|
user(id: ID!): User
|
||||||
|
}
|
||||||
|
|
||||||
|
extend type Mutation {
|
||||||
|
createUser(input: CreateUserInput!): User!
|
||||||
|
deleteUser(id: ID!): User!
|
||||||
|
setUserAdmin(id: ID!, admin: Boolean!): User!
|
||||||
|
}
|
||||||
55
backend/graph/spotify.resolvers.go
Normal file
55
backend/graph/spotify.resolvers.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/graph/model"
|
||||||
|
spotify "github.com/zmb3/spotify/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SpotifyListening is the resolver for the spotifyListening field.
|
||||||
|
func (r *queryResolver) SpotifyListening(ctx context.Context) (*model.SpotifyPlaying, error) {
|
||||||
|
if r.Store.SpotifyClient == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
playing, err := r.Store.SpotifyClient.PlayerCurrentlyPlaying(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &model.SpotifyPlaying{Playing: playing.Playing}
|
||||||
|
if playing.Item != nil {
|
||||||
|
result.Track = mapSpotifyTrack(playing.Item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SpotifyRecent is the resolver for the spotifyRecent field.
|
||||||
|
func (r *queryResolver) SpotifyRecent(ctx context.Context) ([]*model.SpotifyRecentItem, error) {
|
||||||
|
if r.Store.SpotifyClient == nil {
|
||||||
|
return []*model.SpotifyRecentItem{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Store.RecentSongsFresh() {
|
||||||
|
return mapRecentItems(*r.Store.RecentSongs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := spotify.RecentlyPlayedOptions{Limit: 3}
|
||||||
|
played, err := r.Store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Store.RecentSongs = &played
|
||||||
|
r.Store.RecentSongsFetchedAt = time.Now()
|
||||||
|
|
||||||
|
return mapRecentItems(played), nil
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
52
backend/graph/steam.resolvers.go
Normal file
52
backend/graph/steam.resolvers.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/graph/model"
|
||||||
|
"adam-french.co.uk/backend/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SteamStatus is the resolver for the steamStatus field.
|
||||||
|
func (r *queryResolver) SteamStatus(ctx context.Context) (*model.SteamStatus, error) {
|
||||||
|
if r.Store.SteamAPIKey == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.Store.SteamFresh() {
|
||||||
|
return &model.SteamStatus{
|
||||||
|
Online: r.Store.SteamOnline,
|
||||||
|
RecentGames: mapSteamGames(r.Store.SteamRecentGames),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
games, err := services.FetchRecentlyPlayedGames(r.Store.SteamAPIKey, r.Store.SteamID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := services.FetchPlayerSummary(r.Store.SteamAPIKey, r.Store.SteamID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
online := false
|
||||||
|
if summary != nil {
|
||||||
|
online = summary.PersonaState > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Store.SteamRecentGames = games
|
||||||
|
r.Store.SteamOnline = online
|
||||||
|
r.Store.SteamFetchedAt = time.Now()
|
||||||
|
|
||||||
|
return &model.SteamStatus{
|
||||||
|
Online: online,
|
||||||
|
RecentGames: mapSteamGames(games),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
118
backend/graph/user.resolvers.go
Normal file
118
backend/graph/user.resolvers.go
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
package graph
|
||||||
|
|
||||||
|
// This file will be automatically regenerated based on the schema, any resolver
|
||||||
|
// implementations
|
||||||
|
// will be copied through when generating and any unknown code will be moved to the end.
|
||||||
|
// Code generated by github.com/99designs/gqlgen version v0.17.88
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/graph/model"
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateUser is the resolver for the createUser field.
|
||||||
|
func (r *mutationResolver) CreateUser(ctx context.Context, input model.CreateUserInput) (*models.User, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
user := models.User{Username: input.Username, Password: hashedPassword}
|
||||||
|
if err := r.Store.DB.Create(&user).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUser is the resolver for the deleteUser field.
|
||||||
|
func (r *mutationResolver) DeleteUser(ctx context.Context, id int) (*models.User, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
if err := r.Store.DB.First(&user, id).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.Store.DB.Delete(&user).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserAdmin is the resolver for the setUserAdmin field.
|
||||||
|
func (r *mutationResolver) SetUserAdmin(ctx context.Context, id int, admin bool) (*models.User, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
|
||||||
|
callerID, ok := UserIDFromCtx(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unauthorized")
|
||||||
|
}
|
||||||
|
|
||||||
|
if uint(id) == callerID {
|
||||||
|
return nil, fmt.Errorf("cannot change your own admin status")
|
||||||
|
}
|
||||||
|
|
||||||
|
var user models.User
|
||||||
|
if err := r.Store.DB.First(&user, id).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Admin = admin
|
||||||
|
if err := r.Store.DB.Save(&user).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users is the resolver for the users field.
|
||||||
|
func (r *queryResolver) Users(ctx context.Context) ([]*models.User, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
var users []models.User
|
||||||
|
if err := r.Store.DB.Find(&users).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result := make([]*models.User, len(users))
|
||||||
|
for i := range users {
|
||||||
|
result[i] = &users[i]
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// User is the resolver for the user field.
|
||||||
|
func (r *queryResolver) User(ctx context.Context, id int) (*models.User, error) {
|
||||||
|
if !IsAdminFromCtx(ctx) {
|
||||||
|
return nil, fmt.Errorf("admin access required")
|
||||||
|
}
|
||||||
|
var user models.User
|
||||||
|
if err := r.Store.DB.First(&user, id).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("user not found")
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ID is the resolver for the id field.
|
||||||
|
func (r *userResolver) ID(ctx context.Context, obj *models.User) (int, error) {
|
||||||
|
return int(obj.ID), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// User returns UserResolver implementation.
|
||||||
|
func (r *Resolver) User() UserResolver { return &userResolver{r} }
|
||||||
|
|
||||||
|
type userResolver struct{ *Resolver }
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"adam-french.co.uk/backend/models"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CreateActivityInput struct {
|
|
||||||
Type string `json:"type" binding:"required"`
|
|
||||||
Name string `json:"name" binding:"required"`
|
|
||||||
Link *string `json:"link"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *Store) GetActivity(ctx *gin.Context) {
|
|
||||||
var activitys []models.Activity
|
|
||||||
if err := store.DB.Order("Created_At DESC").Find(&activitys).Error; err != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.JSON(http.StatusOK, activitys)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *Store) CreateActivity(ctx *gin.Context) {
|
|
||||||
var input CreateActivityInput
|
|
||||||
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
activity := models.Activity{Type: input.Type, Name: input.Name, Link: input.Link}
|
|
||||||
tx := store.DB.Create(&activity)
|
|
||||||
if tx.Error != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusCreated, activity)
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,30 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"adam-french.co.uk/backend/models"
|
"adam-french.co.uk/backend/models"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type UserCredentials struct {
|
||||||
|
Username string `json:"username" binding:"required"`
|
||||||
|
Password string `json:"password" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
func (store *Store) AuthMiddlewear(ctx *gin.Context) {
|
func (store *Store) AuthMiddlewear(ctx *gin.Context) {
|
||||||
access_token, err := ctx.Cookie("access_token")
|
access_token, err := ctx.Cookie("access_token")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.AbortWithStatusJSON(401, err.Error())
|
ctx.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, err := store.Auth.VerifyJWT(access_token)
|
claims, err := store.Auth.VerifyJWT(access_token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.AbortWithStatusJSON(401, err.Error())
|
ctx.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +33,109 @@ func (store *Store) AuthMiddlewear(ctx *gin.Context) {
|
|||||||
ctx.Next()
|
ctx.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (store *Store) AdminMiddleware(ctx *gin.Context) {
|
||||||
|
claims, exists := ctx.Get("userClaims")
|
||||||
|
if !exists {
|
||||||
|
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mapClaims, ok := claims.(*jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
ctx.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid claims"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
admin, ok := (*mapClaims)["admin"].(bool)
|
||||||
|
if !ok || !admin {
|
||||||
|
ctx.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "admin access required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) ValidateAdmin(ctx *gin.Context) {
|
||||||
|
accessToken, err := ctx.Cookie("access_token")
|
||||||
|
if err != nil {
|
||||||
|
// No access token — try refreshing
|
||||||
|
if !store.tryRefreshAndValidateAdmin(ctx) {
|
||||||
|
ctx.Status(http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := store.Auth.VerifyJWT(accessToken)
|
||||||
|
if err != nil {
|
||||||
|
// Expired/invalid access token — try refreshing
|
||||||
|
if !store.tryRefreshAndValidateAdmin(ctx) {
|
||||||
|
ctx.Status(http.StatusUnauthorized)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
admin, ok := (*claims)["admin"].(bool)
|
||||||
|
if !ok || !admin {
|
||||||
|
ctx.Status(http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) tryRefreshAndValidateAdmin(ctx *gin.Context) bool {
|
||||||
|
refreshToken, err := ctx.Cookie("refresh_token")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := store.Auth.VerifyJWT(refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDF, ok := (*claims)["id"].(float64)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
user := models.User{ID: uint(userIDF)}
|
||||||
|
if err := store.DB.First(&user).Error; err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.Admin {
|
||||||
|
ctx.Status(http.StatusForbidden)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := store.Auth.GenerateJWT(&user)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
|
ctx.SetCookie(
|
||||||
|
"access_token",
|
||||||
|
tokens.AccessToken,
|
||||||
|
int(store.Auth.Config.AccessTokenLifetime.Seconds()),
|
||||||
|
"/",
|
||||||
|
store.Auth.Config.Domain,
|
||||||
|
true, true,
|
||||||
|
)
|
||||||
|
ctx.SetCookie(
|
||||||
|
"refresh_token",
|
||||||
|
tokens.RefreshToken,
|
||||||
|
int(store.Auth.Config.RefreshTokenLifetime.Seconds()),
|
||||||
|
"/",
|
||||||
|
store.Auth.Config.Domain,
|
||||||
|
true, true,
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx.Status(http.StatusOK)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (store *Store) CheckToken(ctx *gin.Context) {
|
func (store *Store) CheckToken(ctx *gin.Context) {
|
||||||
access_token, err := ctx.Cookie("access_token")
|
access_token, err := ctx.Cookie("access_token")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -36,13 +145,13 @@ func (store *Store) CheckToken(ctx *gin.Context) {
|
|||||||
|
|
||||||
claims, err := store.Auth.VerifyJWT(access_token)
|
claims, err := store.Auth.VerifyJWT(access_token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(401, err.Error())
|
ctx.JSON(401, gin.H{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userIDF, ok := (*claims)["id"].(float64)
|
userIDF, ok := (*claims)["id"].(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
ctx.JSON(401, gin.H{"error": "claims does not contain id"})
|
ctx.JSON(401, gin.H{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID := uint(userIDF)
|
userID := uint(userIDF)
|
||||||
@@ -50,8 +159,9 @@ func (store *Store) CheckToken(ctx *gin.Context) {
|
|||||||
user := models.User{ID: userID}
|
user := models.User{ID: userID}
|
||||||
tx := store.DB.First(&user)
|
tx := store.DB.First(&user)
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
ctx.JSON(http.StatusNotFound, tx.Error.Error())
|
log.Println(tx.Error)
|
||||||
removeCookies(ctx)
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
store.removeCookies(ctx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,17 +171,16 @@ func (store *Store) CheckToken(ctx *gin.Context) {
|
|||||||
func (store *Store) RefreshToken(ctx *gin.Context) {
|
func (store *Store) RefreshToken(ctx *gin.Context) {
|
||||||
refreshToken, err := ctx.Cookie("refresh_token")
|
refreshToken, err := ctx.Cookie("refresh_token")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusUnauthorized, err.Error())
|
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, err := store.Auth.VerifyJWT(refreshToken)
|
claims, err := store.Auth.VerifyJWT(refreshToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusUnauthorized, err.Error())
|
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("claims: %v\n", claims)
|
|
||||||
|
|
||||||
userIDF, ok := (*claims)["id"].(float64)
|
userIDF, ok := (*claims)["id"].(float64)
|
||||||
if !ok {
|
if !ok {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid token claims"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid token claims"})
|
||||||
@@ -82,22 +191,25 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
|
|||||||
user := models.User{ID: userID}
|
user := models.User{ID: userID}
|
||||||
tx := store.DB.First(&user)
|
tx := store.DB.First(&user)
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
ctx.JSON(http.StatusNotFound, tx.Error.Error())
|
log.Println(tx.Error)
|
||||||
removeCookies(ctx)
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
|
store.removeCookies(ctx)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens, err := store.Auth.GenerateJWT(&user)
|
tokens, err := store.Auth.GenerateJWT(&user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, err.Error())
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
ctx.SetCookie(
|
ctx.SetCookie(
|
||||||
"access_token",
|
"access_token",
|
||||||
tokens.AccessToken,
|
tokens.AccessToken,
|
||||||
int(store.Auth.Config.AccessTokenLifetime.Seconds()),
|
int(store.Auth.Config.AccessTokenLifetime.Seconds()),
|
||||||
store.Auth.Config.Endpoint,
|
"/",
|
||||||
store.Auth.Config.Domain,
|
store.Auth.Config.Domain,
|
||||||
true, true,
|
true, true,
|
||||||
)
|
)
|
||||||
@@ -105,7 +217,7 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
|
|||||||
"refresh_token",
|
"refresh_token",
|
||||||
tokens.RefreshToken,
|
tokens.RefreshToken,
|
||||||
int(store.Auth.Config.RefreshTokenLifetime.Seconds()),
|
int(store.Auth.Config.RefreshTokenLifetime.Seconds()),
|
||||||
store.Auth.Config.Endpoint,
|
"/",
|
||||||
store.Auth.Config.Domain,
|
store.Auth.Config.Domain,
|
||||||
true, true,
|
true, true,
|
||||||
)
|
)
|
||||||
@@ -114,34 +226,41 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) Login(ctx *gin.Context) {
|
func (store *Store) Login(ctx *gin.Context) {
|
||||||
|
if !store.LoginLimiter.Allow(ctx.ClientIP()) {
|
||||||
|
ctx.JSON(http.StatusTooManyRequests, gin.H{"error": "too many login attempts, please try again later"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var input UserCredentials
|
var input UserCredentials
|
||||||
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
||||||
ctx.JSON(http.StatusBadRequest, err.Error())
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := models.User{}
|
user := models.User{}
|
||||||
if err := store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
|
if err := store.DB.Where("username = ?", input.Username).First(&user).Error; err != nil {
|
||||||
ctx.JSON(http.StatusNotFound, err.Error())
|
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil {
|
if err := bcrypt.CompareHashAndPassword(user.Password, []byte(input.Password)); err != nil {
|
||||||
ctx.JSON(http.StatusUnauthorized, err.Error())
|
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tokens, err := store.Auth.GenerateJWT(&user)
|
tokens, err := store.Auth.GenerateJWT(&user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, err.Error())
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
ctx.SetCookie(
|
ctx.SetCookie(
|
||||||
"access_token",
|
"access_token",
|
||||||
tokens.AccessToken,
|
tokens.AccessToken,
|
||||||
int(store.Auth.Config.AccessTokenLifetime.Seconds()),
|
int(store.Auth.Config.AccessTokenLifetime.Seconds()),
|
||||||
store.Auth.Config.Endpoint,
|
"/",
|
||||||
store.Auth.Config.Domain,
|
store.Auth.Config.Domain,
|
||||||
true, true,
|
true, true,
|
||||||
)
|
)
|
||||||
@@ -149,7 +268,7 @@ func (store *Store) Login(ctx *gin.Context) {
|
|||||||
"refresh_token",
|
"refresh_token",
|
||||||
tokens.RefreshToken,
|
tokens.RefreshToken,
|
||||||
int(store.Auth.Config.RefreshTokenLifetime.Seconds()),
|
int(store.Auth.Config.RefreshTokenLifetime.Seconds()),
|
||||||
store.Auth.Config.Endpoint,
|
"/",
|
||||||
store.Auth.Config.Domain,
|
store.Auth.Config.Domain,
|
||||||
true, true,
|
true, true,
|
||||||
)
|
)
|
||||||
@@ -158,26 +277,27 @@ func (store *Store) Login(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) Logout(ctx *gin.Context) {
|
func (store *Store) Logout(ctx *gin.Context) {
|
||||||
removeCookies(ctx)
|
store.removeCookies(ctx)
|
||||||
|
|
||||||
ctx.Status(http.StatusOK)
|
ctx.Status(http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeCookies(ctx *gin.Context) {
|
func (store *Store) removeCookies(ctx *gin.Context) {
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
ctx.SetCookie(
|
ctx.SetCookie(
|
||||||
"access_token",
|
"access_token",
|
||||||
"",
|
"",
|
||||||
-1,
|
-1,
|
||||||
"",
|
"/",
|
||||||
"",
|
store.Auth.Config.Domain,
|
||||||
true, true,
|
true, true,
|
||||||
)
|
)
|
||||||
ctx.SetCookie(
|
ctx.SetCookie(
|
||||||
"refresh_token",
|
"refresh_token",
|
||||||
"",
|
"",
|
||||||
-1,
|
-1,
|
||||||
"",
|
"/",
|
||||||
"",
|
store.Auth.Config.Domain,
|
||||||
true, true,
|
true, true,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
45
backend/handlers/handle_email_sync.go
Normal file
45
backend/handlers/handle_email_sync.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (store *Store) TriggerEmailSync(ctx *gin.Context) {
|
||||||
|
if store.EmailSync == nil || store.EmailSync.HTTPClient == nil {
|
||||||
|
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "email sync not configured or not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := store.EmailSync.SyncEmails(ctx.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[EmailSync] Manual sync error: %v", err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "sync failed", "details": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"message": "sync completed"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) CompleteEmailAuth(ctx *gin.Context) {
|
||||||
|
if store.EmailSync == nil {
|
||||||
|
ctx.JSON(http.StatusServiceUnavailable, gin.H{"error": "email sync not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code := ctx.Query("code")
|
||||||
|
if code == "" {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "missing authorization code"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := store.EmailSync.CompleteAuth(ctx.Request.Context(), code); err != nil {
|
||||||
|
log.Printf("[EmailSync] Auth completion error: %v", err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "authentication failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"message": "email authentication successful"})
|
||||||
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"adam-french.co.uk/backend/models"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CreateFavoriteInput struct {
|
|
||||||
Type string `json:"type" binding:"required"`
|
|
||||||
Name string `json:"name" binding:"required"`
|
|
||||||
Link *string `json:"link"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *Store) GetFavorites(ctx *gin.Context) {
|
|
||||||
var favorites []models.Favorite
|
|
||||||
if err := store.DB.Order("Created_At DESC").Find(&favorites).Error; err != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.JSON(http.StatusOK, favorites)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *Store) CreateFavorite(ctx *gin.Context) {
|
|
||||||
var input CreateFavoriteInput
|
|
||||||
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
favorite := models.Favorite{Type: input.Type, Name: input.Name, Link: input.Link}
|
|
||||||
tx := store.DB.Create(&favorite)
|
|
||||||
if tx.Error != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusCreated, favorite)
|
|
||||||
}
|
|
||||||
97
backend/handlers/handle_message_upload.go
Normal file
97
backend/handlers/handle_message_upload.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
var allowedExtensions = map[string]bool{
|
||||||
|
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
||||||
|
".mp4": true, ".webm": true, ".mp3": true, ".ogg": true,
|
||||||
|
".pdf": true, ".txt": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var extensionToMIMEPrefix = map[string]string{
|
||||||
|
".jpg": "image/", ".jpeg": "image/", ".png": "image/", ".gif": "image/", ".webp": "image/",
|
||||||
|
".mp4": "video/", ".webm": "video/",
|
||||||
|
".pdf": "application/pdf", ".txt": "text/",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) UploadMessageFile(ctx *gin.Context) {
|
||||||
|
file, err := ctx.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSize = 10 << 20 // 10MB
|
||||||
|
if file.Size > maxSize {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file too large"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||||
|
if !allowedExtensions[ext] {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file type not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate actual content type matches extension
|
||||||
|
f, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf := make([]byte, 512)
|
||||||
|
n, err := f.Read(buf)
|
||||||
|
f.Close()
|
||||||
|
if err != nil && n == 0 {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "failed to read file content"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detectedType := http.DetectContentType(buf[:n])
|
||||||
|
|
||||||
|
expectedPrefix, ok := extensionToMIMEPrefix[ext]
|
||||||
|
if ok && !strings.HasPrefix(detectedType, expectedPrefix) {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file content does not match extension"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
b := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
filename := hex.EncodeToString(b) + ext
|
||||||
|
|
||||||
|
uploadDir := "/backend/uploads/"
|
||||||
|
dest := filepath.Join(uploadDir, filename)
|
||||||
|
|
||||||
|
src, err := file.Open()
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(out, src); err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"url": "/uploads/" + filename})
|
||||||
|
}
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"adam-french.co.uk/backend/models"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
type CreatePostInput struct {
|
|
||||||
Title string `json:"title" binding:"required"`
|
|
||||||
Content string `json:"content" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *Store) GetPosts(ctx *gin.Context) {
|
|
||||||
var posts []models.Post
|
|
||||||
if err := store.DB.Preload("Author").Order("Created_At DESC").Find(&posts).Error; err != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.JSON(http.StatusOK, posts)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *Store) GetPost(ctx *gin.Context) {
|
|
||||||
postIDStr := ctx.Param("id")
|
|
||||||
|
|
||||||
postID, err := strconv.ParseUint(postIDStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, "invalid id")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
post := models.Post{ID: uint(postID)}
|
|
||||||
if err := store.DB.First(&post).Error; err != nil {
|
|
||||||
ctx.JSON(http.StatusNotFound, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, post)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *Store) CreatePost(ctx *gin.Context) {
|
|
||||||
var input CreatePostInput
|
|
||||||
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
claimsVal, ok := ctx.Get("userClaims")
|
|
||||||
if !ok {
|
|
||||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, ok := claimsVal.(*jwt.MapClaims)
|
|
||||||
if !ok {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userIDF, ok := (*claims)["id"].(float64)
|
|
||||||
if !ok {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID := uint(userIDF)
|
|
||||||
|
|
||||||
if !(*claims)["admin"].(bool) {
|
|
||||||
ctx.JSON(http.StatusForbidden, gin.H{"error": "you are not admin :("})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create post
|
|
||||||
post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID}
|
|
||||||
tx := store.DB.Create(&post)
|
|
||||||
if tx.Error != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusCreated, post)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *Store) UpdatePost(ctx *gin.Context) {
|
|
||||||
postID := ctx.Param("id")
|
|
||||||
var post models.Post
|
|
||||||
if err := store.DB.First(&post, postID).Error; err != nil {
|
|
||||||
ctx.JSON(http.StatusNotFound, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
claimsVal, ok := ctx.Get("userClaims")
|
|
||||||
if !ok {
|
|
||||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, ok := claimsVal.(*jwt.MapClaims)
|
|
||||||
if !ok {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userIDF, ok := (*claims)["id"].(float64)
|
|
||||||
if !ok {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID := uint(userIDF)
|
|
||||||
|
|
||||||
if !(userID == post.AuthorID) {
|
|
||||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user and post author id missmatch"})
|
|
||||||
}
|
|
||||||
|
|
||||||
var input CreatePostInput
|
|
||||||
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
post.Title = input.Title
|
|
||||||
post.Content = input.Content
|
|
||||||
tx := store.DB.Save(&post)
|
|
||||||
if tx.Error != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, post)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *Store) DeletePost(ctx *gin.Context) {
|
|
||||||
postID := ctx.Param("id")
|
|
||||||
var post models.Post
|
|
||||||
if err := store.DB.First(&post, postID).Error; err != nil {
|
|
||||||
ctx.JSON(http.StatusNotFound, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
claimsVal, ok := ctx.Get("userClaims")
|
|
||||||
if !ok {
|
|
||||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, ok := claimsVal.(*jwt.MapClaims)
|
|
||||||
if !ok {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userIDF, ok := (*claims)["id"].(float64)
|
|
||||||
if !ok {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID := uint(userIDF)
|
|
||||||
|
|
||||||
if !(userID == post.AuthorID) {
|
|
||||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user and post author id missmatch"})
|
|
||||||
}
|
|
||||||
|
|
||||||
store.DB.Delete(&post)
|
|
||||||
ctx.JSON(http.StatusOK, post)
|
|
||||||
}
|
|
||||||
185
backend/handlers/handle_radio.go
Normal file
185
backend/handlers/handle_radio.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fallbackMusicDir = "/backend/fallback_music"
|
||||||
|
|
||||||
|
var allowedAudioExtensions = map[string]bool{
|
||||||
|
".mp3": true, ".ogg": true, ".flac": true, ".wav": true, ".m4a": true, ".opus": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) UploadRadioSong(ctx *gin.Context) {
|
||||||
|
file, err := ctx.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSize = 50 << 20 // 50MB
|
||||||
|
if file.Size > maxSize {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file too large (max 50MB)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(file.Filename))
|
||||||
|
if !allowedAudioExtensions[ext] {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "file type not allowed (accepted: .mp3, .ogg, .flac, .wav, .m4a, .opus)"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := filepath.Base(file.Filename)
|
||||||
|
dest := filepath.Join(fallbackMusicDir, filename)
|
||||||
|
|
||||||
|
// Verify the resolved path stays within the music directory
|
||||||
|
absDest, err := filepath.Abs(dest)
|
||||||
|
if err != nil || !strings.HasPrefix(absDest, fallbackMusicDir+string(os.PathSeparator)) {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(dest); err == nil {
|
||||||
|
ctx.JSON(http.StatusConflict, gin.H{"error": "file already exists"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.SaveUploadedFile(file, dest); err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"name": filename})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) ListRadioSongs(ctx *gin.Context) {
|
||||||
|
type songInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Modified int64 `json:"modified"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
songs := []songInfo{}
|
||||||
|
|
||||||
|
// Read enabled songs
|
||||||
|
entries, err := os.ReadDir(fallbackMusicDir)
|
||||||
|
if err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read music directory"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
songs = append(songs, songInfo{
|
||||||
|
Name: entry.Name(),
|
||||||
|
Size: info.Size(),
|
||||||
|
Modified: info.ModTime().Unix(),
|
||||||
|
Disabled: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read disabled songs
|
||||||
|
disabledDir := filepath.Join(fallbackMusicDir, "disabled")
|
||||||
|
disabledEntries, err := os.ReadDir(disabledDir)
|
||||||
|
if err == nil {
|
||||||
|
for _, entry := range disabledEntries {
|
||||||
|
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
songs = append(songs, songInfo{
|
||||||
|
Name: entry.Name(),
|
||||||
|
Size: info.Size(),
|
||||||
|
Modified: info.ModTime().Unix(),
|
||||||
|
Disabled: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"songs": songs})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) DisableRadioSong(ctx *gin.Context) {
|
||||||
|
filename := filepath.Base(ctx.Param("filename"))
|
||||||
|
if filename == "." || filename == "/" {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
src := filepath.Join(fallbackMusicDir, filename)
|
||||||
|
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||||
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
disabledDir := filepath.Join(fallbackMusicDir, "disabled")
|
||||||
|
if err := os.MkdirAll(disabledDir, 0o755); err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create disabled directory"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := filepath.Join(disabledDir, filename)
|
||||||
|
if err := os.Rename(src, dst); err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to disable song"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"disabled": filename})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) EnableRadioSong(ctx *gin.Context) {
|
||||||
|
filename := filepath.Base(ctx.Param("filename"))
|
||||||
|
if filename == "." || filename == "/" {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
src := filepath.Join(fallbackMusicDir, "disabled", filename)
|
||||||
|
if _, err := os.Stat(src); os.IsNotExist(err) {
|
||||||
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "file not found in disabled directory"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dst := filepath.Join(fallbackMusicDir, filename)
|
||||||
|
if err := os.Rename(src, dst); err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to enable song"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"enabled": filename})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (store *Store) DeleteRadioSong(ctx *gin.Context) {
|
||||||
|
filename := filepath.Base(ctx.Param("filename"))
|
||||||
|
if filename == "." || filename == "/" {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid filename"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := filepath.Join(fallbackMusicDir, filename)
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
ctx.JSON(http.StatusNotFound, gin.H{"error": "file not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.JSON(http.StatusOK, gin.H{"deleted": filename})
|
||||||
|
}
|
||||||
@@ -5,8 +5,9 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"strings"
|
||||||
|
|
||||||
"github.com/rwcarlsen/goexif/exif"
|
"github.com/rwcarlsen/goexif/exif"
|
||||||
|
|
||||||
@@ -16,15 +17,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ExtractedRowingData struct {
|
type ExtractedRowingData struct {
|
||||||
TimeMinutes float64 `json:"timeMinutes"`
|
TimeMinutes uint64 `json:"timeMinutes"`
|
||||||
TimeSeconds float64 `json:"timeSeconds"`
|
TimeSeconds uint64 `json:"timeSeconds"`
|
||||||
Distance float64 `json:"distance"`
|
Distance uint64 `json:"distance"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) GetRowing(ctx *gin.Context) {
|
func (store *Store) GetRowing(ctx *gin.Context) {
|
||||||
var rowing []models.Rowing
|
var rowing []models.Rowing
|
||||||
if err := store.DB.Order("Created_At DESC").Find(&rowing).Error; err != nil {
|
if err := store.DB.Order("Created_At DESC").Find(&rowing).Error; err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, err.Error())
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx.JSON(http.StatusOK, rowing)
|
ctx.JSON(http.StatusOK, rowing)
|
||||||
@@ -82,6 +84,13 @@ func (store *Store) CreateRowing(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
encoded := base64.StdEncoding.EncodeToString(data)
|
encoded := base64.StdEncoding.EncodeToString(data)
|
||||||
|
|
||||||
|
// Reject duplicates: same EXIF datetime already recorded
|
||||||
|
var existing models.Rowing
|
||||||
|
if err := store.DB.Where("date = ?", dateTaken).First(&existing).Error; err == nil {
|
||||||
|
ctx.JSON(http.StatusConflict, gin.H{"error": "duplicate entry for this date"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Build the message with an image + text prompt
|
// Build the message with an image + text prompt
|
||||||
message, err := store.ClaudeClient.Messages.New(context.Background(), anthropic.MessageNewParams{
|
message, err := store.ClaudeClient.Messages.New(context.Background(), anthropic.MessageNewParams{
|
||||||
Model: anthropic.ModelClaudeHaiku4_5,
|
Model: anthropic.ModelClaudeHaiku4_5,
|
||||||
@@ -110,21 +119,30 @@ No text, no markdown, no explanation. Just the JSON object.`),
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to analyze image"})
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process image"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(message.Content) == 0 {
|
if len(message.Content) == 0 {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "empty response from Claude"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "empty response from image processor"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
extractedData := ExtractedRowingData{}
|
extractedData := ExtractedRowingData{}
|
||||||
err = json.Unmarshal([]byte(message.Content[0].Text), &extractedData)
|
raw := message.Content[0].Text
|
||||||
|
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
raw = strings.TrimPrefix(raw, "```json")
|
||||||
|
raw = strings.TrimPrefix(raw, "```")
|
||||||
|
raw = strings.TrimSuffix(raw, "```")
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
|
||||||
|
err = json.Unmarshal([]byte(raw), &extractedData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse JSON response"})
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse image data"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,15 +152,39 @@ No text, no markdown, no explanation. Just the JSON object.`),
|
|||||||
}
|
}
|
||||||
|
|
||||||
totalSeconds := extractedData.TimeMinutes*60 + extractedData.TimeSeconds
|
totalSeconds := extractedData.TimeMinutes*60 + extractedData.TimeSeconds
|
||||||
totalDuration := time.Duration(totalSeconds * float64(time.Second))
|
|
||||||
per500m := time.Duration(totalSeconds / extractedData.Distance * 500 * float64(time.Second))
|
// Validate for anomalous values
|
||||||
|
const (
|
||||||
|
minDistance = 100 // metres
|
||||||
|
maxDistance = 100000 // metres
|
||||||
|
minTotalSecs = 30 // 30 seconds
|
||||||
|
maxTotalSecs = 7200 // 2 hours
|
||||||
|
minPacePer500m = 80 // ~1:20 /500m (faster than any human)
|
||||||
|
maxPacePer500m = 150 // ~2:30 /500m (slow, not important)
|
||||||
|
)
|
||||||
|
if extractedData.Distance < minDistance || extractedData.Distance > maxDistance {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous distance value"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if totalSeconds < minTotalSecs || totalSeconds > maxTotalSecs {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous time value"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
per500m := float64(totalSeconds) / float64(extractedData.Distance) * 500.0
|
||||||
|
if per500m < minPacePer500m || per500m > maxPacePer500m {
|
||||||
|
ctx.JSON(http.StatusBadRequest, gin.H{"error": "anomalous pace value"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
calories := float64(extractedData.Distance) / 7500.0 * 500.0
|
||||||
|
|
||||||
rowing := models.Rowing{
|
rowing := models.Rowing{
|
||||||
Date: dateTaken,
|
Date: dateTaken,
|
||||||
Time: totalDuration,
|
Time: totalSeconds,
|
||||||
TimePer500m: per500m,
|
TimePer500m: per500m,
|
||||||
Distance: extractedData.Distance,
|
Distance: extractedData.Distance,
|
||||||
Calories: extractedData.Distance / 7500.0 * 500.0,
|
Calories: calories,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := store.DB.Create(&rowing).Error; err != nil {
|
if err := store.DB.Create(&rowing).Error; err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -17,11 +18,16 @@ func (store *Store) CompleteSpotifyAuth(ctx *gin.Context) {
|
|||||||
|
|
||||||
token, err := store.SpotifyAuth.Token(c, state, ctx.Request)
|
token, err := store.SpotifyAuth.Token(c, state, ctx.Request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.String(http.StatusInternalServerError, "Couldn't get token: %v", err)
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "authentication failed"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
services.SaveSpotifyToken(services.SPOTIFY_TOKEN_JSON_PATH, token)
|
if err := services.SaveSpotifyToken(services.SPOTIFY_TOKEN_JSON_PATH, token); err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
client := spotify.New(store.SpotifyAuth.Client(c, token))
|
client := spotify.New(store.SpotifyAuth.Client(c, token))
|
||||||
|
|
||||||
@@ -29,9 +35,6 @@ func (store *Store) CompleteSpotifyAuth(ctx *gin.Context) {
|
|||||||
|
|
||||||
ctx.JSON(http.StatusOK, gin.H{
|
ctx.JSON(http.StatusOK, gin.H{
|
||||||
"message": "Authentication successful",
|
"message": "Authentication successful",
|
||||||
"token": token.AccessToken,
|
|
||||||
"type": token.TokenType,
|
|
||||||
"expiry": token.Expiry,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +48,8 @@ func (store *Store) ListeningTo(ctx *gin.Context) {
|
|||||||
|
|
||||||
playing, err := store.SpotifyClient.PlayerCurrentlyPlaying(c)
|
playing, err := store.SpotifyClient.PlayerCurrentlyPlaying(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(500, gin.H{"error": err.Error()})
|
log.Println(err)
|
||||||
|
ctx.JSON(500, gin.H{"error": "failed to fetch currently playing"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,15 +57,22 @@ func (store *Store) ListeningTo(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) RecentlyPlayed(ctx *gin.Context) {
|
func (store *Store) RecentlyPlayed(ctx *gin.Context) {
|
||||||
|
if store.SpotifyClient == nil {
|
||||||
|
ctx.JSON(500, gin.H{"error": "Spotify not authenticated"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
opts := spotify.RecentlyPlayedOptions{Limit: 3}
|
opts := spotify.RecentlyPlayedOptions{Limit: 3}
|
||||||
|
|
||||||
if store.RecentSongsFresh() {
|
if store.RecentSongsFresh() {
|
||||||
ctx.JSON(200, *store.RecentSongs)
|
ctx.JSON(200, *store.RecentSongs)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
played, err := store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts)
|
played, err := store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(500, gin.H{"error": err.Error()})
|
log.Println(err)
|
||||||
|
ctx.JSON(500, gin.H{"error": "failed to fetch recently played"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"adam-french.co.uk/backend/models"
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
|
||||||
)
|
|
||||||
|
|
||||||
type UserCredentials struct {
|
|
||||||
Username string `json:"username" binding:"required"`
|
|
||||||
Password string `json:"password" binding:"required"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *Store) CreateUser(ctx *gin.Context) {
|
|
||||||
var input UserCredentials
|
|
||||||
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
|
|
||||||
ctx.JSON(http.StatusBadRequest, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
user := models.User{Username: input.Username, Password: hashedPassword}
|
|
||||||
tx := store.DB.Create(&user)
|
|
||||||
if tx.Error != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate JWT token
|
|
||||||
tokens, err := store.Auth.GenerateJWT(&user)
|
|
||||||
if err != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.SetCookie(
|
|
||||||
"access_token",
|
|
||||||
tokens.AccessToken,
|
|
||||||
int(store.Auth.Config.AccessTokenLifetime.Seconds()),
|
|
||||||
store.Auth.Config.Endpoint,
|
|
||||||
store.Auth.Config.Domain,
|
|
||||||
true, true,
|
|
||||||
)
|
|
||||||
ctx.SetCookie(
|
|
||||||
"refresh_token",
|
|
||||||
tokens.RefreshToken,
|
|
||||||
int(store.Auth.Config.RefreshTokenLifetime.Seconds()),
|
|
||||||
store.Auth.Config.Endpoint,
|
|
||||||
store.Auth.Config.Domain,
|
|
||||||
true, true,
|
|
||||||
)
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *Store) GetUser(ctx *gin.Context) {
|
|
||||||
userID := ctx.Param("id")
|
|
||||||
var user models.User
|
|
||||||
if err := store.DB.First(&user, userID).Error; err != nil {
|
|
||||||
ctx.JSON(http.StatusNotFound, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, user)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *Store) GetUsers(ctx *gin.Context) {
|
|
||||||
var users []models.User
|
|
||||||
if err := store.DB.Find(&users).Error; err != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.JSON(http.StatusOK, users)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *Store) UpdateUser(ctx *gin.Context) {
|
|
||||||
claimsVal, ok := ctx.Get("userClaims")
|
|
||||||
if !ok {
|
|
||||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, ok := claimsVal.(*jwt.MapClaims)
|
|
||||||
if !ok {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userIDF, ok := (*claims)["id"].(float64)
|
|
||||||
if !ok {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID := uint(userIDF)
|
|
||||||
|
|
||||||
var user models.User
|
|
||||||
if err := store.DB.First(&user, userID).Error; err != nil {
|
|
||||||
ctx.JSON(http.StatusNotFound, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "will be implemented"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (store *Store) DeleteUser(ctx *gin.Context) {
|
|
||||||
claimsVal, ok := ctx.Get("userClaims")
|
|
||||||
if !ok {
|
|
||||||
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
claims, ok := claimsVal.(*jwt.MapClaims)
|
|
||||||
if !ok {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
userIDF, ok := (*claims)["id"].(float64)
|
|
||||||
if !ok {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
userID := uint(userIDF)
|
|
||||||
|
|
||||||
var user models.User
|
|
||||||
if err := store.DB.First(&user, userID).Error; err != nil {
|
|
||||||
ctx.JSON(http.StatusNotFound, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tx := store.DB.Delete(&user)
|
|
||||||
if tx.Error != nil {
|
|
||||||
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.SetCookie(
|
|
||||||
"access_token",
|
|
||||||
"",
|
|
||||||
-1,
|
|
||||||
store.Auth.Config.Endpoint,
|
|
||||||
store.Auth.Config.Domain,
|
|
||||||
true, true,
|
|
||||||
)
|
|
||||||
ctx.SetCookie(
|
|
||||||
"refresh_token",
|
|
||||||
"",
|
|
||||||
-1,
|
|
||||||
store.Auth.Config.Endpoint,
|
|
||||||
store.Auth.Config.Domain,
|
|
||||||
true, true,
|
|
||||||
)
|
|
||||||
|
|
||||||
ctx.JSON(http.StatusOK, user)
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
func (store *Store) ConnectWebSocket(ctx *gin.Context) {
|
func (store *Store) ConnectWebSocket(ctx *gin.Context) {
|
||||||
conn, err := services.Upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
|
conn, err := services.Upgrader.Upgrade(ctx.Writer, ctx.Request, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.JSON(500, gin.H{"error": err.Error()})
|
// Upgrader already wrote the HTTP error response, so just return
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,34 @@ type Store struct {
|
|||||||
ClaudeClient *anthropic.Client
|
ClaudeClient *anthropic.Client
|
||||||
Auth *services.Auth
|
Auth *services.Auth
|
||||||
Notes *services.Notes
|
Notes *services.Notes
|
||||||
|
LoginLimiter *services.RateLimiter
|
||||||
|
EmailSync *services.EmailSyncService
|
||||||
|
|
||||||
RecentSongs *[]spotify.RecentlyPlayedItem
|
RecentSongs *[]spotify.RecentlyPlayedItem
|
||||||
RecentSongsFetchedAt time.Time
|
RecentSongsFetchedAt time.Time
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
125
backend/main.go
125
backend/main.go
@@ -1,27 +1,38 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/99designs/gqlgen/graphql/handler"
|
||||||
|
"github.com/99designs/gqlgen/graphql/handler/extension"
|
||||||
|
"github.com/99designs/gqlgen/graphql/handler/lru"
|
||||||
|
"github.com/99designs/gqlgen/graphql/handler/transport"
|
||||||
|
"github.com/99designs/gqlgen/graphql/playground"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/vektah/gqlparser/v2/ast"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/graph"
|
||||||
"adam-french.co.uk/backend/handlers"
|
"adam-french.co.uk/backend/handlers"
|
||||||
"adam-french.co.uk/backend/services"
|
"adam-french.co.uk/backend/services"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
logsDir := "/backend/logs"
|
logsDir := "/backend/logs"
|
||||||
logFile, err := os.OpenFile(logsDir+"/go.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
logFile, err := os.OpenFile(logsDir+"/go.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
gin.DefaultWriter = io.MultiWriter(os.Stdout, logFile)
|
gin.DefaultWriter = io.MultiWriter(os.Stdout, logFile)
|
||||||
|
|
||||||
|
if os.Getenv("DEV_MODE") != "true" {
|
||||||
|
gin.SetMode(gin.ReleaseMode)
|
||||||
|
}
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
|
|
||||||
err = r.SetTrustedProxies([]string{"172.28.0.0/16"})
|
err = r.SetTrustedProxies([]string{"172.28.0.0/16"})
|
||||||
@@ -39,6 +50,11 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if os.Getenv("SEED_DB") == "true" {
|
||||||
|
services.SeedDatabase(db)
|
||||||
|
}
|
||||||
|
domainName := os.Getenv("DOMAIN")
|
||||||
|
services.InitWebSocket(db, domainName)
|
||||||
|
|
||||||
// SPOTIFY
|
// SPOTIFY
|
||||||
spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE")
|
spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE")
|
||||||
@@ -54,9 +70,8 @@ func main() {
|
|||||||
claudeClient := services.InitClaude(&claudeConfig)
|
claudeClient := services.InitClaude(&claudeConfig)
|
||||||
|
|
||||||
authSecret := os.Getenv("BACKEND_SECRET")
|
authSecret := os.Getenv("BACKEND_SECRET")
|
||||||
domainName := os.Getenv("DOMAIN")
|
|
||||||
backendEndpoint := os.Getenv("BACKEND_ENDPOINT")
|
backendEndpoint := os.Getenv("BACKEND_ENDPOINT")
|
||||||
accessTokenLifetime := 24 * time.Hour
|
accessTokenLifetime := 7 * 24 * time.Hour
|
||||||
refreshTokenLifetime := 365 * 24 * time.Hour
|
refreshTokenLifetime := 365 * 24 * time.Hour
|
||||||
authConfig := services.AuthConfig{Secret: []byte(authSecret), Domain: domainName, RefreshTokenLifetime: refreshTokenLifetime, AccessTokenLifetime: accessTokenLifetime, Endpoint: backendEndpoint}
|
authConfig := services.AuthConfig{Secret: []byte(authSecret), Domain: domainName, RefreshTokenLifetime: refreshTokenLifetime, AccessTokenLifetime: accessTokenLifetime, Endpoint: backendEndpoint}
|
||||||
auth := services.InitAuth(&authConfig)
|
auth := services.InitAuth(&authConfig)
|
||||||
@@ -65,41 +80,57 @@ func main() {
|
|||||||
notesConfig := services.NotesConfig{Dir: notesDir}
|
notesConfig := services.NotesConfig{Dir: notesDir}
|
||||||
notes := services.InitNotes(¬esConfig)
|
notes := services.InitNotes(¬esConfig)
|
||||||
|
|
||||||
store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes}
|
giteaHost := os.Getenv("GITEA_HOST")
|
||||||
|
giteaPort := os.Getenv("GITEA_PORT")
|
||||||
|
|
||||||
|
steamAPIKey := os.Getenv("STEAM_API_KEY")
|
||||||
|
steamID := os.Getenv("STEAM_ID")
|
||||||
|
|
||||||
|
// EMAIL SYNC
|
||||||
|
emailSyncInterval := 30 * time.Minute
|
||||||
|
if interval := os.Getenv("EMAIL_SYNC_INTERVAL"); interval != "" {
|
||||||
|
if parsed, err := time.ParseDuration(interval); err == nil {
|
||||||
|
emailSyncInterval = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emailSyncConfig := services.EmailSyncConfig{
|
||||||
|
Backend: os.Getenv("EMAIL_BACKEND"),
|
||||||
|
ClientID: os.Getenv("MSGRAPH_CLIENT_ID"),
|
||||||
|
ClientSecret: os.Getenv("MSGRAPH_CLIENT_SECRET"),
|
||||||
|
TenantID: os.Getenv("MSGRAPH_TENANT_ID"),
|
||||||
|
RedirectURI: os.Getenv("MSGRAPH_REDIRECT_URI"),
|
||||||
|
IMAP: &services.IMAPConfig{
|
||||||
|
Host: os.Getenv("IMAP_HOST"),
|
||||||
|
Port: os.Getenv("IMAP_PORT"),
|
||||||
|
Email: os.Getenv("IMAP_EMAIL"),
|
||||||
|
Password: os.Getenv("IMAP_PASSWORD"),
|
||||||
|
},
|
||||||
|
SyncInterval: emailSyncInterval,
|
||||||
|
Enabled: os.Getenv("EMAIL_SYNC_ENABLED") == "true",
|
||||||
|
}
|
||||||
|
emailSync := services.InitEmailSync(&emailSyncConfig, db, claudeClient)
|
||||||
|
|
||||||
|
loginLimiter := services.NewRateLimiter(5, time.Minute)
|
||||||
|
|
||||||
|
store := handlers.Store{DB: db, SpotifyAuth: spotifyAuth, SpotifyClient: spotifyClient, ClaudeClient: claudeClient, Auth: auth, Notes: notes, LoginLimiter: loginLimiter, EmailSync: emailSync, GiteaHost: giteaHost, GiteaPort: giteaPort, SteamAPIKey: steamAPIKey, SteamID: steamID}
|
||||||
|
|
||||||
protected := r.Group("/", store.AuthMiddlewear)
|
protected := r.Group("/", store.AuthMiddlewear)
|
||||||
|
admin := r.Group("/", store.AuthMiddlewear, store.AdminMiddleware)
|
||||||
// FAVORITES
|
|
||||||
r.GET("/favorites", store.GetFavorites)
|
|
||||||
protected.POST("/favorites", store.CreateFavorite)
|
|
||||||
|
|
||||||
// ROWING
|
// ROWING
|
||||||
r.GET("/rowing", store.GetRowing)
|
r.GET("/rowing", store.GetRowing)
|
||||||
protected.POST("/rowing", store.CreateRowing)
|
admin.POST("/rowing", store.CreateRowing)
|
||||||
|
|
||||||
// ACTIVITIES
|
|
||||||
r.GET("/activity", store.GetActivity)
|
|
||||||
protected.POST("/activity", store.CreateActivity)
|
|
||||||
|
|
||||||
// POSTS
|
|
||||||
r.GET("/posts", store.GetPosts)
|
|
||||||
protected.POST("/posts", store.CreatePost)
|
|
||||||
r.GET("/posts/:id", store.GetPost)
|
|
||||||
protected.PUT("/posts/:id", store.UpdatePost)
|
|
||||||
protected.DELETE("/posts/:id", store.DeletePost)
|
|
||||||
|
|
||||||
// USERS
|
|
||||||
r.GET("/user/:id", store.GetUser)
|
|
||||||
protected.PUT("/user/:id", store.UpdateUser)
|
|
||||||
protected.DELETE("/user/:id", store.DeleteUser)
|
|
||||||
r.GET("/user", store.GetUsers)
|
|
||||||
r.POST("/user", store.CreateUser)
|
|
||||||
|
|
||||||
// AUTH
|
// AUTH
|
||||||
r.POST("/auth/login", store.Login)
|
r.POST("/auth/login", store.Login)
|
||||||
r.POST("/auth/refresh", store.RefreshToken)
|
r.POST("/auth/refresh", store.RefreshToken)
|
||||||
r.GET("/auth/check", store.CheckToken)
|
r.GET("/auth/check", store.CheckToken)
|
||||||
r.POST("/auth/logout", store.Logout)
|
r.POST("/auth/logout", store.Logout)
|
||||||
|
r.GET("/auth/validate-admin", store.ValidateAdmin)
|
||||||
|
|
||||||
|
// EMAIL SYNC
|
||||||
|
r.GET("/email/callback", store.CompleteEmailAuth)
|
||||||
|
admin.POST("/email/sync", store.TriggerEmailSync)
|
||||||
|
|
||||||
// SPOTIFY
|
// SPOTIFY
|
||||||
r.GET("/spotify/callback", store.CompleteSpotifyAuth)
|
r.GET("/spotify/callback", store.CompleteSpotifyAuth)
|
||||||
@@ -107,17 +138,53 @@ func main() {
|
|||||||
r.GET("/spotify/recent", store.RecentlyPlayed)
|
r.GET("/spotify/recent", store.RecentlyPlayed)
|
||||||
// r.POST("/spotify", store.SendSong)
|
// r.POST("/spotify", store.SendSong)
|
||||||
|
|
||||||
|
// RADIO
|
||||||
|
admin.POST("/radio/upload", store.UploadRadioSong)
|
||||||
|
admin.GET("/radio/songs", store.ListRadioSongs)
|
||||||
|
admin.DELETE("/radio/songs/:filename", store.DeleteRadioSong)
|
||||||
|
admin.PATCH("/radio/songs/:filename/disable", store.DisableRadioSong)
|
||||||
|
admin.PATCH("/radio/songs/:filename/enable", store.EnableRadioSong)
|
||||||
|
|
||||||
// MESSAGES
|
// MESSAGES
|
||||||
r.GET("/ws", store.ConnectWebSocket)
|
r.GET("/ws", store.ConnectWebSocket)
|
||||||
|
protected.POST("/messages/upload", store.UploadMessageFile)
|
||||||
|
|
||||||
// NOTES
|
// NOTES
|
||||||
r.GET("/notes/*path", store.GetNoteFile)
|
admin.GET("/notes/*path", store.GetNoteFile)
|
||||||
|
|
||||||
|
// GRAPHQL
|
||||||
|
gqlSrv := handler.New(graph.NewExecutableSchema(graph.Config{
|
||||||
|
Resolvers: &graph.Resolver{Store: &store},
|
||||||
|
}))
|
||||||
|
gqlSrv.AddTransport(transport.Websocket{KeepAlivePingInterval: 10 * time.Second})
|
||||||
|
gqlSrv.AddTransport(transport.Options{})
|
||||||
|
gqlSrv.AddTransport(transport.POST{})
|
||||||
|
gqlSrv.AddTransport(transport.MultipartForm{})
|
||||||
|
gqlSrv.SetQueryCache(lru.New[*ast.QueryDocument](1000))
|
||||||
|
gqlSrv.Use(extension.FixedComplexityLimit(200))
|
||||||
|
devMode := os.Getenv("DEV_MODE") == "true"
|
||||||
|
if devMode && os.Getenv("GQL_INTROSPECTION") == "true" {
|
||||||
|
gqlSrv.Use(extension.Introspection{})
|
||||||
|
}
|
||||||
|
r.POST("/graphql", graph.AuthContextMiddleware(auth), func(c *gin.Context) {
|
||||||
|
gqlSrv.ServeHTTP(c.Writer, c.Request)
|
||||||
|
})
|
||||||
|
if devMode && os.Getenv("GQL_PLAYGROUND") == "true" {
|
||||||
|
r.GET("/graphql", func(c *gin.Context) {
|
||||||
|
playground.Handler("GraphQL Playground", "/graphql").ServeHTTP(c.Writer, c.Request)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// HELLO WORLD
|
// HELLO WORLD
|
||||||
r.GET("/", func(c *gin.Context) {
|
r.GET("/", func(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{"message": "Hello World"})
|
c.JSON(200, gin.H{"message": "Hello World"})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Launch email sync scheduler
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
go store.EmailSync.StartScheduler(ctx)
|
||||||
|
|
||||||
port := os.Getenv("BACKEND_PORT")
|
port := os.Getenv("BACKEND_PORT")
|
||||||
r.Run(fmt.Sprintf(":%s", port))
|
r.Run(fmt.Sprintf(":%s", port))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ type Post struct {
|
|||||||
type Message struct {
|
type Message struct {
|
||||||
ID uint `gorm:"primarykey" json:"id"`
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
Content string `json:"text"`
|
Content string `json:"text"`
|
||||||
AuthorID uint `json:"-"`
|
AuthorID uint `json:"authorId"`
|
||||||
Author *User `gorm:"foreignKey:AuthorID" json:"author"`
|
FileURL string `json:"fileUrl,omitempty"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||||
}
|
}
|
||||||
@@ -61,8 +61,52 @@ type Rowing struct {
|
|||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||||
Date time.Time `json:"date"`
|
Date time.Time `json:"date"`
|
||||||
Time time.Duration `json:"time"`
|
Time uint64 `json:"time"`
|
||||||
TimePer500m time.Duration `json:"timePer500m"`
|
Distance uint64 `json:"distance"`
|
||||||
Distance float64 `json:"distance"`
|
TimePer500m float64 `json:"timePer500m"`
|
||||||
Calories float64 `json:"calories"`
|
Calories float64 `json:"calories"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Bookmark struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||||
|
Category string `gorm:"not null" json:"category"`
|
||||||
|
Name string `gorm:"not null" json:"name"`
|
||||||
|
Link string `gorm:"not null" json:"link"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobAppReference struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||||
|
Category string `gorm:"not null" json:"category"`
|
||||||
|
Label string `gorm:"not null" json:"label"`
|
||||||
|
Value string `gorm:"not null" json:"value"`
|
||||||
|
SortOrder int `gorm:"default:0" json:"sortOrder"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProcessedEmail struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
GraphMessageID string `gorm:"uniqueIndex;not null" json:"graphMessageId"`
|
||||||
|
Subject string `gorm:"not null" json:"subject"`
|
||||||
|
Action string `gorm:"not null" json:"action"`
|
||||||
|
JobAppID *uint `json:"jobAppId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type JobApplication struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||||
|
JobTitle string `gorm:"not null" json:"jobTitle"`
|
||||||
|
Company string `gorm:"not null" json:"company"`
|
||||||
|
Location *string `json:"location"`
|
||||||
|
URL *string `json:"url"`
|
||||||
|
Status string `gorm:"not null" json:"status"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
AppliedAt *time.Time `json:"appliedAt"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ func migrateDatabase(db *gorm.DB) error {
|
|||||||
&models.Activity{},
|
&models.Activity{},
|
||||||
&models.Favorite{},
|
&models.Favorite{},
|
||||||
&models.Rowing{},
|
&models.Rowing{},
|
||||||
|
&models.Message{},
|
||||||
|
&models.JobApplication{},
|
||||||
|
&models.JobAppReference{},
|
||||||
|
&models.Bookmark{},
|
||||||
|
&models.ProcessedEmail{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
337
backend/services/email_imap.go
Normal file
337
backend/services/email_imap.go
Normal file
@@ -0,0 +1,337 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
|
"net"
|
||||||
|
"net/mail"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IMAPConfig struct {
|
||||||
|
Host string
|
||||||
|
Port string
|
||||||
|
Email string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// imapClient is a minimal, lenient IMAP client that tolerates
|
||||||
|
// Outlook's non-standard response formatting.
|
||||||
|
type imapClient struct {
|
||||||
|
conn net.Conn
|
||||||
|
reader *bufio.Reader
|
||||||
|
tag atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func imapDial(addr string) (*imapClient, error) {
|
||||||
|
conn, err := tls.Dial("tcp", addr, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c := &imapClient{
|
||||||
|
conn: conn,
|
||||||
|
reader: bufio.NewReader(conn),
|
||||||
|
}
|
||||||
|
// Read server greeting
|
||||||
|
if _, err := c.readLine(); err != nil {
|
||||||
|
conn.Close()
|
||||||
|
return nil, fmt.Errorf("reading greeting: %w", err)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *imapClient) Close() error {
|
||||||
|
return c.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *imapClient) nextTag() string {
|
||||||
|
return fmt.Sprintf("A%04d", c.tag.Add(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *imapClient) readLine() (string, error) {
|
||||||
|
line, err := c.reader.ReadString('\n')
|
||||||
|
return strings.TrimRight(line, "\r\n"), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendCommand sends a tagged command and reads lines until the tagged response.
|
||||||
|
// Returns all untagged response lines and the final tagged status line.
|
||||||
|
func (c *imapClient) sendCommand(cmd string) (untagged []string, status string, err error) {
|
||||||
|
tag := c.nextTag()
|
||||||
|
_, err = fmt.Fprintf(c.conn, "%s %s\r\n", tag, cmd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
line, err := c.readLine()
|
||||||
|
if err != nil {
|
||||||
|
return untagged, "", err
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, tag+" ") {
|
||||||
|
return untagged, line, nil
|
||||||
|
}
|
||||||
|
untagged = append(untagged, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendCommandOK sends a command and returns an error if the response is not OK.
|
||||||
|
func (c *imapClient) sendCommandOK(cmd string) ([]string, error) {
|
||||||
|
untagged, status, err := c.sendCommand(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return untagged, err
|
||||||
|
}
|
||||||
|
// Status line is like: A0001 OK ... or A0001 NO ...
|
||||||
|
parts := strings.SplitN(status, " ", 3)
|
||||||
|
if len(parts) < 2 || parts[1] != "OK" {
|
||||||
|
return untagged, fmt.Errorf("command %q failed: %s", cmd, status)
|
||||||
|
}
|
||||||
|
return untagged, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchLiteral reads an IMAP literal {N}\r\n followed by N bytes.
|
||||||
|
func (c *imapClient) readLiteral(size int) (string, error) {
|
||||||
|
buf := make([]byte, size)
|
||||||
|
_, err := io.ReadFull(c.reader, buf)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendFetch sends a FETCH command and collects the full response including literals.
|
||||||
|
// Returns raw response lines (with literals inlined after their header lines).
|
||||||
|
func (c *imapClient) sendFetch(cmd string) ([]string, error) {
|
||||||
|
tag := c.nextTag()
|
||||||
|
_, err := fmt.Fprintf(c.conn, "%s %s\r\n", tag, cmd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
for {
|
||||||
|
line, err := c.readLine()
|
||||||
|
if err != nil {
|
||||||
|
return lines, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for literal marker {N} at end of line
|
||||||
|
if idx := strings.LastIndex(line, "{"); idx >= 0 && strings.HasSuffix(line, "}") {
|
||||||
|
sizeStr := line[idx+1 : len(line)-1]
|
||||||
|
var size int
|
||||||
|
if _, err := fmt.Sscanf(sizeStr, "%d", &size); err == nil && size > 0 {
|
||||||
|
literal, err := c.readLiteral(size)
|
||||||
|
if err != nil {
|
||||||
|
return lines, fmt.Errorf("reading literal of %d bytes: %w", size, err)
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
lines = append(lines, literal)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(line, tag+" ") {
|
||||||
|
// Check for OK
|
||||||
|
parts := strings.SplitN(line, " ", 3)
|
||||||
|
if len(parts) >= 2 && parts[1] != "OK" {
|
||||||
|
return lines, fmt.Errorf("FETCH failed: %s", line)
|
||||||
|
}
|
||||||
|
return lines, nil
|
||||||
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchEmailsIMAP connects to an IMAP server and retrieves emails since the given time.
|
||||||
|
func (s *EmailSyncService) fetchEmailsIMAP(since time.Time) ([]graphMessage, error) {
|
||||||
|
addr := fmt.Sprintf("%s:%s", s.Config.IMAP.Host, s.Config.IMAP.Port)
|
||||||
|
|
||||||
|
c, err := imapDial(addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("IMAP connect: %w", err)
|
||||||
|
}
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
// Quote the password to handle special characters
|
||||||
|
quotedPass := fmt.Sprintf("%q", s.Config.IMAP.Password)
|
||||||
|
if _, err := c.sendCommandOK(fmt.Sprintf("LOGIN %s %s", s.Config.IMAP.Email, quotedPass)); err != nil {
|
||||||
|
return nil, fmt.Errorf("IMAP login: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := c.sendCommandOK("SELECT INBOX"); err != nil {
|
||||||
|
return nil, fmt.Errorf("IMAP select INBOX: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SEARCH SINCE uses date only (no time), per IMAP spec
|
||||||
|
dateStr := since.UTC().Format("02-Jan-2006")
|
||||||
|
untagged, err := c.sendCommandOK(fmt.Sprintf("SEARCH SINCE %s", dateStr))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("IMAP search: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse sequence numbers from "* SEARCH 1 2 3 ..."
|
||||||
|
var seqNums []string
|
||||||
|
for _, line := range untagged {
|
||||||
|
if strings.HasPrefix(line, "* SEARCH") {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
if len(parts) > 2 {
|
||||||
|
seqNums = append(seqNums, parts[2:]...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(seqNums) == 0 {
|
||||||
|
log.Printf("[EmailSync/IMAP] No messages found since %s", dateStr)
|
||||||
|
c.sendCommand("LOGOUT")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[EmailSync/IMAP] Found %d messages since %s", len(seqNums), dateStr)
|
||||||
|
|
||||||
|
seqSet := strings.Join(seqNums, ",")
|
||||||
|
fetchLines, err := c.sendFetch(fmt.Sprintf("FETCH %s (BODY[])", seqSet))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("IMAP fetch: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse fetched messages - literals contain the raw RFC822 message
|
||||||
|
var messages []graphMessage
|
||||||
|
for i := 0; i < len(fetchLines); i++ {
|
||||||
|
line := fetchLines[i]
|
||||||
|
// Look for a literal following this line
|
||||||
|
if strings.Contains(line, "BODY[]") && strings.HasSuffix(line, "}") {
|
||||||
|
if i+1 < len(fetchLines) {
|
||||||
|
raw := fetchLines[i+1]
|
||||||
|
i++ // skip the literal
|
||||||
|
gm, err := parseRawEmail(raw)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[EmailSync/IMAP] Error parsing email: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
messages = append(messages, gm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.sendCommand("LOGOUT")
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRawEmail parses an RFC822 email into a graphMessage.
|
||||||
|
func parseRawEmail(raw string) (graphMessage, error) {
|
||||||
|
msg, err := mail.ReadMessage(strings.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return graphMessage{}, fmt.Errorf("parsing message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
header := msg.Header
|
||||||
|
|
||||||
|
// Parse From
|
||||||
|
var fromName, fromAddr string
|
||||||
|
if fromList, err := header.AddressList("From"); err == nil && len(fromList) > 0 {
|
||||||
|
fromName = fromList[0].Name
|
||||||
|
fromAddr = fromList[0].Address
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse Date
|
||||||
|
dateStr := header.Get("Date")
|
||||||
|
parsedDate, err := mail.ParseDate(dateStr)
|
||||||
|
if err != nil {
|
||||||
|
parsedDate = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract body text
|
||||||
|
bodyContent := extractTextBody(msg.Header, msg.Body)
|
||||||
|
|
||||||
|
return graphMessage{
|
||||||
|
ID: header.Get("Message-ID"),
|
||||||
|
Subject: decodeHeader(header.Get("Subject")),
|
||||||
|
ReceivedDateTime: parsedDate.Format(time.RFC3339),
|
||||||
|
From: graphFrom{
|
||||||
|
EmailAddress: graphEmailAddress{
|
||||||
|
Name: fromName,
|
||||||
|
Address: fromAddr,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Body: graphBody{
|
||||||
|
ContentType: "text",
|
||||||
|
Content: bodyContent,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractTextBody pulls the text/plain or text/html content from a message body.
|
||||||
|
func extractTextBody(header mail.Header, body io.Reader) string {
|
||||||
|
contentType := header.Get("Content-Type")
|
||||||
|
if contentType == "" {
|
||||||
|
contentType = "text/plain"
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaType, params, err := mime.ParseMediaType(contentType)
|
||||||
|
if err != nil {
|
||||||
|
// Try reading as plain text
|
||||||
|
b, _ := io.ReadAll(body)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(mediaType, "multipart/") {
|
||||||
|
boundary := params["boundary"]
|
||||||
|
if boundary == "" {
|
||||||
|
b, _ := io.ReadAll(body)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
mr := multipart.NewReader(body, boundary)
|
||||||
|
var textContent, htmlContent string
|
||||||
|
for {
|
||||||
|
part, err := mr.NextPart()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
partType := part.Header.Get("Content-Type")
|
||||||
|
partMedia, _, _ := mime.ParseMediaType(partType)
|
||||||
|
b, err := io.ReadAll(part)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch partMedia {
|
||||||
|
case "text/plain":
|
||||||
|
textContent = string(b)
|
||||||
|
case "text/html":
|
||||||
|
htmlContent = string(b)
|
||||||
|
case "multipart/alternative", "multipart/related", "multipart/mixed":
|
||||||
|
// Recursively handle nested multipart
|
||||||
|
nested := extractTextBody(
|
||||||
|
mail.Header{"Content-Type": {partType}},
|
||||||
|
strings.NewReader(string(b)),
|
||||||
|
)
|
||||||
|
if nested != "" && textContent == "" {
|
||||||
|
textContent = nested
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if textContent != "" {
|
||||||
|
return textContent
|
||||||
|
}
|
||||||
|
return htmlContent
|
||||||
|
}
|
||||||
|
|
||||||
|
b, _ := io.ReadAll(body)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeHeader decodes RFC 2047 encoded header values.
|
||||||
|
func decodeHeader(s string) string {
|
||||||
|
dec := new(mime.WordDecoder)
|
||||||
|
decoded, err := dec.DecodeHeader(s)
|
||||||
|
if err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return decoded
|
||||||
|
}
|
||||||
680
backend/services/email_sync.go
Normal file
680
backend/services/email_sync.go
Normal file
@@ -0,0 +1,680 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
"github.com/anthropics/anthropic-sdk-go"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const MSGRAPH_TOKEN_JSON_PATH = "/backend/token/msgraph_token.json"
|
||||||
|
|
||||||
|
type EmailSyncConfig struct {
|
||||||
|
Backend string // "graph" or "imap"
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
TenantID string
|
||||||
|
RedirectURI string
|
||||||
|
IMAP *IMAPConfig
|
||||||
|
SyncInterval time.Duration
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmailSyncService struct {
|
||||||
|
Config *EmailSyncConfig
|
||||||
|
OAuthConfig *oauth2.Config
|
||||||
|
HTTPClient *http.Client
|
||||||
|
DB *gorm.DB
|
||||||
|
ClaudeClient *anthropic.Client
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// Microsoft Graph API response types
|
||||||
|
|
||||||
|
type graphMessagesResponse struct {
|
||||||
|
Value []graphMessage `json:"value"`
|
||||||
|
NextLink string `json:"@odata.nextLink"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type graphMessage struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
ReceivedDateTime string `json:"receivedDateTime"`
|
||||||
|
From graphFrom `json:"from"`
|
||||||
|
Body graphBody `json:"body"`
|
||||||
|
BodyPreview string `json:"bodyPreview"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type graphFrom struct {
|
||||||
|
EmailAddress graphEmailAddress `json:"emailAddress"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type graphEmailAddress struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type graphBody struct {
|
||||||
|
ContentType string `json:"contentType"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Claude response type
|
||||||
|
|
||||||
|
type EmailAnalysis struct {
|
||||||
|
IsJobEmail bool `json:"isJobEmail"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Company string `json:"company"`
|
||||||
|
JobTitle string `json:"jobTitle"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Location *string `json:"location"`
|
||||||
|
URL *string `json:"url"`
|
||||||
|
AppliedAt *string `json:"appliedAt"`
|
||||||
|
Notes *string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email filtering config
|
||||||
|
|
||||||
|
var subjectKeywords = []string{
|
||||||
|
"application", "interview", "offer", "rejected", "assessment",
|
||||||
|
"applied", "candidate", "position", "role", "hiring",
|
||||||
|
"thank you for applying", "we regret", "move forward",
|
||||||
|
"next steps", "coding challenge", "take-home",
|
||||||
|
}
|
||||||
|
|
||||||
|
var senderDomains = []string{
|
||||||
|
"greenhouse.io", "lever.co", "workday.com", "myworkday.com",
|
||||||
|
"ashbyhq.com", "smartrecruiters.com", "icims.com",
|
||||||
|
"jobvite.com", "taleo.net", "breezy.hr", "recruitee.com",
|
||||||
|
"applytojob.com", "jazz.co", "dover.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status progression order
|
||||||
|
|
||||||
|
var statusOrder = map[string]int{
|
||||||
|
"applied": 0,
|
||||||
|
"screening": 1,
|
||||||
|
"assessment": 2,
|
||||||
|
"interviewing": 3,
|
||||||
|
"offer": 4,
|
||||||
|
"rejected": 5,
|
||||||
|
"withdrawn": 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Token persistence
|
||||||
|
|
||||||
|
func SaveMSGraphToken(path string, tok *oauth2.Token) error {
|
||||||
|
data := struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
Expiry time.Time `json:"expiry"`
|
||||||
|
}{
|
||||||
|
AccessToken: tok.AccessToken,
|
||||||
|
RefreshToken: tok.RefreshToken,
|
||||||
|
TokenType: tok.TokenType,
|
||||||
|
Expiry: tok.Expiry,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating token directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonBytes, err := json.MarshalIndent(data, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return os.WriteFile(path, jsonBytes, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadMSGraphToken(path string) (*oauth2.Token, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
Expiry time.Time `json:"expiry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(data, &saved); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &oauth2.Token{
|
||||||
|
AccessToken: saved.AccessToken,
|
||||||
|
RefreshToken: saved.RefreshToken,
|
||||||
|
TokenType: saved.TokenType,
|
||||||
|
Expiry: saved.Expiry,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// persistingTokenSource wraps an oauth2.TokenSource to save refreshed tokens to disk.
|
||||||
|
type persistingTokenSource struct {
|
||||||
|
base oauth2.TokenSource
|
||||||
|
path string
|
||||||
|
lastToken *oauth2.Token
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *persistingTokenSource) Token() (*oauth2.Token, error) {
|
||||||
|
tok, err := p.base.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Save only when the token actually changed (i.e. was refreshed)
|
||||||
|
if p.lastToken == nil || tok.AccessToken != p.lastToken.AccessToken {
|
||||||
|
p.lastToken = tok
|
||||||
|
if saveErr := SaveMSGraphToken(p.path, tok); saveErr != nil {
|
||||||
|
log.Printf("[EmailSync] Warning: failed to persist refreshed token: %v", saveErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tok, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitEmailSync(config *EmailSyncConfig, db *gorm.DB, claudeClient *anthropic.Client) *EmailSyncService {
|
||||||
|
svc := &EmailSyncService{
|
||||||
|
Config: config,
|
||||||
|
DB: db,
|
||||||
|
ClaudeClient: claudeClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch config.Backend {
|
||||||
|
case "imap":
|
||||||
|
if config.IMAP == nil || config.IMAP.Email == "" {
|
||||||
|
log.Println("[EmailSync] IMAP backend selected but not configured")
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
// IMAP connects per-sync, no persistent client needed.
|
||||||
|
// Mark as ready by setting a non-nil HTTPClient (used as readiness flag).
|
||||||
|
svc.HTTPClient = http.DefaultClient
|
||||||
|
log.Printf("[EmailSync] Configured with IMAP backend (%s)", config.IMAP.Host)
|
||||||
|
|
||||||
|
case "graph":
|
||||||
|
oauthConfig := &oauth2.Config{
|
||||||
|
ClientID: config.ClientID,
|
||||||
|
ClientSecret: config.ClientSecret,
|
||||||
|
Endpoint: oauth2.Endpoint{
|
||||||
|
AuthURL: fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/authorize", config.TenantID),
|
||||||
|
TokenURL: fmt.Sprintf("https://login.microsoftonline.com/%s/oauth2/v2.0/token", config.TenantID),
|
||||||
|
},
|
||||||
|
RedirectURL: config.RedirectURI,
|
||||||
|
Scopes: []string{"https://graph.microsoft.com/Mail.Read", "offline_access"},
|
||||||
|
}
|
||||||
|
svc.OAuthConfig = oauthConfig
|
||||||
|
|
||||||
|
token, err := LoadMSGraphToken(MSGRAPH_TOKEN_JSON_PATH)
|
||||||
|
if err != nil || token == nil {
|
||||||
|
authURL := oauthConfig.AuthCodeURL("state", oauth2.AccessTypeOffline)
|
||||||
|
log.Println("[EmailSync] No token saved. Authenticate Microsoft Graph with:")
|
||||||
|
log.Println(authURL)
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
baseSource := oauthConfig.TokenSource(context.Background(), token)
|
||||||
|
persistSource := &persistingTokenSource{base: baseSource, path: MSGRAPH_TOKEN_JSON_PATH, lastToken: token}
|
||||||
|
svc.HTTPClient = oauth2.NewClient(context.Background(), persistSource)
|
||||||
|
log.Println("[EmailSync] Authenticated with Microsoft Graph")
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.Printf("[EmailSync] Unknown backend %q, defaulting to disabled", config.Backend)
|
||||||
|
}
|
||||||
|
|
||||||
|
return svc
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthURL returns the OAuth2 authorization URL for initial setup.
|
||||||
|
func (s *EmailSyncService) AuthURL() string {
|
||||||
|
return s.OAuthConfig.AuthCodeURL("state", oauth2.AccessTypeOffline)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteAuth exchanges an authorization code for a token and initializes the HTTP client.
|
||||||
|
func (s *EmailSyncService) CompleteAuth(ctx context.Context, code string) error {
|
||||||
|
token, err := s.OAuthConfig.Exchange(ctx, code)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("token exchange failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SaveMSGraphToken(MSGRAPH_TOKEN_JSON_PATH, token); err != nil {
|
||||||
|
return fmt.Errorf("saving token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseSource := s.OAuthConfig.TokenSource(ctx, token)
|
||||||
|
persistSource := &persistingTokenSource{base: baseSource, path: MSGRAPH_TOKEN_JSON_PATH, lastToken: token}
|
||||||
|
s.HTTPClient = oauth2.NewClient(ctx, persistSource)
|
||||||
|
|
||||||
|
log.Println("[EmailSync] Authentication completed successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartScheduler runs SyncEmails on a recurring interval.
|
||||||
|
func (s *EmailSyncService) StartScheduler(ctx context.Context) {
|
||||||
|
if !s.Config.Enabled {
|
||||||
|
log.Println("[EmailSync] Disabled, skipping scheduler")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.HTTPClient == nil {
|
||||||
|
log.Println("[EmailSync] Not authenticated, skipping scheduler")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[EmailSync] Starting scheduler, interval: %s", s.Config.SyncInterval)
|
||||||
|
|
||||||
|
// Run once immediately on startup
|
||||||
|
if err := s.SyncEmails(ctx); err != nil {
|
||||||
|
log.Printf("[EmailSync] Initial sync error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(s.Config.SyncInterval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
log.Println("[EmailSync] Scheduler stopped")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if err := s.SyncEmails(ctx); err != nil {
|
||||||
|
log.Printf("[EmailSync] Sync error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncEmails is the main pipeline: fetch, filter, dedup, classify, create/update.
|
||||||
|
func (s *EmailSyncService) SyncEmails(ctx context.Context) error {
|
||||||
|
if !s.mu.TryLock() {
|
||||||
|
return fmt.Errorf("sync already in progress")
|
||||||
|
}
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
log.Printf("[EmailSync] PANIC recovered: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Println("[EmailSync] Starting sync...")
|
||||||
|
|
||||||
|
// Determine the time window: use the most recent ProcessedEmail timestamp, or default to 24h ago
|
||||||
|
since := time.Now().Add(-24 * time.Hour)
|
||||||
|
var latest models.ProcessedEmail
|
||||||
|
if err := s.DB.Order("created_at DESC").First(&latest).Error; err == nil {
|
||||||
|
since = latest.CreatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch emails from Microsoft Graph
|
||||||
|
emails, err := s.fetchEmails(ctx, since)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetching emails: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[EmailSync] Fetched %d emails since %s", len(emails), since.Format(time.RFC3339))
|
||||||
|
|
||||||
|
// Filter by subject/sender patterns
|
||||||
|
filtered := s.filterEmails(emails)
|
||||||
|
log.Printf("[EmailSync] %d emails passed filters", len(filtered))
|
||||||
|
|
||||||
|
var created, updated, skipped, errored int
|
||||||
|
|
||||||
|
for _, email := range filtered {
|
||||||
|
// Dedup check
|
||||||
|
var existing models.ProcessedEmail
|
||||||
|
if err := s.DB.Where("graph_message_id = ?", email.ID).First(&existing).Error; err == nil {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process this email
|
||||||
|
action, jobAppID, processErr := s.processEmail(ctx, email)
|
||||||
|
if processErr != nil {
|
||||||
|
log.Printf("[EmailSync] Error processing email %q: %v", email.Subject, processErr)
|
||||||
|
s.DB.Create(&models.ProcessedEmail{
|
||||||
|
GraphMessageID: email.ID,
|
||||||
|
Subject: truncate(email.Subject, 255),
|
||||||
|
Action: "error",
|
||||||
|
})
|
||||||
|
errored++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record as processed
|
||||||
|
s.DB.Create(&models.ProcessedEmail{
|
||||||
|
GraphMessageID: email.ID,
|
||||||
|
Subject: truncate(email.Subject, 255),
|
||||||
|
Action: action,
|
||||||
|
JobAppID: jobAppID,
|
||||||
|
})
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "created":
|
||||||
|
created++
|
||||||
|
case "updated":
|
||||||
|
updated++
|
||||||
|
default:
|
||||||
|
skipped++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[EmailSync] Sync complete: %d fetched, %d filtered, %d created, %d updated, %d skipped, %d errors",
|
||||||
|
len(emails), len(filtered), created, updated, skipped, errored)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchEmails retrieves emails since the given time using the configured backend.
|
||||||
|
func (s *EmailSyncService) fetchEmails(ctx context.Context, since time.Time) ([]graphMessage, error) {
|
||||||
|
if s.Config.Backend == "imap" {
|
||||||
|
return s.fetchEmailsIMAP(since)
|
||||||
|
}
|
||||||
|
return s.fetchEmailsGraph(ctx, since)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchEmailsGraph retrieves emails from the Microsoft Graph API since the given time.
|
||||||
|
func (s *EmailSyncService) fetchEmailsGraph(ctx context.Context, since time.Time) ([]graphMessage, error) {
|
||||||
|
var all []graphMessage
|
||||||
|
|
||||||
|
sinceStr := since.UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
params := url.Values{
|
||||||
|
"$filter": {fmt.Sprintf("receivedDateTime ge %s", sinceStr)},
|
||||||
|
"$select": {"id,subject,from,receivedDateTime,body,bodyPreview"},
|
||||||
|
"$top": {"50"},
|
||||||
|
"$orderby": {"receivedDateTime asc"},
|
||||||
|
}
|
||||||
|
|
||||||
|
nextURL := fmt.Sprintf("https://graph.microsoft.com/v1.0/me/messages?%s", params.Encode())
|
||||||
|
|
||||||
|
for nextURL != "" {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", nextURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := s.HTTPClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("Graph API returned %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result graphMessagesResponse
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
all = append(all, result.Value...)
|
||||||
|
nextURL = result.NextLink
|
||||||
|
}
|
||||||
|
|
||||||
|
return all, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterEmails returns only emails that match subject keywords or sender domains.
|
||||||
|
func (s *EmailSyncService) filterEmails(emails []graphMessage) []graphMessage {
|
||||||
|
var matched []graphMessage
|
||||||
|
for _, email := range emails {
|
||||||
|
if s.matchesFilter(email) {
|
||||||
|
matched = append(matched, email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EmailSyncService) matchesFilter(email graphMessage) bool {
|
||||||
|
subjectLower := strings.ToLower(email.Subject)
|
||||||
|
for _, kw := range subjectKeywords {
|
||||||
|
if strings.Contains(subjectLower, kw) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
senderAddr := strings.ToLower(email.From.EmailAddress.Address)
|
||||||
|
for _, domain := range senderDomains {
|
||||||
|
if strings.HasSuffix(senderAddr, "@"+domain) || strings.HasSuffix(senderAddr, "."+domain) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// processEmail sends a single email to Claude and creates/updates a JobApplication.
|
||||||
|
// Returns the action taken ("created", "updated", "skipped") and the job application ID if applicable.
|
||||||
|
func (s *EmailSyncService) processEmail(ctx context.Context, email graphMessage) (string, *uint, error) {
|
||||||
|
cleaned := cleanEmailBody(email.Body.Content, email.Body.ContentType)
|
||||||
|
|
||||||
|
userMsg := fmt.Sprintf("Subject: %s\nFrom: %s <%s>\nDate: %s\n\n%s",
|
||||||
|
email.Subject,
|
||||||
|
email.From.EmailAddress.Name,
|
||||||
|
email.From.EmailAddress.Address,
|
||||||
|
email.ReceivedDateTime,
|
||||||
|
cleaned,
|
||||||
|
)
|
||||||
|
|
||||||
|
message, err := s.ClaudeClient.Messages.New(ctx, anthropic.MessageNewParams{
|
||||||
|
Model: anthropic.ModelClaudeHaiku4_5,
|
||||||
|
MaxTokens: 512,
|
||||||
|
System: []anthropic.TextBlockParam{
|
||||||
|
{Text: emailClassificationPrompt},
|
||||||
|
},
|
||||||
|
Messages: []anthropic.MessageParam{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Content: []anthropic.ContentBlockParamUnion{anthropic.NewTextBlock(userMsg)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("Claude API error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(message.Content) == 0 {
|
||||||
|
return "", nil, fmt.Errorf("empty response from Claude")
|
||||||
|
}
|
||||||
|
|
||||||
|
raw := message.Content[0].Text
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
raw = strings.TrimPrefix(raw, "```json")
|
||||||
|
raw = strings.TrimPrefix(raw, "```")
|
||||||
|
raw = strings.TrimSuffix(raw, "```")
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
|
||||||
|
var analysis EmailAnalysis
|
||||||
|
if err := json.Unmarshal([]byte(raw), &analysis); err != nil {
|
||||||
|
return "", nil, fmt.Errorf("parsing Claude response: %w (raw: %s)", err, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !analysis.IsJobEmail {
|
||||||
|
return "skipped", nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if analysis.Company == "" || analysis.JobTitle == "" {
|
||||||
|
return "skipped", nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find an existing job application
|
||||||
|
var existing models.JobApplication
|
||||||
|
found := s.DB.Where("LOWER(company) = LOWER(?) AND LOWER(job_title) = LOWER(?)",
|
||||||
|
analysis.Company, analysis.JobTitle).
|
||||||
|
Order("created_at DESC").
|
||||||
|
First(&existing).Error == nil
|
||||||
|
|
||||||
|
if found && analysis.Action == "update" {
|
||||||
|
return s.updateJobApplication(&existing, &analysis)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return s.createJobApplication(&analysis)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Found but action is "create" — the record already exists, skip
|
||||||
|
return "skipped", &existing.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EmailSyncService) createJobApplication(analysis *EmailAnalysis) (string, *uint, error) {
|
||||||
|
app := models.JobApplication{
|
||||||
|
Company: analysis.Company,
|
||||||
|
JobTitle: analysis.JobTitle,
|
||||||
|
Status: analysis.Status,
|
||||||
|
Location: analysis.Location,
|
||||||
|
URL: analysis.URL,
|
||||||
|
Notes: analysis.Notes,
|
||||||
|
}
|
||||||
|
|
||||||
|
if analysis.AppliedAt != nil {
|
||||||
|
if t, err := time.Parse(time.RFC3339, *analysis.AppliedAt); err == nil {
|
||||||
|
app.AppliedAt = &t
|
||||||
|
} else if t, err := time.Parse("2006-01-02", *analysis.AppliedAt); err == nil {
|
||||||
|
app.AppliedAt = &t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if app.Status == "" {
|
||||||
|
app.Status = "applied"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DB.Create(&app).Error; err != nil {
|
||||||
|
return "", nil, fmt.Errorf("creating job application: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[EmailSync] Created job application: %s at %s (status: %s)", app.JobTitle, app.Company, app.Status)
|
||||||
|
return "created", &app.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *EmailSyncService) updateJobApplication(existing *models.JobApplication, analysis *EmailAnalysis) (string, *uint, error) {
|
||||||
|
newOrder, newExists := statusOrder[analysis.Status]
|
||||||
|
currentOrder, currentExists := statusOrder[existing.Status]
|
||||||
|
|
||||||
|
// Only update if the new status represents progression
|
||||||
|
if !newExists || (currentExists && newOrder <= currentOrder) {
|
||||||
|
return "skipped", &existing.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.Status = analysis.Status
|
||||||
|
|
||||||
|
// Append to notes if Claude provided any
|
||||||
|
if analysis.Notes != nil && *analysis.Notes != "" {
|
||||||
|
if existing.Notes != nil && *existing.Notes != "" {
|
||||||
|
combined := *existing.Notes + "\n" + *analysis.Notes
|
||||||
|
existing.Notes = &combined
|
||||||
|
} else {
|
||||||
|
existing.Notes = analysis.Notes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.DB.Save(existing).Error; err != nil {
|
||||||
|
return "", nil, fmt.Errorf("updating job application: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[EmailSync] Updated job application: %s at %s (status: %s)", existing.JobTitle, existing.Company, existing.Status)
|
||||||
|
return "updated", &existing.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanEmailBody strips HTML and truncates the email body for Claude.
|
||||||
|
func cleanEmailBody(content string, contentType string) string {
|
||||||
|
text := content
|
||||||
|
|
||||||
|
if strings.EqualFold(contentType, "html") {
|
||||||
|
// Strip HTML tags
|
||||||
|
tagRegex := regexp.MustCompile(`<[^>]*>`)
|
||||||
|
text = tagRegex.ReplaceAllString(text, " ")
|
||||||
|
|
||||||
|
// Decode common HTML entities
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
" ", " ",
|
||||||
|
"&", "&",
|
||||||
|
"<", "<",
|
||||||
|
">", ">",
|
||||||
|
""", `"`,
|
||||||
|
"'", "'",
|
||||||
|
"'", "'",
|
||||||
|
)
|
||||||
|
text = replacer.Replace(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse whitespace
|
||||||
|
spaceRegex := regexp.MustCompile(`[ \t]+`)
|
||||||
|
text = spaceRegex.ReplaceAllString(text, " ")
|
||||||
|
nlRegex := regexp.MustCompile(`\n{3,}`)
|
||||||
|
text = nlRegex.ReplaceAllString(text, "\n\n")
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
|
||||||
|
// Remove common signatures
|
||||||
|
sigPatterns := []string{
|
||||||
|
"\n-- \n",
|
||||||
|
"\nSent from my iPhone",
|
||||||
|
"\nSent from my iPad",
|
||||||
|
"\nGet Outlook for",
|
||||||
|
}
|
||||||
|
for _, pattern := range sigPatterns {
|
||||||
|
if idx := strings.Index(text, pattern); idx > 0 {
|
||||||
|
text = text[:idx]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove forwarded email chains
|
||||||
|
lines := strings.Split(text, "\n")
|
||||||
|
var cleaned []string
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(line), ">") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cleaned = append(cleaned, line)
|
||||||
|
}
|
||||||
|
text = strings.Join(cleaned, "\n")
|
||||||
|
|
||||||
|
// Truncate to ~4000 characters
|
||||||
|
if len(text) > 4000 {
|
||||||
|
text = text[:4000]
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, maxLen int) string {
|
||||||
|
if len(s) <= maxLen {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxLen]
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailClassificationPrompt = `You are an email classifier for job applications. You will receive the subject line, sender, and body of an email. Determine if this email is related to a job application, and if so, extract structured data.
|
||||||
|
|
||||||
|
Return ONLY a JSON object with these exact keys:
|
||||||
|
- "isJobEmail": boolean - true if this email is about a job application
|
||||||
|
- "action": "create" | "update" | "none" - whether this represents a new application confirmation or an update to an existing one
|
||||||
|
- "company": string - the company name
|
||||||
|
- "jobTitle": string - the job title/position
|
||||||
|
- "status": string - one of: "applied", "screening", "interviewing", "assessment", "offer", "rejected", "withdrawn"
|
||||||
|
- "location": string or null - job location if mentioned
|
||||||
|
- "url": string or null - any application portal URL
|
||||||
|
- "appliedAt": string or null - ISO 8601 date if an application date is mentioned (YYYY-MM-DD format)
|
||||||
|
- "notes": string or null - brief summary of what this email communicates (e.g. "Interview scheduled for March 15", "Application confirmed via Greenhouse")
|
||||||
|
|
||||||
|
If isJobEmail is false, only include that field. No text, no markdown, no explanation. Just the JSON object.`
|
||||||
72
backend/services/gitea.go
Normal file
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
|
||||||
|
}
|
||||||
45
backend/services/ratelimit.go
Normal file
45
backend/services/ratelimit.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RateLimiter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
attempts map[string][]time.Time
|
||||||
|
max int
|
||||||
|
window time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRateLimiter(max int, window time.Duration) *RateLimiter {
|
||||||
|
return &RateLimiter{
|
||||||
|
attempts: make(map[string][]time.Time),
|
||||||
|
max: max,
|
||||||
|
window: window,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rl *RateLimiter) Allow(key string) bool {
|
||||||
|
rl.mu.Lock()
|
||||||
|
defer rl.mu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
cutoff := now.Add(-rl.window)
|
||||||
|
|
||||||
|
// Remove expired entries
|
||||||
|
valid := rl.attempts[key][:0]
|
||||||
|
for _, t := range rl.attempts[key] {
|
||||||
|
if t.After(cutoff) {
|
||||||
|
valid = append(valid, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(valid) >= rl.max {
|
||||||
|
rl.attempts[key] = valid
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
rl.attempts[key] = append(valid, now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
65
backend/services/seed.go
Normal file
65
backend/services/seed.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"adam-french.co.uk/backend/models"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SeedDatabase(db *gorm.DB) {
|
||||||
|
var user models.User
|
||||||
|
if db.First(&user).Error == nil {
|
||||||
|
log.Println("Database already has data, skipping seed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Seeding database with test data...")
|
||||||
|
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Failed to hash seed password:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testUser := models.User{
|
||||||
|
Username: "testuser",
|
||||||
|
Password: hashedPassword,
|
||||||
|
Admin: true,
|
||||||
|
}
|
||||||
|
db.Create(&testUser)
|
||||||
|
|
||||||
|
posts := []models.Post{
|
||||||
|
{Title: "Welcome to my blog", Content: "This is the first test post with some example content.", AuthorID: testUser.ID},
|
||||||
|
{Title: "Learning Go", Content: "Go is a great language for building web servers and APIs.", AuthorID: testUser.ID},
|
||||||
|
{Title: "Vue 3 Tips", Content: "The composition API makes Vue components much more flexible.", AuthorID: testUser.ID},
|
||||||
|
}
|
||||||
|
db.Create(&posts)
|
||||||
|
|
||||||
|
link1 := "https://example.com/project"
|
||||||
|
link2 := "https://example.com/book"
|
||||||
|
activities := []models.Activity{
|
||||||
|
{Type: "project", Name: "coding"},
|
||||||
|
{Type: "hobby", Name: "reading", Link: &link1},
|
||||||
|
{Type: "fitness", Name: "exercise"},
|
||||||
|
}
|
||||||
|
db.Create(&activities)
|
||||||
|
|
||||||
|
favorites := []models.Favorite{
|
||||||
|
{Type: "language", Name: "Go"},
|
||||||
|
{Type: "book", Name: "Designing Data-Intensive Applications", Link: &link2},
|
||||||
|
{Type: "framework", Name: "Vue"},
|
||||||
|
}
|
||||||
|
db.Create(&favorites)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
rowingEntries := []models.Rowing{
|
||||||
|
{Date: now.AddDate(0, 0, -14), Time: 1800, Distance: 5000, TimePer500m: 120.0, Calories: 300},
|
||||||
|
{Date: now.AddDate(0, 0, -7), Time: 1750, Distance: 5200, TimePer500m: 118.5, Calories: 315},
|
||||||
|
{Date: now, Time: 1700, Distance: 5400, TimePer500m: 116.2, Calories: 330},
|
||||||
|
}
|
||||||
|
db.Create(&rowingEntries)
|
||||||
|
|
||||||
|
log.Println("Database seeded successfully")
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/zmb3/spotify/v2"
|
"github.com/zmb3/spotify/v2"
|
||||||
@@ -34,6 +35,10 @@ func SaveSpotifyToken(path string, tok *oauth2.Token) error {
|
|||||||
Expiry: tok.Expiry,
|
Expiry: tok.Expiry,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
return fmt.Errorf("creating token directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
jsonBytes, err := json.MarshalIndent(data, "", " ")
|
jsonBytes, err := json.MarshalIndent(data, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
83
backend/services/steam.go
Normal file
83
backend/services/steam.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SteamRecentGame struct {
|
||||||
|
AppID int `json:"appid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Playtime2Weeks int `json:"playtime_2weeks"`
|
||||||
|
PlaytimeForever int `json:"playtime_forever"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SteamRecentGamesResponse struct {
|
||||||
|
Response struct {
|
||||||
|
TotalCount int `json:"total_count"`
|
||||||
|
Games []SteamRecentGame `json:"games"`
|
||||||
|
} `json:"response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SteamPlayerSummary struct {
|
||||||
|
PersonaState int `json:"personastate"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SteamPlayerSummariesResponse struct {
|
||||||
|
Response struct {
|
||||||
|
Players []SteamPlayerSummary `json:"players"`
|
||||||
|
} `json:"response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchRecentlyPlayedGames(apiKey, steamID string) ([]SteamRecentGame, error) {
|
||||||
|
url := fmt.Sprintf("https://api.steampowered.com/IPlayerService/GetRecentlyPlayedGames/v1/?key=%s&steamid=%s&count=3", apiKey, steamID)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result SteamRecentGamesResponse
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Response.Games, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchPlayerSummary(apiKey, steamID string) (*SteamPlayerSummary, error) {
|
||||||
|
url := fmt.Sprintf("https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key=%s&steamids=%s", apiKey, steamID)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result SteamPlayerSummariesResponse
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Response.Players) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result.Response.Players[0], nil
|
||||||
|
}
|
||||||
@@ -1,33 +1,67 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"adam-french.co.uk/backend/models"
|
"adam-french.co.uk/backend/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxMessages = 50
|
||||||
|
|
||||||
|
var allowedDomain string
|
||||||
|
|
||||||
var Upgrader = websocket.Upgrader{
|
var Upgrader = websocket.Upgrader{
|
||||||
ReadBufferSize: 1024,
|
ReadBufferSize: 1024,
|
||||||
WriteBufferSize: 1024,
|
WriteBufferSize: 1024,
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if origin == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
u, err := url.Parse(origin)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
host := u.Hostname()
|
||||||
|
return host == allowedDomain || host == "www."+allowedDomain || host == "localhost"
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
clients = make(map[*websocket.Conn]bool)
|
clients = make(map[*websocket.Conn]bool)
|
||||||
messages = make([]models.Message, 0)
|
mu sync.Mutex
|
||||||
mu sync.Mutex
|
wsDB *gorm.DB
|
||||||
|
nextAuthorID uint
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
rateLimitWindow = time.Second
|
||||||
|
rateLimitMaxMsgs = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
func InitWebSocket(database *gorm.DB, domain string) {
|
||||||
|
wsDB = database
|
||||||
|
allowedDomain = domain
|
||||||
|
}
|
||||||
|
|
||||||
func HandleWebSocket(conn *websocket.Conn) {
|
func HandleWebSocket(conn *websocket.Conn) {
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
clients[conn] = true
|
clients[conn] = true
|
||||||
|
nextAuthorID++
|
||||||
|
authorID := nextAuthorID
|
||||||
|
|
||||||
// Send existing message history to new client
|
var history []models.Message
|
||||||
for _, msg := range messages {
|
wsDB.Order("created_at ASC").Limit(maxMessages).Find(&history)
|
||||||
|
|
||||||
|
for _, msg := range history {
|
||||||
if err := conn.WriteJSON(msg); err != nil {
|
if err := conn.WriteJSON(msg); err != nil {
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
return
|
return
|
||||||
@@ -35,17 +69,32 @@ func HandleWebSocket(conn *websocket.Conn) {
|
|||||||
}
|
}
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
|
|
||||||
|
msgCount := 0
|
||||||
|
windowStart := time.Now()
|
||||||
|
|
||||||
for {
|
for {
|
||||||
var incoming models.Message
|
var incoming models.Message
|
||||||
if err := conn.ReadJSON(&incoming); err != nil {
|
if err := conn.ReadJSON(&incoming); err != nil {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
incoming.CreatedAt = time.Now()
|
now := time.Now()
|
||||||
|
if now.Sub(windowStart) > rateLimitWindow {
|
||||||
|
msgCount = 0
|
||||||
|
windowStart = now
|
||||||
|
}
|
||||||
|
msgCount++
|
||||||
|
if msgCount > rateLimitMaxMsgs {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
incoming.AuthorID = authorID
|
||||||
|
|
||||||
// Store and broadcast
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
messages = append(messages, incoming)
|
wsDB.Create(&incoming)
|
||||||
|
wsDB.Where("id NOT IN (?)",
|
||||||
|
wsDB.Model(&models.Message{}).Select("id").Order("created_at DESC").Limit(maxMessages),
|
||||||
|
).Delete(&models.Message{})
|
||||||
|
|
||||||
for client := range clients {
|
for client := range clients {
|
||||||
if err := client.WriteJSON(incoming); err != nil {
|
if err := client.WriteJSON(incoming); err != nil {
|
||||||
@@ -56,7 +105,6 @@ func HandleWebSocket(conn *websocket.Conn) {
|
|||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup on disconnect
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
delete(clients, conn)
|
delete(clients, conn)
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
if [ ! -d /etc/letsencrypt/live/${DOMAIN} ]; then
|
certbot certonly --webroot -w /var/www/certbot \
|
||||||
certbot certonly --webroot -w /var/www/certbot --email ${EMAIL} -d ${DOMAIN} -d www.${DOMAIN} --agree-tos --non-interactive;
|
--email ${EMAIL} \
|
||||||
fi;
|
-d ${DOMAIN} -d www.${DOMAIN} -d chat.${DOMAIN} \
|
||||||
|
--agree-tos --non-interactive --expand;
|
||||||
|
|
||||||
trap exit TERM;
|
trap exit TERM;
|
||||||
|
|
||||||
|
|||||||
29
docker-compose.dev.yml
Normal file
29
docker-compose.dev.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
services:
|
||||||
|
vue:
|
||||||
|
command: ["npm", "run", "dev"]
|
||||||
|
volumes:
|
||||||
|
- ./vue:/app
|
||||||
|
- /app/node_modules
|
||||||
|
- /app/src/wasm
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=development
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
- SPOTIFY_REDIRECT_URI=https://localhost/api/spotify/callback
|
||||||
|
- GQL_PLAYGROUND=true
|
||||||
|
- GQL_INTROSPECTION=true
|
||||||
|
- DEV_MODE=true
|
||||||
|
- SEED_DB=true
|
||||||
|
nginx:
|
||||||
|
environment:
|
||||||
|
- DEV_MODE=true
|
||||||
|
ports:
|
||||||
|
- 80:80
|
||||||
|
- 443:443
|
||||||
|
hasura:
|
||||||
|
environment:
|
||||||
|
HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
|
||||||
|
HASURA_GRAPHQL_DEV_MODE: "true"
|
||||||
|
certbot:
|
||||||
|
profiles:
|
||||||
|
- disabled
|
||||||
@@ -1,21 +1,49 @@
|
|||||||
networks:
|
networks:
|
||||||
app-network:
|
app-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
ipam:
|
||||||
|
config:
|
||||||
|
- subnet: 172.28.0.0/16
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
# Postgres database
|
||||||
dbdata:
|
dbdata:
|
||||||
|
# File upload
|
||||||
|
uploads:
|
||||||
|
# Vue build
|
||||||
|
vue_dist:
|
||||||
|
# Searxng data
|
||||||
|
searxng_data:
|
||||||
|
# Open-WebUI data
|
||||||
|
openwebui_data:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
vue:
|
||||||
|
build:
|
||||||
|
context: ./vue
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: vue
|
||||||
|
volumes:
|
||||||
|
- vue_dist:/output
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
build:
|
build:
|
||||||
context: ./nginx
|
context: ./nginx
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: nginx
|
container_name: nginx
|
||||||
env_file: ./.env
|
env_file: ./.env
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
|
- vue
|
||||||
- backend
|
- backend
|
||||||
- icecast2
|
- icecast2
|
||||||
|
- gitea
|
||||||
|
- hasura
|
||||||
|
- quartz
|
||||||
|
- searxng
|
||||||
|
- open-webui
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
ports:
|
ports:
|
||||||
@@ -24,15 +52,19 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./certbot/conf:/etc/letsencrypt
|
- ./certbot/conf:/etc/letsencrypt
|
||||||
- ./certbot/www:/var/www/certbot
|
- ./certbot/www:/var/www/certbot
|
||||||
|
- uploads:/uploads
|
||||||
|
- vue_dist:/etc/nginx/html
|
||||||
|
|
||||||
certbot:
|
certbot:
|
||||||
image: certbot/certbot
|
image: certbot/certbot:v3.1.0
|
||||||
container_name: certbot
|
container_name: certbot
|
||||||
volumes:
|
volumes:
|
||||||
- ./certbot/entrypoint.sh:/entrypoint.sh
|
- ./certbot/entrypoint.sh:/entrypoint.sh
|
||||||
- ./certbot/conf:/etc/letsencrypt
|
- ./certbot/conf:/etc/letsencrypt
|
||||||
- ./certbot/www:/var/www/certbot
|
- ./certbot/www:/var/www/certbot
|
||||||
entrypoint: ["/entrypoint.sh"]
|
entrypoint: ["/entrypoint.sh"]
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
@@ -41,7 +73,7 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: "${BACKEND_HOST}"
|
container_name: "${BACKEND_HOST}"
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
networks:
|
networks:
|
||||||
@@ -52,11 +84,30 @@ services:
|
|||||||
- ./backend/token/:/backend/token
|
- ./backend/token/:/backend/token
|
||||||
- ${OBSIDIAN_DIR}:/backend/notes
|
- ${OBSIDIAN_DIR}:/backend/notes
|
||||||
- ./logs:/backend/logs
|
- ./logs:/backend/logs
|
||||||
|
- uploads:/backend/uploads
|
||||||
|
- ./icecast2/fallback_music:/backend/fallback_music
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:8080/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
autoheal:
|
||||||
|
image: willfarrell/autoheal:latest
|
||||||
|
container_name: autoheal
|
||||||
|
restart: always
|
||||||
|
environment:
|
||||||
|
- AUTOHEAL_CONTAINER_LABEL=all
|
||||||
|
- AUTOHEAL_INTERVAL=30
|
||||||
|
- AUTOHEAL_START_PERIOD=60
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16
|
image: postgres:16
|
||||||
container_name: "${POSTGRES_HOST}"
|
container_name: "${POSTGRES_HOST}"
|
||||||
restart: unless-stopped
|
restart: always
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
networks:
|
networks:
|
||||||
@@ -64,6 +115,21 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- dbdata:/var/lib/postgresql/data
|
- dbdata:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
hasura:
|
||||||
|
image: hasura/graphql-engine:v2.44.0
|
||||||
|
container_name: "${HASURA_HOST}"
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
environment:
|
||||||
|
HASURA_GRAPHQL_DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
|
||||||
|
HASURA_GRAPHQL_ADMIN_SECRET: "${HASURA_GRAPHQL_ADMIN_SECRET}"
|
||||||
|
HASURA_GRAPHQL_ENABLE_CONSOLE: "false"
|
||||||
|
HASURA_GRAPHQL_DEV_MODE: "false"
|
||||||
|
HASURA_GRAPHQL_ENABLED_LOG_TYPES: "startup, http-log, webhook-log, websocket-log, query-log"
|
||||||
|
|
||||||
icecast2:
|
icecast2:
|
||||||
build:
|
build:
|
||||||
context: ./icecast2
|
context: ./icecast2
|
||||||
@@ -74,5 +140,80 @@ services:
|
|||||||
- app-network
|
- app-network
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
|
volumes:
|
||||||
|
- ./icecast2/fallback_music:/music:ro
|
||||||
ports:
|
ports:
|
||||||
- "${ICECAST_PORT}:${ICECAST_PORT}"
|
- "${LIQUIDSOAP_HARBOR_PORT:-8001}:${LIQUIDSOAP_HARBOR_PORT:-8001}"
|
||||||
|
|
||||||
|
quartz:
|
||||||
|
build:
|
||||||
|
context: ./quartz
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: "${QUARTZ_HOST}"
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
volumes:
|
||||||
|
- ${OBSIDIAN_DIR}:/quartz/content:ro
|
||||||
|
|
||||||
|
searxng:
|
||||||
|
build:
|
||||||
|
context: ./searxng
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: "${SEARXNG_HOST}"
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
environment:
|
||||||
|
- BASE_URL=https://www.${DOMAIN}/searxng/
|
||||||
|
- INSTANCE_NAME=searxng
|
||||||
|
- SEARXNG_SECRET_KEY=${SEARXNG_SECRET_KEY}
|
||||||
|
volumes:
|
||||||
|
- searxng_data:/etc/searxng
|
||||||
|
|
||||||
|
open-webui:
|
||||||
|
image: ghcr.io/open-webui/open-webui:main
|
||||||
|
container_name: "${OPENWEBUI_HOST}"
|
||||||
|
restart: always
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
env_file:
|
||||||
|
- ./.env
|
||||||
|
environment:
|
||||||
|
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL}
|
||||||
|
- WEBUI_AUTH=False
|
||||||
|
- WEBUI_URL=https://chat.${DOMAIN}
|
||||||
|
volumes:
|
||||||
|
- openwebui_data:/app/backend/data
|
||||||
|
|
||||||
|
gitea:
|
||||||
|
image: docker.gitea.com/gitea:1.25.4-rootless
|
||||||
|
container_name: "${GITEA_HOST}"
|
||||||
|
entrypoint: ["/usr/bin/dumb-init", "--", "/etc/gitea/entrypoint.sh"]
|
||||||
|
networks:
|
||||||
|
- app-network
|
||||||
|
environment:
|
||||||
|
- GITEA__database__DB_TYPE=postgres
|
||||||
|
- GITEA__database__HOST=${POSTGRES_HOST}
|
||||||
|
- GITEA__database__NAME=${POSTGRES_GITEA_DB}
|
||||||
|
- GITEA__database__USER=${POSTGRES_USER}
|
||||||
|
- GITEA__database__PASSWD=${POSTGRES_PASSWORD}
|
||||||
|
- GITEA__server__LFS_JWT_SECRET=${GITEA_LFS_JWT_SECRET}
|
||||||
|
- GITEA__security__INTERNAL_TOKEN=${GITEA_INTERNAL_TOKEN}
|
||||||
|
- GITEA__oauth2__JWT_SECRET=${GITEA_OAUTH2_JWT_SECRET}
|
||||||
|
- USER_UID=1000
|
||||||
|
- USER_GID=1000
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ./gitea/data:/var/lib/gitea
|
||||||
|
- ./gitea/config:/etc/gitea
|
||||||
|
- ./gitea/entrypoint.sh:/etc/gitea/entrypoint.sh:ro
|
||||||
|
- /etc/timezone:/etc/timezone:ro
|
||||||
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
ports:
|
||||||
|
- "2222:2222"
|
||||||
|
- "3000:3000"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
|||||||
24
gitea-runner/download.sh
Executable file
24
gitea-runner/download.sh
Executable file
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="0.2.11"
|
||||||
|
BASE_URL="https://gitea.com/gitea/act_runner/releases/download/v${VERSION}"
|
||||||
|
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
case "$ARCH" in
|
||||||
|
x86_64) ASSET="act_runner-${VERSION}-linux-amd64" ;;
|
||||||
|
aarch64) ASSET="act_runner-${VERSION}-linux-arm64" ;;
|
||||||
|
armv7l) ASSET="act_runner-${VERSION}-linux-armv7" ;;
|
||||||
|
*)
|
||||||
|
echo "Unsupported architecture: $ARCH" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
DEST="${SCRIPT_DIR}/act_runner"
|
||||||
|
|
||||||
|
echo "Downloading act_runner v${VERSION} for ${ARCH}..."
|
||||||
|
curl -fSL "${BASE_URL}/${ASSET}" -o "$DEST"
|
||||||
|
chmod +x "$DEST"
|
||||||
|
echo "Downloaded to $DEST"
|
||||||
27
gitea-runner/run.sh
Executable file
27
gitea-runner/run.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
if [ ! -f ./act_runner ]; then
|
||||||
|
echo "act_runner binary not found. Downloading..." >&2
|
||||||
|
bash "$(dirname "$0")/download.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Waiting for localhost:3000 to respond..." >&2
|
||||||
|
|
||||||
|
while ! curl -sf http://localhost:3000 > /dev/null 2>&1; do
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "localhost:3000 is up." >&2
|
||||||
|
|
||||||
|
if [ ! -f .runner ]; then
|
||||||
|
echo "No .runner file found. Registering runner..." >&2
|
||||||
|
./act_runner register --no-interactive \
|
||||||
|
--instance http://localhost:3000 \
|
||||||
|
--token "${GITEA_RUNNER_REGISTRATION_TOKEN}" \
|
||||||
|
--name "${GITEA_RUNNER_NAME:-pi-runner}" \
|
||||||
|
--labels self-hosted
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Starting act_runner daemon..." >&2
|
||||||
|
exec ./act_runner daemon
|
||||||
|
|
||||||
97
gitea/config/app.ini.template
Normal file
97
gitea/config/app.ini.template
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
APP_NAME = Gitea: Git with a cup of tea
|
||||||
|
RUN_USER = git
|
||||||
|
RUN_MODE = prod
|
||||||
|
WORK_PATH = /var/lib/gitea
|
||||||
|
|
||||||
|
[repository]
|
||||||
|
ROOT = /var/lib/gitea/git/repositories
|
||||||
|
|
||||||
|
[repository.local]
|
||||||
|
LOCAL_COPY_PATH = /tmp/gitea/local-repo
|
||||||
|
|
||||||
|
[repository.upload]
|
||||||
|
TEMP_PATH = /tmp/gitea/uploads
|
||||||
|
|
||||||
|
[server]
|
||||||
|
APP_DATA_PATH = /var/lib/gitea
|
||||||
|
SSH_DOMAIN = adam-french.co.uk
|
||||||
|
HTTP_PORT = 3000
|
||||||
|
ROOT_URL = https://adam-french.co.uk/gitea/
|
||||||
|
DISABLE_SSH = false
|
||||||
|
; In rootless gitea container only internal ssh server is supported
|
||||||
|
START_SSH_SERVER = true
|
||||||
|
SSH_PORT = 2222
|
||||||
|
SSH_LISTEN_PORT = 2222
|
||||||
|
BUILTIN_SSH_SERVER_USER = git
|
||||||
|
LFS_START_SERVER = true
|
||||||
|
DOMAIN = stppi.local
|
||||||
|
LFS_JWT_SECRET =
|
||||||
|
OFFLINE_MODE = true
|
||||||
|
|
||||||
|
[database]
|
||||||
|
PATH = /var/lib/gitea/data/gitea.db
|
||||||
|
DB_TYPE = postgres
|
||||||
|
HOST = db
|
||||||
|
NAME = gitea
|
||||||
|
USER = postgres
|
||||||
|
PASSWD =
|
||||||
|
SCHEMA =
|
||||||
|
SSL_MODE = disable
|
||||||
|
LOG_SQL = false
|
||||||
|
|
||||||
|
[session]
|
||||||
|
PROVIDER_CONFIG = /var/lib/gitea/data/sessions
|
||||||
|
PROVIDER = file
|
||||||
|
|
||||||
|
[picture]
|
||||||
|
AVATAR_UPLOAD_PATH = /var/lib/gitea/data/avatars
|
||||||
|
REPOSITORY_AVATAR_UPLOAD_PATH = /var/lib/gitea/data/repo-avatars
|
||||||
|
|
||||||
|
[attachment]
|
||||||
|
PATH = /var/lib/gitea/data/attachments
|
||||||
|
|
||||||
|
[log]
|
||||||
|
ROOT_PATH = /var/lib/gitea/data/log
|
||||||
|
MODE = console
|
||||||
|
LEVEL = info
|
||||||
|
|
||||||
|
[security]
|
||||||
|
INSTALL_LOCK = true
|
||||||
|
SECRET_KEY =
|
||||||
|
REVERSE_PROXY_LIMIT = 1
|
||||||
|
REVERSE_PROXY_TRUSTED_PROXIES = *
|
||||||
|
PASSWORD_HASH_ALGO = pbkdf2
|
||||||
|
|
||||||
|
[service]
|
||||||
|
DISABLE_REGISTRATION = true
|
||||||
|
REQUIRE_SIGNIN_VIEW = false
|
||||||
|
REGISTER_EMAIL_CONFIRM = false
|
||||||
|
ENABLE_NOTIFY_MAIL = false
|
||||||
|
ALLOW_ONLY_EXTERNAL_REGISTRATION = false
|
||||||
|
ENABLE_CAPTCHA = false
|
||||||
|
DEFAULT_KEEP_EMAIL_PRIVATE = false
|
||||||
|
DEFAULT_ALLOW_CREATE_ORGANIZATION = true
|
||||||
|
DEFAULT_ENABLE_TIMETRACKING = true
|
||||||
|
NO_REPLY_ADDRESS = noreply.localhost
|
||||||
|
|
||||||
|
[lfs]
|
||||||
|
PATH = /var/lib/gitea/git/lfs
|
||||||
|
|
||||||
|
[mailer]
|
||||||
|
ENABLED = false
|
||||||
|
|
||||||
|
[openid]
|
||||||
|
ENABLE_OPENID_SIGNIN = true
|
||||||
|
ENABLE_OPENID_SIGNUP = true
|
||||||
|
|
||||||
|
[cron.update_checker]
|
||||||
|
ENABLED = false
|
||||||
|
|
||||||
|
[repository.pull-request]
|
||||||
|
DEFAULT_MERGE_STYLE = merge
|
||||||
|
|
||||||
|
[repository.signing]
|
||||||
|
DEFAULT_TRUST_MODEL = committer
|
||||||
|
|
||||||
|
[oauth2]
|
||||||
|
JWT_SECRET =
|
||||||
10
gitea/entrypoint.sh
Executable file
10
gitea/entrypoint.sh
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Generate app.ini from template if it doesn't already exist
|
||||||
|
if [ ! -f /etc/gitea/app.ini ]; then
|
||||||
|
cp /etc/gitea/app.ini.template /etc/gitea/app.ini
|
||||||
|
echo "Generated app.ini from template"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec /usr/local/bin/docker-entrypoint.sh "$@"
|
||||||
1
icecast2/.dockerignore
Normal file
1
icecast2/.dockerignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
fallback_music/
|
||||||
@@ -1,19 +1,13 @@
|
|||||||
FROM debian:latest as builder
|
FROM savonet/liquidsoap:v2.3.2
|
||||||
|
USER root
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install --yes icecast2 gettext media-types
|
&& apt-get install --yes icecast2 gettext media-types \
|
||||||
# RUN apt-get install --yes liquidsoap
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN useradd radio
|
RUN useradd radio
|
||||||
RUN chown -R radio:radio /etc/icecast2 /var/log/icecast2
|
RUN mkdir -p /music /etc/liquidsoap
|
||||||
# RUN chown -R radio:radio /etc/liquidsoap /var/log/liquidsoap
|
RUN chown -R radio:radio /etc/icecast2 /var/log/icecast2 /music /etc/liquidsoap
|
||||||
USER radio
|
USER radio
|
||||||
|
|
||||||
COPY icecast.xml.template /etc/icecast2/icecast.xml.template
|
COPY icecast.xml.template /etc/icecast2/icecast.xml.template
|
||||||
# COPY stream.liq.template /etc/liquidsoap/stream.liq.template
|
COPY stream.liq.template /etc/liquidsoap/stream.liq.template
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT [ "/entrypoint.sh" ]
|
ENTRYPOINT [ "/entrypoint.sh" ]
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
#!/bin/sh
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Substitute environment variables into template
|
|
||||||
envsubst < /etc/icecast2/icecast.xml.template > /etc/icecast2/icecast.xml
|
envsubst < /etc/icecast2/icecast.xml.template > /etc/icecast2/icecast.xml
|
||||||
# envsubst < /etc/liquidsoap/stream.liq.template > /etc/liquidsoap/stream.liq
|
envsubst < /etc/liquidsoap/stream.liq.template > /etc/liquidsoap/stream.liq
|
||||||
|
|
||||||
# Run icecast with the generated config
|
icecast2 -c /etc/icecast2/icecast.xml &
|
||||||
exec icecast2 -c /etc/icecast2/icecast.xml
|
sleep 2
|
||||||
# exec liquidsoap /etc/liquidsoap/stream.liq
|
liquidsoap /etc/liquidsoap/stream.liq &
|
||||||
# wait -n
|
wait -n
|
||||||
|
kill $(jobs -p) 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
|||||||
0
icecast2/fallback_music/.gitkeep
Normal file
0
icecast2/fallback_music/.gitkeep
Normal file
@@ -1,24 +1,17 @@
|
|||||||
[general]
|
settings.server.telnet := false
|
||||||
duration = 0 # 0 = run forever
|
|
||||||
bufferSecs = 5 # buffer size in seconds
|
|
||||||
reconnect = yes # reconnect on failure
|
|
||||||
reconnectDelay = 5
|
|
||||||
|
|
||||||
[input]
|
music = playlist("/music", mode="randomize", reload_mode="watch")
|
||||||
device = pulse # PulseAudio input
|
|
||||||
sampleRate = 44100 # in Hz
|
|
||||||
bitsPerSample = 16
|
|
||||||
channel = 2
|
|
||||||
|
|
||||||
[icecast2-0]
|
live = input.harbor("${LIQUIDSOAP_HARBOR_MOUNT}", port=${LIQUIDSOAP_HARBOR_PORT}, password="${ICECAST_SOURCE_PASSWORD}")
|
||||||
bitrateMode = cbr
|
|
||||||
bitrate = 128 # kbps
|
radio = amplify(0.7, fallback(track_sensitive=false, [live, music, blank()]))
|
||||||
format = mp3
|
|
||||||
server = ${ICECAST_HOST}
|
output.icecast(
|
||||||
port = ${ICECAST_PORT}
|
%mp3,
|
||||||
password = ${ICECAST_SOURCE_PASSWORD}
|
host="localhost",
|
||||||
mountPoint = ${ICECAST_MOUNT}
|
port=${ICECAST_PORT},
|
||||||
name = "Live DJ stream"
|
password="${ICECAST_SOURCE_PASSWORD}",
|
||||||
description = "Live microphone stream"
|
mount="${ICECAST_MOUNT}",
|
||||||
genre = "Various"
|
fallible=true,
|
||||||
public = yes
|
radio
|
||||||
|
)
|
||||||
|
|||||||
2
nginx/.dockerignore
Normal file
2
nginx/.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
**/.git
|
||||||
|
**/.DS_Store
|
||||||
@@ -1,32 +1,9 @@
|
|||||||
FROM nginx:latest
|
FROM nginx:1.27
|
||||||
RUN rm -rf /etc/nginx/html/*
|
RUN rm -rf /etc/nginx/html/* && \
|
||||||
|
apt-get update && apt-get install -y gettext-base openssl && \
|
||||||
# Install dependencies needed to add NodeSource repo
|
rm -rf /var/lib/apt/lists/*
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
curl \
|
|
||||||
build-essential \
|
|
||||||
git \
|
|
||||||
gettext-base \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Node.js LTS + npm
|
|
||||||
RUN curl -fsSL https://deb.nodesource.com/setup_lts.x | bash - \
|
|
||||||
&& apt-get install -y nodejs \
|
|
||||||
&& npm install -g npm@latest
|
|
||||||
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY vue/ ./
|
|
||||||
RUN npm install
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
|
|
||||||
RUN mkdir -p /etc/nginx/html \
|
|
||||||
&& cp -r ./dist/* /etc/nginx/html/
|
|
||||||
|
|
||||||
COPY nginx.conf.template /etc/nginx/nginx.conf.template
|
COPY nginx.conf.template /etc/nginx/nginx.conf.template
|
||||||
COPY nginx_setup.conf.template /etc/nginx/nginx_setup.conf.template
|
COPY nginx_setup.conf.template /etc/nginx/nginx_setup.conf.template
|
||||||
COPY robots.txt /etc/nginx/html/robots.txt
|
COPY nginx_dev.conf.template /etc/nginx/nginx_dev.conf.template
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -1,15 +1,49 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Check if certificate exists
|
# Check if DEV_MODE
|
||||||
if [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then
|
if [ "$DEV_MODE" = "true" ]; then
|
||||||
echo "Certificates found. Using production nginx config."
|
echo "Dev mode. Generating self-signed certificate for HTTPS."
|
||||||
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT}' \
|
CERT_DIR="/etc/letsencrypt/live/localhost"
|
||||||
< /etc/nginx/nginx.conf.template \
|
if [ ! -f "$CERT_DIR/fullchain.pem" ]; then
|
||||||
> /etc/nginx/nginx.conf
|
mkdir -p "$CERT_DIR"
|
||||||
|
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||||
|
-keyout "$CERT_DIR/privkey.pem" \
|
||||||
|
-out "$CERT_DIR/fullchain.pem" \
|
||||||
|
-subj "/CN=localhost" 2>/dev/null
|
||||||
|
fi
|
||||||
|
# In dev mode, so use nginx_dev.conf.template
|
||||||
|
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT} ${HASURA_HOST} ${HASURA_PORT} ${QUARTZ_HOST} ${QUARTZ_PORT} ${UPTIMEKUMA_HOST} ${UPTIMEKUMA_PORT} ${SEARXNG_HOST} ${SEARXNG_PORT} ${WALLABAG_HOST} ${WALLABAG_PORT} ${OPENWEBUI_HOST} ${OPENWEBUI_PORT}' \
|
||||||
|
</etc/nginx/nginx_dev.conf.template \
|
||||||
|
>/etc/nginx/nginx.conf
|
||||||
|
elif [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ] && [ -f "/etc/letsencrypt/live/$DOMAIN/privkey.pem" ]; then
|
||||||
|
echo "Certificates found. Using production nginx config."
|
||||||
|
# In production with certificates already existing, so use nginx.conf.template
|
||||||
|
envsubst '${DOMAIN} ${BACKEND_HOST} ${BACKEND_PORT} ${BACKEND_ENDPOINT} ${ICECAST_HOST} ${ICECAST_PORT} ${GITEA_HOST} ${GITEA_PORT} ${HASURA_HOST} ${HASURA_PORT} ${QUARTZ_HOST} ${QUARTZ_PORT} ${UPTIMEKUMA_HOST} ${UPTIMEKUMA_PORT} ${SEARXNG_HOST} ${SEARXNG_PORT} ${WALLABAG_HOST} ${WALLABAG_PORT} ${OPENWEBUI_HOST} ${OPENWEBUI_PORT}' \
|
||||||
|
</etc/nginx/nginx.conf.template \
|
||||||
|
>/etc/nginx/nginx.conf
|
||||||
else
|
else
|
||||||
echo "Certificates NOT found. Using setup nginx config."
|
echo "Certificates NOT found. Using setup nginx config."
|
||||||
envsubst '${DOMAIN}' < /etc/nginx/nginx_setup.conf.template > /etc/nginx/nginx.conf
|
# In production with no certificates, so use nginx_setup.conf.template and will need restart after generation
|
||||||
|
envsubst '${DOMAIN}' </etc/nginx/nginx_setup.conf.template >/etc/nginx/nginx.conf
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure upload directory is traversable by nginx worker
|
||||||
|
chmod 755 /uploads 2>/dev/null || true
|
||||||
|
|
||||||
|
# Wait for Vue assets in production mode
|
||||||
|
if [ "$DEV_MODE" != "true" ]; then
|
||||||
|
echo "Waiting for Vue assets..."
|
||||||
|
elapsed=0
|
||||||
|
while [ ! -f /etc/nginx/html/index.html ] && [ $elapsed -lt 120 ]; do
|
||||||
|
sleep 1
|
||||||
|
elapsed=$((elapsed + 1))
|
||||||
|
done
|
||||||
|
if [ ! -f /etc/nginx/html/index.html ]; then
|
||||||
|
echo "WARNING: Vue assets not found after 120s, starting nginx anyway"
|
||||||
|
else
|
||||||
|
echo "Vue assets ready."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start nginx
|
# Start nginx
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ http {
|
|||||||
server_tokens off;
|
server_tokens off;
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
|
|
||||||
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=graphql:10m rate=10r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
|
||||||
|
|
||||||
log_format compact
|
log_format compact
|
||||||
'$remote_addr "$request" $status rt=$request_time';
|
'$remote_addr "$request" $status rt=$request_time';
|
||||||
|
|
||||||
@@ -18,6 +27,22 @@ http {
|
|||||||
text/javascript mjs;
|
text/javascript mjs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_min_length 256;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/javascript
|
||||||
|
application/javascript
|
||||||
|
application/json
|
||||||
|
application/xml
|
||||||
|
image/svg+xml
|
||||||
|
font/woff2
|
||||||
|
application/font-woff2;
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name $DOMAIN www.$DOMAIN;
|
server_name $DOMAIN www.$DOMAIN;
|
||||||
@@ -48,6 +73,12 @@ http {
|
|||||||
http2 on;
|
http2 on;
|
||||||
server_name www.$DOMAIN;
|
server_name www.$DOMAIN;
|
||||||
|
|
||||||
|
set $upstream_backend http://$BACKEND_HOST:$BACKEND_PORT;
|
||||||
|
set $upstream_icecast http://$ICECAST_HOST:$ICECAST_PORT;
|
||||||
|
set $upstream_gitea http://$GITEA_HOST:$GITEA_PORT;
|
||||||
|
set $upstream_hasura http://$HASURA_HOST:$HASURA_PORT;
|
||||||
|
set $upstream_quartz http://$QUARTZ_HOST:$QUARTZ_PORT;
|
||||||
|
set $upstream_searxng http://$SEARXNG_HOST:$SEARXNG_PORT;
|
||||||
|
|
||||||
root /etc/nginx/html;
|
root /etc/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
@@ -55,6 +86,36 @@ http {
|
|||||||
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
|
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
|
||||||
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
|
||||||
|
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||||
|
|
||||||
|
# Vite hashed assets - immutable, cache 1 year
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fonts - cache 30 days
|
||||||
|
location /fonts/ {
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
# Images - cache 7 days
|
||||||
|
location /img/ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /uploads/ {
|
||||||
|
alias /uploads/;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header Content-Disposition "inline" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'none'; script-src 'none'" always;
|
||||||
|
}
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
@@ -70,16 +131,66 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location = /img/stamps/mine.gif {
|
location = /img/stamps/mine.gif {
|
||||||
add_header Access-Control-Allow-Origin *;
|
add_header Access-Control-Allow-Origin "https://www.$DOMAIN";
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /sound/ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
}
|
}
|
||||||
|
|
||||||
location $BACKEND_ENDPOINT {
|
location $BACKEND_ENDPOINT {
|
||||||
return 301 $BACKEND_ENDPOINT/;
|
return 301 $BACKEND_ENDPOINT/;
|
||||||
}
|
}
|
||||||
|
|
||||||
location $BACKEND_ENDPOINT/ {
|
location $BACKEND_ENDPOINT/ws {
|
||||||
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
proxy_pass $upstream_backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT/auth/login {
|
||||||
|
limit_req zone=login burst=3 nodelay;
|
||||||
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT/messages/upload {
|
||||||
|
limit_req zone=upload burst=3 nodelay;
|
||||||
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT/graphql {
|
||||||
|
limit_req zone=graphql burst=10 nodelay;
|
||||||
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT/ {
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_backend;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
@@ -91,13 +202,154 @@ http {
|
|||||||
}
|
}
|
||||||
|
|
||||||
location /radio/ {
|
location /radio/ {
|
||||||
proxy_pass http://$ICECAST_HOST:$ICECAST_PORT/;
|
rewrite ^/radio/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_icecast;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location /gitea {
|
||||||
|
return 301 /gitea/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /gitea/ {
|
||||||
|
rewrite ^/gitea/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_gitea;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /hasura {
|
||||||
|
return 301 /hasura/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /hasura/ {
|
||||||
|
auth_request /internal/auth/admin-validate;
|
||||||
|
error_page 401 403 = @auth_denied;
|
||||||
|
rewrite ^/hasura/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_hasura;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /notes {
|
||||||
|
return 301 /notes/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /notes/ {
|
||||||
|
auth_request /internal/auth/admin-validate;
|
||||||
|
error_page 401 403 = @auth_denied;
|
||||||
|
rewrite ^/notes/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_quartz;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
location = /internal/auth/admin-validate {
|
||||||
|
internal;
|
||||||
|
rewrite ^ /auth/validate-admin break;
|
||||||
|
proxy_pass $upstream_backend;
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @auth_denied {
|
||||||
|
return 302 /admin/login?redirect=$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /searxng {
|
||||||
|
return 301 /searxng/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /searxng/ {
|
||||||
|
auth_request /internal/auth/admin-validate;
|
||||||
|
error_page 401 403 = @auth_denied;
|
||||||
|
rewrite ^/searxng/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_searxng;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name chat.$DOMAIN;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://chat.$DOMAIN$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name chat.$DOMAIN;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
|
||||||
|
|
||||||
|
set $upstream_openwebui http://$OPENWEBUI_HOST:$OPENWEBUI_PORT;
|
||||||
|
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||||
|
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
location = /internal/auth/admin-validate {
|
||||||
|
internal;
|
||||||
|
rewrite ^ /auth/validate-admin break;
|
||||||
|
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT;
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @auth_denied {
|
||||||
|
return 302 https://www.$DOMAIN/admin/login?redirect=https://chat.$DOMAIN$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
auth_request /internal/auth/admin-validate;
|
||||||
|
error_page 401 403 = @auth_denied;
|
||||||
|
proxy_pass $upstream_openwebui;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
proxy_send_timeout 300s;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
378
nginx/nginx_dev.conf.template
Normal file
378
nginx/nginx_dev.conf.template
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
server_tokens off;
|
||||||
|
charset utf-8;
|
||||||
|
|
||||||
|
resolver 127.0.0.11 valid=10s;
|
||||||
|
|
||||||
|
client_max_body_size 50M;
|
||||||
|
|
||||||
|
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
|
||||||
|
|
||||||
|
log_format compact
|
||||||
|
'$remote_addr "$request" $status rt=$request_time';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log compact;
|
||||||
|
|
||||||
|
types {
|
||||||
|
text/javascript mjs;
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_min_length 256;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/javascript
|
||||||
|
application/javascript
|
||||||
|
application/json
|
||||||
|
application/xml
|
||||||
|
image/svg+xml
|
||||||
|
font/woff2
|
||||||
|
application/font-woff2;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name $DOMAIN www.$DOMAIN;
|
||||||
|
|
||||||
|
set $upstream_backend http://$BACKEND_HOST:$BACKEND_PORT;
|
||||||
|
set $upstream_icecast http://$ICECAST_HOST:$ICECAST_PORT;
|
||||||
|
set $upstream_gitea http://$GITEA_HOST:$GITEA_PORT;
|
||||||
|
set $upstream_hasura http://$HASURA_HOST:$HASURA_PORT;
|
||||||
|
set $upstream_quartz http://$QUARTZ_HOST:$QUARTZ_PORT;
|
||||||
|
set $upstream_searxng http://$SEARXNG_HOST:$SEARXNG_PORT;
|
||||||
|
|
||||||
|
location /uploads/ {
|
||||||
|
alias /uploads/;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header Content-Disposition "inline" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'none'; script-src 'none'" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://vue:5173;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT {
|
||||||
|
return 301 $BACKEND_ENDPOINT/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT/ws {
|
||||||
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT/auth/login {
|
||||||
|
limit_req zone=login burst=3 nodelay;
|
||||||
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT/messages/upload {
|
||||||
|
limit_req zone=upload burst=3 nodelay;
|
||||||
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT/ {
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /radio {
|
||||||
|
return 301 /radio/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /radio/ {
|
||||||
|
rewrite ^/radio/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_icecast;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /gitea {
|
||||||
|
return 301 /gitea/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /gitea/ {
|
||||||
|
rewrite ^/gitea/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_gitea;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /hasura {
|
||||||
|
return 301 /hasura/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /hasura/ {
|
||||||
|
auth_request /internal/auth/admin-validate;
|
||||||
|
error_page 401 403 = @auth_denied;
|
||||||
|
rewrite ^/hasura/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_hasura;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /notes {
|
||||||
|
return 301 /notes/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /notes/ {
|
||||||
|
auth_request /internal/auth/admin-validate;
|
||||||
|
error_page 401 403 = @auth_denied;
|
||||||
|
rewrite ^/notes/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_quartz;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
location = /internal/auth/admin-validate {
|
||||||
|
internal;
|
||||||
|
rewrite ^ /auth/validate-admin break;
|
||||||
|
proxy_pass $upstream_backend;
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @auth_denied {
|
||||||
|
return 302 /admin/login?redirect=$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /searxng {
|
||||||
|
return 301 /searxng/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /searxng/ {
|
||||||
|
auth_request /internal/auth/admin-validate;
|
||||||
|
error_page 401 403 = @auth_denied;
|
||||||
|
rewrite ^/searxng/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_searxng;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name $DOMAIN www.$DOMAIN;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/localhost/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/localhost/privkey.pem;
|
||||||
|
|
||||||
|
set $upstream_backend http://$BACKEND_HOST:$BACKEND_PORT;
|
||||||
|
set $upstream_icecast http://$ICECAST_HOST:$ICECAST_PORT;
|
||||||
|
set $upstream_gitea http://$GITEA_HOST:$GITEA_PORT;
|
||||||
|
set $upstream_hasura http://$HASURA_HOST:$HASURA_PORT;
|
||||||
|
set $upstream_quartz http://$QUARTZ_HOST:$QUARTZ_PORT;
|
||||||
|
set $upstream_searxng http://$SEARXNG_HOST:$SEARXNG_PORT;
|
||||||
|
|
||||||
|
location /uploads/ {
|
||||||
|
alias /uploads/;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header Content-Disposition "inline" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'none'; img-src 'self'; style-src 'none'; script-src 'none'" always;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://vue:5173;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT {
|
||||||
|
return 301 $BACKEND_ENDPOINT/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT/ws {
|
||||||
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_backend;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT/auth/login {
|
||||||
|
limit_req zone=login burst=3 nodelay;
|
||||||
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT/messages/upload {
|
||||||
|
limit_req zone=upload burst=3 nodelay;
|
||||||
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT/ {
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_backend;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /radio {
|
||||||
|
return 301 /radio/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /radio/ {
|
||||||
|
rewrite ^/radio/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_icecast;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /gitea {
|
||||||
|
return 301 /gitea/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /gitea/ {
|
||||||
|
rewrite ^/gitea/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_gitea;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /hasura {
|
||||||
|
return 301 /hasura/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /hasura/ {
|
||||||
|
auth_request /internal/auth/admin-validate;
|
||||||
|
error_page 401 403 = @auth_denied;
|
||||||
|
rewrite ^/hasura/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_hasura;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /notes {
|
||||||
|
return 301 /notes/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /notes/ {
|
||||||
|
auth_request /internal/auth/admin-validate;
|
||||||
|
error_page 401 403 = @auth_denied;
|
||||||
|
rewrite ^/notes/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_quartz;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
location = /internal/auth/admin-validate {
|
||||||
|
internal;
|
||||||
|
rewrite ^ /auth/validate-admin break;
|
||||||
|
proxy_pass $upstream_backend;
|
||||||
|
proxy_pass_request_body off;
|
||||||
|
proxy_set_header Content-Length "";
|
||||||
|
proxy_set_header Cookie $http_cookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @auth_denied {
|
||||||
|
return 302 /admin/login?redirect=$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /searxng {
|
||||||
|
return 301 /searxng/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /searxng/ {
|
||||||
|
auth_request /internal/auth/admin-validate;
|
||||||
|
error_page 401 403 = @auth_denied;
|
||||||
|
rewrite ^/searxng/(.*)$ /$1 break;
|
||||||
|
proxy_pass $upstream_searxng;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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,286 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
|
|
||||||
/* PRINTING */
|
|
||||||
@media print {
|
|
||||||
.no-print,
|
|
||||||
.no-print * {
|
|
||||||
display: none !important;
|
|
||||||
margin: 0px;
|
|
||||||
padding: 0px;
|
|
||||||
width: 0x;
|
|
||||||
height: 0px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
/* END OF PRINTING */
|
|
||||||
|
|
||||||
/* FONTS */
|
|
||||||
@font-face {
|
|
||||||
font-family: "big_noodle_titling";
|
|
||||||
src: url("/fonts/big_noodle_titling.ttf") format("truetype");
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "CreatoDisplay";
|
|
||||||
src: url("/fonts/CreatoDisplay-Bold.otf") format("opentype");
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
/* END OF FONTS */
|
|
||||||
|
|
||||||
/* VARIABLES */
|
|
||||||
:root {
|
|
||||||
/* RED, WHITE, BLACK are standard*/
|
|
||||||
--portal_grey: #dddddd;
|
|
||||||
--portal_orange: #ff9a00;
|
|
||||||
--portal_light_orange: #ff5d00;
|
|
||||||
--portal_blue: #0065ff;
|
|
||||||
--portal_light_blue: #00a2ff;
|
|
||||||
|
|
||||||
/* MAIN COLORS */
|
|
||||||
--primary: #55ffbb;
|
|
||||||
--secondary: #62ff57;
|
|
||||||
--tertiary: #ff579a;
|
|
||||||
--quaternary: #024942;
|
|
||||||
|
|
||||||
/* BACKGROUND COLORS */
|
|
||||||
--bg_primary: #1b110e;
|
|
||||||
--bg_secondary: #000;
|
|
||||||
--link: #222;
|
|
||||||
|
|
||||||
--bdr: 2px;
|
|
||||||
|
|
||||||
--spacing: 3px;
|
|
||||||
|
|
||||||
/* FONTS USED */
|
|
||||||
--font_heading: big_noodle_titling;
|
|
||||||
--font_default: CreatoDisplay;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-tertiary: var(--tertiary);
|
|
||||||
--color-quaternary: var(--quaternary);
|
|
||||||
|
|
||||||
--color-bg_primary: var(--bg_primary);
|
|
||||||
--color-bg_secondary: var(--bg_secondary);
|
|
||||||
--color-link: var(--link);
|
|
||||||
|
|
||||||
--borderWidth-primary: var(--primary);
|
|
||||||
--borderWidth-secondary: var(--secondary);
|
|
||||||
--borderWidth-tertiary: var(--tertiary);
|
|
||||||
|
|
||||||
--font-heading: var(--font_heading);
|
|
||||||
--default-font-family: var(--font_default);
|
|
||||||
}
|
|
||||||
/* END OF VARIABLES */
|
|
||||||
/* ELEMENTS */
|
|
||||||
body {
|
|
||||||
margin: 0 auto;
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
@apply overflow-y-scroll w-full h-full p-10;
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
@apply text-secondary border-primary border;
|
|
||||||
}
|
|
||||||
|
|
||||||
small {
|
|
||||||
@apply text-tertiary;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
@apply text-tertiary;
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
@apply text-tertiary;
|
|
||||||
}
|
|
||||||
|
|
||||||
li {
|
|
||||||
@apply text-tertiary;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1,
|
|
||||||
h2,
|
|
||||||
h3,
|
|
||||||
h4 {
|
|
||||||
@apply m-1 font-heading text-primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3,
|
|
||||||
h4 {
|
|
||||||
@apply text-lg;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
@apply text-2xl;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
@apply text-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
@apply text-secondary;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
@apply text-primary bg-link text-center font-heading text-xl tracking-wide;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
textarea {
|
|
||||||
@apply text-primary border;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
@apply border-primary border text-primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
@apply gap-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr {
|
|
||||||
@apply border-primary border-b text-primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
@apply pr-3 pl-3 border-r border-dotted border-tertiary;
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
@apply pr-3 pl-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* END OF ELEMENTS */
|
|
||||||
|
|
||||||
/* CLASSES */
|
|
||||||
|
|
||||||
.img-stamp {
|
|
||||||
width: 99px;
|
|
||||||
height: 55px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* BORDERS */
|
|
||||||
.bdr-1 {
|
|
||||||
@apply border-30;
|
|
||||||
border-image: url("/img/borders/border1.gif") 30 round;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bdr-1-inv {
|
|
||||||
@apply border-30;
|
|
||||||
border-image: url("/img/borders/border1inv.gif") 30 round;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bdr-2 {
|
|
||||||
@apply border-5;
|
|
||||||
border-image: url("/img/borders/border4.gif") 7 round;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bdr-cv {
|
|
||||||
@apply border-30;
|
|
||||||
border-image: url("/img/borders/bordercv.png") 30 round;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A5 Page */
|
|
||||||
.a5page-landscape {
|
|
||||||
@apply m-0 box-content;
|
|
||||||
height: 148mm;
|
|
||||||
width: 210mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.a5page-portrait {
|
|
||||||
@apply m-0 box-content;
|
|
||||||
width: 148mm;
|
|
||||||
height: 210mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* A4 Page */
|
|
||||||
.a4page-portrait {
|
|
||||||
@apply m-0 box-content;
|
|
||||||
width: 210mm;
|
|
||||||
height: 297mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.a4page-landscape {
|
|
||||||
@apply m-0 box-content;
|
|
||||||
height: 210mm;
|
|
||||||
width: 297mm;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* END OF CLASSES */
|
|
||||||
|
|
||||||
/* PHONE */
|
|
||||||
@media (max-width: 850px) {
|
|
||||||
.a4page-portrait {
|
|
||||||
width: 100%; /* fill mobile width */
|
|
||||||
height: fit-content;
|
|
||||||
margin: 0 auto; /* center horizontally */
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.a4page-landscape {
|
|
||||||
width: 100%; /* fill mobile width */
|
|
||||||
height: fit-content;
|
|
||||||
margin: 0 auto; /* center horizontally */
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.a5page-portrait {
|
|
||||||
width: 100%;
|
|
||||||
height: fit-content;
|
|
||||||
margin: 0 auto;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
.a5page-landscape {
|
|
||||||
width: 100%;
|
|
||||||
height: fit-content;
|
|
||||||
margin: 0 auto;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.tl {
|
|
||||||
@apply absolute top-0 left-0;
|
|
||||||
}
|
|
||||||
.tr {
|
|
||||||
@apply absolute top-0 right-0;
|
|
||||||
}
|
|
||||||
.bl {
|
|
||||||
@apply absolute bottom-0 left-0;
|
|
||||||
}
|
|
||||||
.br {
|
|
||||||
@apply absolute bottom-0 right-0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.background {
|
|
||||||
@apply fixed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.halftone {
|
|
||||||
--dot_size: 1px;
|
|
||||||
--bg_size: 3px;
|
|
||||||
--bg_pos: calc(var(--bg_size) / 2);
|
|
||||||
--blur: 0%;
|
|
||||||
|
|
||||||
background-color: var(--bg_secondary);
|
|
||||||
background-image: radial-gradient(
|
|
||||||
circle at center,
|
|
||||||
var(--bg_primary) var(--dot_size),
|
|
||||||
transparent var(--blur)
|
|
||||||
);
|
|
||||||
background-size: var(--bg_size) var(--bg_size);
|
|
||||||
background-position: 0 0;
|
|
||||||
|
|
||||||
mask-image: linear-gradient(
|
|
||||||
30deg,
|
|
||||||
rgba(1, 1, 1, 1) 0%,
|
|
||||||
rgba(1, 1, 1, 0.9) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
<template>
|
|
||||||
<footer></footer>
|
|
||||||
</template>
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import Headline from "@/components/text/Headline.vue";
|
|
||||||
import { computed } from "vue";
|
|
||||||
import { useRoute } from "vue-router";
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
|
|
||||||
const parentPath = computed(() => {
|
|
||||||
const segments = route.path.split("/").filter(Boolean);
|
|
||||||
if (segments.length == 1) {
|
|
||||||
return "/";
|
|
||||||
} else {
|
|
||||||
segments.pop();
|
|
||||||
return segments.length ? "/" + segments.join("/") : null;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const inHome = computed(() => {
|
|
||||||
return route.path == "/";
|
|
||||||
});
|
|
||||||
|
|
||||||
const faces = [
|
|
||||||
"^_^",
|
|
||||||
"¯\\_(ツ)_/¯",
|
|
||||||
"(◕‿◕✿)",
|
|
||||||
"ಠ_ಠ",
|
|
||||||
"ʘ‿ʘ",
|
|
||||||
"^̮^",
|
|
||||||
">_>",
|
|
||||||
"¬_¬",
|
|
||||||
"˙ ͜ʟ˙",
|
|
||||||
"( ͡° ͜ʖ ͡°)",
|
|
||||||
"[̲̅$̲̅(̲̅5̲̅)̲̅$̲̅]",
|
|
||||||
"(ง'̀-'́)ง",
|
|
||||||
"\ (•◡•) /",
|
|
||||||
"( ͡ᵔ ͜ʖ ͡ᵔ )",
|
|
||||||
"ᕙ(⇀‸↼‶)ᕗ",
|
|
||||||
"⚆ _ ⚆",
|
|
||||||
"(。◕‿◕。)",
|
|
||||||
"(╯°□°)╯︵ ʞooqǝɔɐɟ",
|
|
||||||
"̿ ̿ ̿'̿'\̵͇̿̿\з=(•_•)=ε/̵͇̿̿/'̿'̿ ̿",
|
|
||||||
"(☞゚ヮ゚)☞ ☜(゚ヮ゚☜)",
|
|
||||||
];
|
|
||||||
const faces_string = faces.join(" ");
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<nav class="flex flex-row w-full h-fit border border-primary bg-bg_primary">
|
|
||||||
<RouterLink class="bdr-2 bg-bg_primary" to="/" v-if="!inHome">
|
|
||||||
<a>HOME</a>
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
class="bdr-2 bg-bg_primary"
|
|
||||||
v-if="parentPath"
|
|
||||||
:to="parentPath"
|
|
||||||
>
|
|
||||||
<a>UP</a>
|
|
||||||
</RouterLink>
|
|
||||||
<Headline class="border flex-1">
|
|
||||||
<code class="whitespace-pre">{{ faces_string }}</code>
|
|
||||||
</Headline>
|
|
||||||
</nav>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.left {
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div v-if="streamLive">
|
|
||||||
<img src="/img/tmpen31z3pe.PNG" />
|
|
||||||
<audio controls :src="streamUrl" ref="audio"></audio>
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<img src="/img/tmpen31z3pe.PNG" />
|
|
||||||
<div class="m-1">
|
|
||||||
<p>Stream is offline. Tune in Fridays @ 6:00pm, Monday @ 8:00am</p>
|
|
||||||
<Button @click="checkStream()">Check Stream</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import Button from "@/components/input/Button.vue";
|
|
||||||
import { ref, onMounted } from "vue";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
const streamMount = ref("");
|
|
||||||
const streamUrl = ref("");
|
|
||||||
const streamLive = ref(false);
|
|
||||||
const audio = ref(null);
|
|
||||||
|
|
||||||
async function checkStream() {
|
|
||||||
try {
|
|
||||||
const res = await axios.get("/radio/status-json.xsl");
|
|
||||||
const data = res.data;
|
|
||||||
|
|
||||||
streamMount.value = data.icestats.source.listenurl.split("/").pop();
|
|
||||||
if (streamMount.value) {
|
|
||||||
streamLive.value = true;
|
|
||||||
streamUrl.value = "/radio/" + streamMount.value;
|
|
||||||
|
|
||||||
if (audio.value) audio.value.load();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
streamLive.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
checkStream();
|
|
||||||
setInterval(checkStream, 120000);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -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,36 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
import Button from "@/components/input/Button.vue";
|
|
||||||
import { ref } from "vue";
|
|
||||||
import axios from "axios";
|
|
||||||
|
|
||||||
const title = ref("");
|
|
||||||
const content = ref("");
|
|
||||||
|
|
||||||
async function post() {
|
|
||||||
try {
|
|
||||||
const res = await axios.post("/api/posts", {
|
|
||||||
title: title.value,
|
|
||||||
content: content.value,
|
|
||||||
});
|
|
||||||
title.value = "";
|
|
||||||
content.value = "";
|
|
||||||
console.log(res.data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<h1>Create Post</h1>
|
|
||||||
<input type="text" v-model="title" placeholder="Title" />
|
|
||||||
<textarea
|
|
||||||
class="h-50"
|
|
||||||
v-model="content"
|
|
||||||
placeholder="Content"
|
|
||||||
></textarea>
|
|
||||||
<Button @click="post">Upload</Button>
|
|
||||||
<!-- make textarea take up most the space -->
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user