Fix security vulnerabilities across backend, frontend, and infra
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m44s

- Fix auth bypass in UpdatePost/DeletePost (missing return after auth check)
- Remove Spotify access token from callback response
- Replace internal error messages with generic responses in all handlers
- Harden GraphQL: complexity limit, disable playground/introspection in prod
- Add security headers (X-Frame-Options, HSTS, etc.) to nginx
- Disable Hasura console/dev mode in production
- Add DOMPurify sanitization to Markdown component
- Fix cookie removal to use correct domain/path from auth config
- Fix nil dereference in rowing handler when Claude API errors
- Fix wildcard CORS on stamp endpoint
- Pin nginx and certbot Docker image versions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 23:59:10 +01:00
parent 091bfcaef6
commit 75cede3b1b
14 changed files with 141 additions and 76 deletions

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"log"
"net/http" "net/http"
"adam-french.co.uk/backend/models" "adam-french.co.uk/backend/models"
@@ -16,7 +17,8 @@ type CreateActivityInput struct {
func (store *Store) GetActivity(ctx *gin.Context) { func (store *Store) GetActivity(ctx *gin.Context) {
var activitys []models.Activity var activitys []models.Activity
if err := store.DB.Order("Created_At DESC").Find(&activitys).Error; err != nil { if err := store.DB.Order("Created_At DESC").Find(&activitys).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, err.Error()) log.Println(err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return return
} }
ctx.JSON(http.StatusOK, activitys) ctx.JSON(http.StatusOK, activitys)
@@ -25,14 +27,15 @@ func (store *Store) GetActivity(ctx *gin.Context) {
func (store *Store) CreateActivity(ctx *gin.Context) { func (store *Store) CreateActivity(ctx *gin.Context) {
var input CreateActivityInput var input CreateActivityInput
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, err.Error()) ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return return
} }
activity := models.Activity{Type: input.Type, Name: input.Name, Link: input.Link} activity := models.Activity{Type: input.Type, Name: input.Name, Link: input.Link}
tx := store.DB.Create(&activity) tx := store.DB.Create(&activity)
if tx.Error != nil { if tx.Error != nil {
ctx.JSON(http.StatusInternalServerError, tx.Error.Error()) log.Println(tx.Error)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return return
} }

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"log"
"net/http" "net/http"
"adam-french.co.uk/backend/models" "adam-french.co.uk/backend/models"
@@ -12,13 +13,13 @@ import (
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
} }
@@ -58,13 +59,13 @@ func (store *Store) CheckToken(ctx *gin.Context) {
claims, err := store.Auth.VerifyJWT(access_token) claims, err := store.Auth.VerifyJWT(access_token)
if err != nil { if err != nil {
ctx.JSON(401, err.Error()) ctx.JSON(401, gin.H{"error": "unauthorized"})
return return
} }
userIDF, ok := (*claims)["id"].(float64) userIDF, ok := (*claims)["id"].(float64)
if !ok { if !ok {
ctx.JSON(401, gin.H{"error": "claims does not contain id"}) ctx.JSON(401, gin.H{"error": "unauthorized"})
return return
} }
userID := uint(userIDF) userID := uint(userIDF)
@@ -72,8 +73,9 @@ func (store *Store) CheckToken(ctx *gin.Context) {
user := models.User{ID: userID} user := models.User{ID: userID}
tx := store.DB.First(&user) tx := store.DB.First(&user)
if tx.Error != nil { if tx.Error != nil {
ctx.JSON(http.StatusNotFound, tx.Error.Error()) log.Println(tx.Error)
removeCookies(ctx) ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
store.removeCookies(ctx)
return return
} }
@@ -83,13 +85,13 @@ 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 return
} }
@@ -103,14 +105,16 @@ 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
} }
@@ -138,7 +142,7 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
func (store *Store) Login(ctx *gin.Context) { func (store *Store) Login(ctx *gin.Context) {
var input UserCredentials var input UserCredentials
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, err.Error()) ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return return
} }
@@ -155,7 +159,8 @@ func (store *Store) Login(ctx *gin.Context) {
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
} }
@@ -181,27 +186,27 @@ func (store *Store) Login(ctx *gin.Context) {
} }
func (store *Store) Logout(ctx *gin.Context) { func (store *Store) Logout(ctx *gin.Context) {
removeCookies(ctx) store.removeCookies(ctx)
ctx.Status(http.StatusOK) ctx.Status(http.StatusOK)
} }
func removeCookies(ctx *gin.Context) { func (store *Store) removeCookies(ctx *gin.Context) {
ctx.SetSameSite(http.SameSiteLaxMode) ctx.SetSameSite(http.SameSiteLaxMode)
ctx.SetCookie( ctx.SetCookie(
"access_token", "access_token",
"", "",
-1, -1,
"", store.Auth.Config.Endpoint,
"", store.Auth.Config.Domain,
true, true, true, true,
) )
ctx.SetCookie( ctx.SetCookie(
"refresh_token", "refresh_token",
"", "",
-1, -1,
"", store.Auth.Config.Endpoint,
"", store.Auth.Config.Domain,
true, true, true, true,
) )
} }

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"log"
"net/http" "net/http"
"adam-french.co.uk/backend/models" "adam-french.co.uk/backend/models"
@@ -16,7 +17,8 @@ type CreateFavoriteInput struct {
func (store *Store) GetFavorites(ctx *gin.Context) { func (store *Store) GetFavorites(ctx *gin.Context) {
var favorites []models.Favorite var favorites []models.Favorite
if err := store.DB.Order("Created_At DESC").Find(&favorites).Error; err != nil { if err := store.DB.Order("Created_At DESC").Find(&favorites).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, err.Error()) log.Println(err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return return
} }
ctx.JSON(http.StatusOK, favorites) ctx.JSON(http.StatusOK, favorites)
@@ -25,14 +27,15 @@ func (store *Store) GetFavorites(ctx *gin.Context) {
func (store *Store) CreateFavorite(ctx *gin.Context) { func (store *Store) CreateFavorite(ctx *gin.Context) {
var input CreateFavoriteInput var input CreateFavoriteInput
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, err.Error()) ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return return
} }
favorite := models.Favorite{Type: input.Type, Name: input.Name, Link: input.Link} favorite := models.Favorite{Type: input.Type, Name: input.Name, Link: input.Link}
tx := store.DB.Create(&favorite) tx := store.DB.Create(&favorite)
if tx.Error != nil { if tx.Error != nil {
ctx.JSON(http.StatusInternalServerError, tx.Error.Error()) log.Println(tx.Error)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return return
} }

View File

@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"log"
"net/http" "net/http"
"strconv" "strconv"
@@ -17,7 +18,8 @@ type CreatePostInput struct {
func (store *Store) GetPosts(ctx *gin.Context) { func (store *Store) GetPosts(ctx *gin.Context) {
var posts []models.Post var posts []models.Post
if err := store.DB.Preload("Author").Order("Created_At DESC").Find(&posts).Error; err != nil { if err := store.DB.Preload("Author").Order("Created_At DESC").Find(&posts).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, err.Error()) log.Println(err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return return
} }
ctx.JSON(http.StatusOK, posts) ctx.JSON(http.StatusOK, posts)
@@ -34,7 +36,8 @@ func (store *Store) GetPost(ctx *gin.Context) {
post := models.Post{ID: uint(postID)} post := models.Post{ID: uint(postID)}
if err := store.DB.First(&post).Error; err != nil { if err := store.DB.First(&post).Error; err != nil {
ctx.JSON(http.StatusNotFound, err.Error()) log.Println(err)
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return return
} }
@@ -44,25 +47,25 @@ func (store *Store) GetPost(ctx *gin.Context) {
func (store *Store) CreatePost(ctx *gin.Context) { func (store *Store) CreatePost(ctx *gin.Context) {
var input CreatePostInput var input CreatePostInput
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, err.Error()) ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return return
} }
claimsVal, ok := ctx.Get("userClaims") claimsVal, ok := ctx.Get("userClaims")
if !ok { if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"}) ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return return
} }
claims, ok := claimsVal.(*jwt.MapClaims) claims, ok := claimsVal.(*jwt.MapClaims)
if !ok { if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"}) ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return return
} }
userIDF, ok := (*claims)["id"].(float64) userIDF, ok := (*claims)["id"].(float64)
if !ok { if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"}) ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return return
} }
userID := uint(userIDF) userID := uint(userIDF)
@@ -71,7 +74,8 @@ func (store *Store) CreatePost(ctx *gin.Context) {
post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID} post := models.Post{Title: input.Title, Content: input.Content, AuthorID: userID}
tx := store.DB.Create(&post) tx := store.DB.Create(&post)
if tx.Error != nil { if tx.Error != nil {
ctx.JSON(http.StatusInternalServerError, tx.Error.Error()) log.Println(tx.Error)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return return
} }
@@ -82,36 +86,38 @@ func (store *Store) UpdatePost(ctx *gin.Context) {
postID := ctx.Param("id") postID := ctx.Param("id")
var post models.Post var post models.Post
if err := store.DB.First(&post, postID).Error; err != nil { if err := store.DB.First(&post, postID).Error; err != nil {
ctx.JSON(http.StatusNotFound, err.Error()) log.Println(err)
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return return
} }
claimsVal, ok := ctx.Get("userClaims") claimsVal, ok := ctx.Get("userClaims")
if !ok { if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"}) ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return return
} }
claims, ok := claimsVal.(*jwt.MapClaims) claims, ok := claimsVal.(*jwt.MapClaims)
if !ok { if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"}) ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return return
} }
userIDF, ok := (*claims)["id"].(float64) userIDF, ok := (*claims)["id"].(float64)
if !ok { if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"}) ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return return
} }
userID := uint(userIDF) userID := uint(userIDF)
if !(userID == post.AuthorID) { if !(userID == post.AuthorID) {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user and post author id missmatch"}) ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
} }
var input CreatePostInput var input CreatePostInput
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil { if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
ctx.JSON(http.StatusBadRequest, err.Error()) ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return return
} }
@@ -119,7 +125,8 @@ func (store *Store) UpdatePost(ctx *gin.Context) {
post.Content = input.Content post.Content = input.Content
tx := store.DB.Save(&post) tx := store.DB.Save(&post)
if tx.Error != nil { if tx.Error != nil {
ctx.JSON(http.StatusInternalServerError, tx.Error.Error()) log.Println(tx.Error)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return return
} }
@@ -130,31 +137,33 @@ func (store *Store) DeletePost(ctx *gin.Context) {
postID := ctx.Param("id") postID := ctx.Param("id")
var post models.Post var post models.Post
if err := store.DB.First(&post, postID).Error; err != nil { if err := store.DB.First(&post, postID).Error; err != nil {
ctx.JSON(http.StatusNotFound, err.Error()) log.Println(err)
ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return return
} }
claimsVal, ok := ctx.Get("userClaims") claimsVal, ok := ctx.Get("userClaims")
if !ok { if !ok {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"}) ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return return
} }
claims, ok := claimsVal.(*jwt.MapClaims) claims, ok := claimsVal.(*jwt.MapClaims)
if !ok { if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"}) ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return return
} }
userIDF, ok := (*claims)["id"].(float64) userIDF, ok := (*claims)["id"].(float64)
if !ok { if !ok {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"}) ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return return
} }
userID := uint(userIDF) userID := uint(userIDF)
if !(userID == post.AuthorID) { if !(userID == post.AuthorID) {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user and post author id missmatch"}) ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
} }
store.DB.Delete(&post) store.DB.Delete(&post)

View File

@@ -5,6 +5,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"io" "io"
"log"
"net/http" "net/http"
"strings" "strings"
@@ -24,7 +25,8 @@ type ExtractedRowingData struct {
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)
@@ -118,18 +120,13 @@ No text, no markdown, no explanation. Just the JSON object.`),
}, },
}) })
if err != nil { if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{ log.Println(err)
"raw": message.Content[0].Text, ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process image"})
"error": err.Error(),
})
return return
} }
if len(message.Content) == 0 { if len(message.Content) == 0 {
ctx.JSON(http.StatusInternalServerError, gin.H{ ctx.JSON(http.StatusInternalServerError, gin.H{"error": "empty response from image processor"})
"raw": message.Content[0].Text,
"error": "empty response from Claude",
})
return return
} }
@@ -144,11 +141,8 @@ No text, no markdown, no explanation. Just the JSON object.`),
err = json.Unmarshal([]byte(raw), &extractedData) err = json.Unmarshal([]byte(raw), &extractedData)
if err != nil { if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{ log.Println(err)
"error": "failed to parse JSON response", ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse image data"})
"detail": err.Error(),
"raw": raw,
})
return return
} }

