diff --git a/backend/handlers/handle_activity.go b/backend/handlers/handle_activity.go
index 0877838..dd0a742 100644
--- a/backend/handlers/handle_activity.go
+++ b/backend/handlers/handle_activity.go
@@ -1,6 +1,7 @@
package handlers
import (
+ "log"
"net/http"
"adam-french.co.uk/backend/models"
@@ -16,7 +17,8 @@ type CreateActivityInput struct {
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())
+ log.Println(err)
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
ctx.JSON(http.StatusOK, activitys)
@@ -25,14 +27,15 @@ func (store *Store) GetActivity(ctx *gin.Context) {
func (store *Store) CreateActivity(ctx *gin.Context) {
var input CreateActivityInput
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
- ctx.JSON(http.StatusBadRequest, err.Error())
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
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())
+ log.Println(tx.Error)
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
diff --git a/backend/handlers/handle_auth.go b/backend/handlers/handle_auth.go
index cd729a7..16b4c22 100644
--- a/backend/handlers/handle_auth.go
+++ b/backend/handlers/handle_auth.go
@@ -1,6 +1,7 @@
package handlers
import (
+ "log"
"net/http"
"adam-french.co.uk/backend/models"
@@ -12,13 +13,13 @@ import (
func (store *Store) AuthMiddlewear(ctx *gin.Context) {
access_token, err := ctx.Cookie("access_token")
if err != nil {
- ctx.AbortWithStatusJSON(401, err.Error())
+ ctx.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
return
}
claims, err := store.Auth.VerifyJWT(access_token)
if err != nil {
- ctx.AbortWithStatusJSON(401, err.Error())
+ ctx.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
return
}
@@ -58,13 +59,13 @@ func (store *Store) CheckToken(ctx *gin.Context) {
claims, err := store.Auth.VerifyJWT(access_token)
if err != nil {
- ctx.JSON(401, err.Error())
+ ctx.JSON(401, gin.H{"error": "unauthorized"})
return
}
userIDF, ok := (*claims)["id"].(float64)
if !ok {
- ctx.JSON(401, gin.H{"error": "claims does not contain id"})
+ ctx.JSON(401, gin.H{"error": "unauthorized"})
return
}
userID := uint(userIDF)
@@ -72,8 +73,9 @@ func (store *Store) CheckToken(ctx *gin.Context) {
user := models.User{ID: userID}
tx := store.DB.First(&user)
if tx.Error != nil {
- ctx.JSON(http.StatusNotFound, tx.Error.Error())
- removeCookies(ctx)
+ log.Println(tx.Error)
+ ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ store.removeCookies(ctx)
return
}
@@ -83,13 +85,13 @@ func (store *Store) CheckToken(ctx *gin.Context) {
func (store *Store) RefreshToken(ctx *gin.Context) {
refreshToken, err := ctx.Cookie("refresh_token")
if err != nil {
- ctx.JSON(http.StatusUnauthorized, err.Error())
+ ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
claims, err := store.Auth.VerifyJWT(refreshToken)
if err != nil {
- ctx.JSON(http.StatusUnauthorized, err.Error())
+ ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
@@ -103,14 +105,16 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
user := models.User{ID: userID}
tx := store.DB.First(&user)
if tx.Error != nil {
- ctx.JSON(http.StatusNotFound, tx.Error.Error())
- removeCookies(ctx)
+ log.Println(tx.Error)
+ ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
+ store.removeCookies(ctx)
return
}
tokens, err := store.Auth.GenerateJWT(&user)
if err != nil {
- ctx.JSON(http.StatusInternalServerError, err.Error())
+ log.Println(err)
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
@@ -138,7 +142,7 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
func (store *Store) Login(ctx *gin.Context) {
var input UserCredentials
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
- ctx.JSON(http.StatusBadRequest, err.Error())
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
@@ -155,7 +159,8 @@ func (store *Store) Login(ctx *gin.Context) {
tokens, err := store.Auth.GenerateJWT(&user)
if err != nil {
- ctx.JSON(http.StatusInternalServerError, err.Error())
+ log.Println(err)
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
@@ -181,27 +186,27 @@ func (store *Store) Login(ctx *gin.Context) {
}
func (store *Store) Logout(ctx *gin.Context) {
- removeCookies(ctx)
+ store.removeCookies(ctx)
ctx.Status(http.StatusOK)
}
-func removeCookies(ctx *gin.Context) {
+func (store *Store) removeCookies(ctx *gin.Context) {
ctx.SetSameSite(http.SameSiteLaxMode)
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,
)
}
diff --git a/backend/handlers/handle_favorites.go b/backend/handlers/handle_favorites.go
index b70dceb..c4b77ab 100644
--- a/backend/handlers/handle_favorites.go
+++ b/backend/handlers/handle_favorites.go
@@ -1,6 +1,7 @@
package handlers
import (
+ "log"
"net/http"
"adam-french.co.uk/backend/models"
@@ -16,7 +17,8 @@ type CreateFavoriteInput struct {
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())
+ log.Println(err)
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
ctx.JSON(http.StatusOK, favorites)
@@ -25,14 +27,15 @@ func (store *Store) GetFavorites(ctx *gin.Context) {
func (store *Store) CreateFavorite(ctx *gin.Context) {
var input CreateFavoriteInput
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
- ctx.JSON(http.StatusBadRequest, err.Error())
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
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())
+ log.Println(tx.Error)
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
diff --git a/backend/handlers/handle_post.go b/backend/handlers/handle_post.go
index d660400..e6489d3 100644
--- a/backend/handlers/handle_post.go
+++ b/backend/handlers/handle_post.go
@@ -1,6 +1,7 @@
package handlers
import (
+ "log"
"net/http"
"strconv"
@@ -17,7 +18,8 @@ type CreatePostInput struct {
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())
+ log.Println(err)
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
ctx.JSON(http.StatusOK, posts)
@@ -34,7 +36,8 @@ func (store *Store) GetPost(ctx *gin.Context) {
post := models.Post{ID: uint(postID)}
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
}
@@ -44,25 +47,25 @@ func (store *Store) GetPost(ctx *gin.Context) {
func (store *Store) CreatePost(ctx *gin.Context) {
var input CreatePostInput
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
- ctx.JSON(http.StatusBadRequest, err.Error())
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
claimsVal, ok := ctx.Get("userClaims")
if !ok {
- ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"})
+ ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
claims, ok := claimsVal.(*jwt.MapClaims)
if !ok {
- ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
userIDF, ok := (*claims)["id"].(float64)
if !ok {
- ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"})
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
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}
tx := store.DB.Create(&post)
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
}
@@ -82,36 +86,38 @@ 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())
+ log.Println(err)
+ ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
claimsVal, ok := ctx.Get("userClaims")
if !ok {
- ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"})
+ ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
claims, ok := claimsVal.(*jwt.MapClaims)
if !ok {
- ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
userIDF, ok := (*claims)["id"].(float64)
if !ok {
- ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"})
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
userID := uint(userIDF)
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
if err := ctx.ShouldBindBodyWithJSON(&input); err != nil {
- ctx.JSON(http.StatusBadRequest, err.Error())
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
@@ -119,7 +125,8 @@ func (store *Store) UpdatePost(ctx *gin.Context) {
post.Content = input.Content
tx := store.DB.Save(&post)
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
}
@@ -130,31 +137,33 @@ 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())
+ log.Println(err)
+ ctx.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
claimsVal, ok := ctx.Get("userClaims")
if !ok {
- ctx.JSON(http.StatusUnauthorized, gin.H{"error": "user claims could not be found"})
+ ctx.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
claims, ok := claimsVal.(*jwt.MapClaims)
if !ok {
- ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid claims"})
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
userIDF, ok := (*claims)["id"].(float64)
if !ok {
- ctx.JSON(http.StatusInternalServerError, gin.H{"error": "invalid user id in claims"})
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": "internal error"})
return
}
userID := uint(userIDF)
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)
diff --git a/backend/handlers/handle_rowing.go b/backend/handlers/handle_rowing.go
index fcd9c89..1091572 100644
--- a/backend/handlers/handle_rowing.go
+++ b/backend/handlers/handle_rowing.go
@@ -5,6 +5,7 @@ import (
"encoding/base64"
"encoding/json"
"io"
+ "log"
"net/http"
"strings"
@@ -24,7 +25,8 @@ type ExtractedRowingData struct {
func (store *Store) GetRowing(ctx *gin.Context) {
var rowing []models.Rowing
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
}
ctx.JSON(http.StatusOK, rowing)
@@ -118,18 +120,13 @@ No text, no markdown, no explanation. Just the JSON object.`),
},
})
if err != nil {
- ctx.JSON(http.StatusInternalServerError, gin.H{
- "raw": message.Content[0].Text,
- "error": err.Error(),
- })
+ log.Println(err)
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to process image"})
return
}
if len(message.Content) == 0 {
- ctx.JSON(http.StatusInternalServerError, gin.H{
- "raw": message.Content[0].Text,
- "error": "empty response from Claude",
- })
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": "empty response from image processor"})
return
}
@@ -144,11 +141,8 @@ No text, no markdown, no explanation. Just the JSON object.`),
err = json.Unmarshal([]byte(raw), &extractedData)
if err != nil {
- ctx.JSON(http.StatusInternalServerError, gin.H{
- "error": "failed to parse JSON response",
- "detail": err.Error(),
- "raw": raw,
- })
+ log.Println(err)
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse image data"})
return
}
diff --git a/backend/handlers/handle_spotify.go b/backend/handlers/handle_spotify.go
index 7217b06..e36987e 100644
--- a/backend/handlers/handle_spotify.go
+++ b/backend/handlers/handle_spotify.go
@@ -2,6 +2,7 @@ package handlers
import (
"context"
+ "log"
"net/http"
"time"
@@ -17,12 +18,14 @@ func (store *Store) CompleteSpotifyAuth(ctx *gin.Context) {
token, err := store.SpotifyAuth.Token(c, state, ctx.Request)
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
}
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
}
@@ -32,9 +35,6 @@ func (store *Store) CompleteSpotifyAuth(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{
"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)
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
}
@@ -70,7 +71,8 @@ func (store *Store) RecentlyPlayed(ctx *gin.Context) {
played, err := store.SpotifyClient.PlayerRecentlyPlayedOpt(ctx, &opts)
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
}
diff --git a/backend/main.go b/backend/main.go
index 496fe19..55db1cd 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -8,8 +8,12 @@ import (
"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/vektah/gqlparser/v2/ast"
"adam-french.co.uk/backend/graph"
"adam-french.co.uk/backend/handlers"
@@ -129,15 +133,27 @@ func main() {
r.GET("/notes/*path", store.GetNoteFile)
// GRAPHQL
- gqlSrv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{
+ gqlSrv := handler.New(graph.NewExecutableSchema(graph.Config{
Resolvers: &graph.Resolver{Store: &store},
}))
+ gqlSrv.AddTransport(transport.Websocket{KeepAlivePingInterval: 10 * time.Second})
+ gqlSrv.AddTransport(transport.Options{})
+ gqlSrv.AddTransport(transport.GET{})
+ gqlSrv.AddTransport(transport.POST{})
+ gqlSrv.AddTransport(transport.MultipartForm{})
+ gqlSrv.SetQueryCache(lru.New[*ast.QueryDocument](1000))
+ gqlSrv.Use(extension.FixedComplexityLimit(200))
+ if os.Getenv("GQL_INTROSPECTION") == "true" {
+ gqlSrv.Use(extension.Introspection{})
+ }
r.POST("/graphql", graph.AuthContextMiddleware(auth), func(c *gin.Context) {
gqlSrv.ServeHTTP(c.Writer, c.Request)
})
- r.GET("/graphql", func(c *gin.Context) {
- playground.Handler("GraphQL Playground", "/graphql").ServeHTTP(c.Writer, c.Request)
- })
+ if os.Getenv("GQL_PLAYGROUND") == "true" {
+ r.GET("/graphql", func(c *gin.Context) {
+ playground.Handler("GraphQL Playground", "/graphql").ServeHTTP(c.Writer, c.Request)
+ })
+ }
// HELLO WORLD
r.GET("/", func(c *gin.Context) {
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index c6b03f6..a423813 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -9,6 +9,8 @@ services:
backend:
environment:
- SPOTIFY_REDIRECT_URI=https://localhost/api/spotify/callback
+ - GQL_PLAYGROUND=true
+ - GQL_INTROSPECTION=true
nginx:
environment:
- DEV_MODE=true
@@ -16,6 +18,10 @@ services:
ports:
- 80:80
- 443:443
+ hasura:
+ environment:
+ HASURA_GRAPHQL_ENABLE_CONSOLE: "true"
+ HASURA_GRAPHQL_DEV_MODE: "true"
certbot:
profiles:
- disabled
diff --git a/docker-compose.yml b/docker-compose.yml
index b7b13dc..e541bce 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -43,7 +43,7 @@ services:
- vue_dist:/etc/nginx/html
certbot:
- image: certbot/certbot
+ image: certbot/certbot:v3.1.0
container_name: certbot
volumes:
- ./certbot/entrypoint.sh:/entrypoint.sh
@@ -95,8 +95,8 @@ services:
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: "true"
- HASURA_GRAPHQL_DEV_MODE: "true"
+ 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:
diff --git a/nginx/Dockerfile b/nginx/Dockerfile
index 747f14c..cbdee25 100644
--- a/nginx/Dockerfile
+++ b/nginx/Dockerfile
@@ -1,4 +1,4 @@
-FROM nginx:latest
+FROM nginx:1.27
RUN rm -rf /etc/nginx/html/* && \
apt-get update && apt-get install -y gettext-base openssl && \
rm -rf /var/lib/apt/lists/*
diff --git a/nginx/nginx.conf.template b/nginx/nginx.conf.template
index f04be15..ec093b9 100644
--- a/nginx/nginx.conf.template
+++ b/nginx/nginx.conf.template
@@ -77,6 +77,11 @@ http {
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.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;
@@ -117,7 +122,7 @@ http {
}
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";
}
diff --git a/vue/package-lock.json b/vue/package-lock.json
index 2ca50d2..e7a79d6 100644
--- a/vue/package-lock.json
+++ b/vue/package-lock.json
@@ -12,6 +12,7 @@
"@tailwindcss/vite": "^4.1.18",
"@vueuse/core": "^14.2.1",
"axios": "^1.13.2",
+ "dompurify": "^3.3.3",
"katex": "^0.16.27",
"markdown-it": "^14.1.0",
"markdown-it-wikilinks": "^1.4.0",
@@ -1633,6 +1634,13 @@
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"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": {
"version": "0.0.21",
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz",
@@ -2226,6 +2234,15 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
diff --git a/vue/package.json b/vue/package.json
index 71bf0d4..7699a2d 100644
--- a/vue/package.json
+++ b/vue/package.json
@@ -16,6 +16,7 @@
"@tailwindcss/vite": "^4.1.18",
"@vueuse/core": "^14.2.1",
"axios": "^1.13.2",
+ "dompurify": "^3.3.3",
"katex": "^0.16.27",
"markdown-it": "^14.1.0",
"markdown-it-wikilinks": "^1.4.0",
diff --git a/vue/src/components/util/Markdown.vue b/vue/src/components/util/Markdown.vue
index 8992724..8a740d3 100644
--- a/vue/src/components/util/Markdown.vue
+++ b/vue/src/components/util/Markdown.vue
@@ -1,18 +1,22 @@