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
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
}

View File

@@ -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,
)
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
})
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) {

View File

@@ -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

View File

@@ -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:

View File

@@ -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/*

View File

@@ -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";
}

17
vue/package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

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