View File

@@ -2,6 +2,7 @@ package handlers
import ( import (
"context" "context"
"log"
"net/http" "net/http"
"time" "time"
@@ -17,12 +18,14 @@ 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
} }
if err := services.SaveSpotifyToken(services.SPOTIFY_TOKEN_JSON_PATH, token); err != nil { if err := services.SaveSpotifyToken(services.SPOTIFY_TOKEN_JSON_PATH, token); err != nil {
ctx.String(http.StatusInternalServerError, "Failed to save token: %v", err) log.Println(err)
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return return
} }
@@ -32,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,
}) })
} }
@@ -48,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
} }
@@ -70,7 +71,8 @@ func (store *Store) RecentlyPlayed(ctx *gin.Context) {
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
} }

View File

@@ -8,8 +8,12 @@ import (
"time" "time"
"github.com/99designs/gqlgen/graphql/handler" "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/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/graph"
"adam-french.co.uk/backend/handlers" "adam-french.co.uk/backend/handlers"
@@ -129,15 +133,27 @@ func main() {
r.GET("/notes/*path", store.GetNoteFile) r.GET("/notes/*path", store.GetNoteFile)
// GRAPHQL // GRAPHQL
gqlSrv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{ gqlSrv := handler.New(graph.NewExecutableSchema(graph.Config{
Resolvers: &graph.Resolver{Store: &store}, Resolvers: &graph.Resolver{Store: &store},
})) }))
gqlSrv.AddTransport(transport.Websocket{KeepAlivePingInterval: 10 * time.Second})
gqlSrv.AddTransport(transport.Options{})
gqlSrv.AddTransport(transport.GET{})
gqlSrv.AddTransport(transport.POST{})
gqlSrv.AddTransport(transport.MultipartForm{})
gqlSrv.SetQueryCache(lru.New[*ast.QueryDocument](1000))
gqlSrv.Use(extension.FixedComplexityLimit(200))
if os.Getenv("GQL_INTROSPECTION") == "true" {
gqlSrv.Use(extension.Introspection{})
}
r.POST("/graphql", graph.AuthContextMiddleware(auth), func(c *gin.Context) { r.POST("/graphql", graph.AuthContextMiddleware(auth), func(c *gin.Context) {
gqlSrv.ServeHTTP(c.Writer, c.Request) gqlSrv.ServeHTTP(c.Writer, c.Request)
}) })
r.GET("/graphql", func(c *gin.Context) { if os.Getenv("GQL_PLAYGROUND") == "true" {
playground.Handler("GraphQL Playground", "/graphql").ServeHTTP(c.Writer, c.Request) 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) {

View File

@@ -9,6 +9,8 @@ services:
backend: backend:
environment: environment:
- SPOTIFY_REDIRECT_URI=https://localhost/api/spotify/callback - SPOTIFY_REDIRECT_URI=https://localhost/api/spotify/callback
- GQL_PLAYGROUND=true
- GQL_INTROSPECTION=true
nginx: nginx:
environment: environment:
- DEV_MODE=true - DEV_MODE=true
@@ -16,6 +18,10 @@ services:
ports: ports:
- 80:80 - 80:80
- 443:443 - 443:443
hasura:
environment:
HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
HASURA_GRAPHQL_DEV_MODE: "true"
certbot: certbot:
profiles: profiles:
- disabled - disabled

View File

@@ -43,7 +43,7 @@ services:
- vue_dist:/etc/nginx/html - 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
@@ -95,8 +95,8 @@ services:
environment: environment:
HASURA_GRAPHQL_DATABASE_URL: "postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}" 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_ADMIN_SECRET: "${HASURA_GRAPHQL_ADMIN_SECRET}"
HASURA_GRAPHQL_ENABLE_CONSOLE: "true" HASURA_GRAPHQL_ENABLE_CONSOLE: "false"
HASURA_GRAPHQL_DEV_MODE: "true" HASURA_GRAPHQL_DEV_MODE: "false"
HASURA_GRAPHQL_ENABLED_LOG_TYPES: "startup, http-log, webhook-log, websocket-log, query-log" HASURA_GRAPHQL_ENABLED_LOG_TYPES: "startup, http-log, webhook-log, websocket-log, query-log"
icecast2: icecast2:

View File

@@ -1,4 +1,4 @@
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 && \ apt-get update && apt-get install -y gettext-base openssl && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*

View File

@@ -77,6 +77,11 @@ 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 # Vite hashed assets - immutable, cache 1 year
location /assets/ { location /assets/ {
expires 1y; expires 1y;
@@ -117,7 +122,7 @@ 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; expires 7d;
add_header Cache-Control "public"; add_header Cache-Control "public";
} }

17
vue/package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
"axios": "^1.13.2", "axios": "^1.13.2",
"dompurify": "^3.3.3",
"katex": "^0.16.27", "katex": "^0.16.27",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"markdown-it-wikilinks": "^1.4.0", "markdown-it-wikilinks": "^1.4.0",
@@ -1633,6 +1634,13 @@
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@types/web-bluetooth": { "node_modules/@types/web-bluetooth": {
"version": "0.0.21", "version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
@@ -2226,6 +2234,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

@@ -16,6 +16,7 @@
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@vueuse/core": "^14.2.1", "@vueuse/core": "^14.2.1",
"axios": "^1.13.2", "axios": "^1.13.2",
"dompurify": "^3.3.3",
"katex": "^0.16.27", "katex": "^0.16.27",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"markdown-it-wikilinks": "^1.4.0", "markdown-it-wikilinks": "^1.4.0",

View File

@@ -1,18 +1,22 @@
<script setup> <script setup>
import MarkdownIt from "markdown-it"; import MarkdownIt from "markdown-it";
import { katex } from "@mdit/plugin-katex"; import { katex } from "@mdit/plugin-katex";
import DOMPurify from "dompurify";
const mdIt = MarkdownIt().use(katex); const mdIt = MarkdownIt().use(katex);
//.use(wiki);
const props = defineProps({ const props = defineProps({
source: String, source: String,
}); });
function renderMarkdown(source) {
return DOMPurify.sanitize(mdIt.render(source));
}
</script> </script>
<template> <template>
<div <div
v-html="mdIt.render(props.source)" v-html="renderMarkdown(props.source)"
class="flex flex-col items-center" class="flex flex-col items-center"
></div> ></div>
</template> </template>