Get AI to fix vunerabilities in site
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"adam-french.co.uk/backend/models"
|
"adam-french.co.uk/backend/models"
|
||||||
@@ -68,10 +67,9 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
|
|||||||
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, err.Error())
|
||||||
|
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"})
|
||||||
@@ -93,6 +91,7 @@ func (store *Store) RefreshToken(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
ctx.SetCookie(
|
ctx.SetCookie(
|
||||||
"access_token",
|
"access_token",
|
||||||
tokens.AccessToken,
|
tokens.AccessToken,
|
||||||
@@ -122,12 +121,12 @@ func (store *Store) Login(ctx *gin.Context) {
|
|||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +136,7 @@ func (store *Store) Login(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
ctx.SetCookie(
|
ctx.SetCookie(
|
||||||
"access_token",
|
"access_token",
|
||||||
tokens.AccessToken,
|
tokens.AccessToken,
|
||||||
@@ -164,6 +164,7 @@ func (store *Store) Logout(ctx *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func removeCookies(ctx *gin.Context) {
|
func removeCookies(ctx *gin.Context) {
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
ctx.SetCookie(
|
ctx.SetCookie(
|
||||||
"access_token",
|
"access_token",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ var allowedExtensions = map[string]bool{
|
|||||||
".pdf": true, ".txt": true,
|
".pdf": true, ".txt": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var extensionToMIMEPrefix = map[string]string{
|
||||||
|
".jpg": "image/", ".jpeg": "image/", ".png": "image/png", ".gif": "image/gif", ".webp": "image/webp",
|
||||||
|
".mp4": "video/", ".webm": "video/webm", ".mp3": "audio/", ".ogg": "audio/",
|
||||||
|
".pdf": "application/pdf", ".txt": "text/",
|
||||||
|
}
|
||||||
|
|
||||||
func (store *Store) UploadMessageFile(ctx *gin.Context) {
|
func (store *Store) UploadMessageFile(ctx *gin.Context) {
|
||||||
file, err := ctx.FormFile("file")
|
file, err := ctx.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -36,6 +42,23 @@ func (store *Store) UploadMessageFile(ctx *gin.Context) {
|
|||||||
return
|
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, _ := f.Read(buf)
|
||||||
|
f.Close()
|
||||||
|
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)
|
b := make([]byte, 16)
|
||||||
if _, err := rand.Read(b); err != nil {
|
if _, err := rand.Read(b); err != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})
|
ctx.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate filename"})
|
||||||
|
|||||||
@@ -15,6 +15,21 @@ type UserCredentials struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) CreateUser(ctx *gin.Context) {
|
func (store *Store) CreateUser(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
|
||||||
|
}
|
||||||
|
if !(*claims)["admin"].(bool) {
|
||||||
|
ctx.JSON(http.StatusForbidden, gin.H{"error": "admin access required"})
|
||||||
|
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, err.Error())
|
||||||
@@ -31,32 +46,9 @@ func (store *Store) CreateUser(ctx *gin.Context) {
|
|||||||
tx := store.DB.Create(&user)
|
tx := store.DB.Create(&user)
|
||||||
if tx.Error != nil {
|
if tx.Error != nil {
|
||||||
ctx.JSON(http.StatusInternalServerError, tx.Error.Error())
|
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
|
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)
|
ctx.JSON(http.StatusOK, user)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +133,7 @@ func (store *Store) DeleteUser(ctx *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.SetSameSite(http.SameSiteLaxMode)
|
||||||
ctx.SetCookie(
|
ctx.SetCookie(
|
||||||
"access_token",
|
"access_token",
|
||||||
"",
|
"",
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ func main() {
|
|||||||
if os.Getenv("SEED_DB") == "true" {
|
if os.Getenv("SEED_DB") == "true" {
|
||||||
services.SeedDatabase(db)
|
services.SeedDatabase(db)
|
||||||
}
|
}
|
||||||
services.InitWebSocket(db)
|
domainName := os.Getenv("DOMAIN")
|
||||||
|
services.InitWebSocket(db, domainName)
|
||||||
|
|
||||||
// SPOTIFY
|
// SPOTIFY
|
||||||
spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE")
|
spotifyAuthState := os.Getenv("SPOTIFY_AUTH_STATE")
|
||||||
@@ -57,7 +58,6 @@ 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 := 24 * time.Hour
|
||||||
refreshTokenLifetime := 365 * 24 * time.Hour
|
refreshTokenLifetime := 365 * 24 * time.Hour
|
||||||
@@ -96,7 +96,7 @@ func main() {
|
|||||||
protected.PUT("/user/:id", store.UpdateUser)
|
protected.PUT("/user/:id", store.UpdateUser)
|
||||||
protected.DELETE("/user/:id", store.DeleteUser)
|
protected.DELETE("/user/:id", store.DeleteUser)
|
||||||
r.GET("/user", store.GetUsers)
|
r.GET("/user", store.GetUsers)
|
||||||
r.POST("/user", store.CreateUser)
|
protected.POST("/user", store.CreateUser)
|
||||||
|
|
||||||
// AUTH
|
// AUTH
|
||||||
r.POST("/auth/login", store.Login)
|
r.POST("/auth/login", store.Login)
|
||||||
@@ -112,7 +112,7 @@ func main() {
|
|||||||
|
|
||||||
// MESSAGES
|
// MESSAGES
|
||||||
r.GET("/ws", store.ConnectWebSocket)
|
r.GET("/ws", store.ConnectWebSocket)
|
||||||
r.POST("/messages/upload", store.UploadMessageFile)
|
protected.POST("/messages/upload", store.UploadMessageFile)
|
||||||
|
|
||||||
// NOTES
|
// NOTES
|
||||||
r.GET("/notes/*path", store.GetNoteFile)
|
r.GET("/notes/*path", store.GetNoteFile)
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"adam-french.co.uk/backend/models"
|
"adam-french.co.uk/backend/models"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -12,11 +14,19 @@ import (
|
|||||||
|
|
||||||
const maxMessages = 50
|
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 {
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
return true
|
origin := r.Header.Get("Origin")
|
||||||
|
if origin == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
origin = strings.TrimPrefix(origin, "https://")
|
||||||
|
origin = strings.TrimPrefix(origin, "http://")
|
||||||
|
return origin == allowedDomain || origin == "www."+allowedDomain
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,8 +37,14 @@ var (
|
|||||||
nextAuthorID uint
|
nextAuthorID uint
|
||||||
)
|
)
|
||||||
|
|
||||||
func InitWebSocket(database *gorm.DB) {
|
const (
|
||||||
|
rateLimitWindow = time.Second
|
||||||
|
rateLimitMaxMsgs = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
func InitWebSocket(database *gorm.DB, domain string) {
|
||||||
wsDB = database
|
wsDB = database
|
||||||
|
allowedDomain = domain
|
||||||
}
|
}
|
||||||
|
|
||||||
func HandleWebSocket(conn *websocket.Conn) {
|
func HandleWebSocket(conn *websocket.Conn) {
|
||||||
@@ -50,12 +66,25 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if now.Sub(windowStart) > rateLimitWindow {
|
||||||
|
msgCount = 0
|
||||||
|
windowStart = now
|
||||||
|
}
|
||||||
|
msgCount++
|
||||||
|
if msgCount > rateLimitMaxMsgs {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
incoming.AuthorID = authorID
|
incoming.AuthorID = authorID
|
||||||
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
|
|||||||
@@ -69,8 +69,6 @@ services:
|
|||||||
- app-network
|
- app-network
|
||||||
volumes:
|
volumes:
|
||||||
- dbdata:/var/lib/postgresql/data
|
- dbdata:/var/lib/postgresql/data
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
|
|
||||||
icecast2:
|
icecast2:
|
||||||
build:
|
build:
|
||||||
@@ -82,8 +80,6 @@ services:
|
|||||||
- app-network
|
- app-network
|
||||||
env_file:
|
env_file:
|
||||||
- ./.env
|
- ./.env
|
||||||
ports:
|
|
||||||
- "${ICECAST_PORT}:${ICECAST_PORT}"
|
|
||||||
|
|
||||||
gitea-runner:
|
gitea-runner:
|
||||||
image: gitea/act_runner:latest
|
image: gitea/act_runner:latest
|
||||||
@@ -99,7 +95,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./gitea-runner/config.yaml:/config.yaml
|
- ./gitea-runner/config.yaml:/config.yaml
|
||||||
- ./gitea-runner/data:/data
|
- ./gitea-runner/data:/data
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock # WARNING: Docker socket mount gives container host-level access. Runner is in 'disabled' profile to mitigate risk.
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
@@ -124,7 +120,6 @@ services:
|
|||||||
- /etc/timezone:/etc/timezone:ro
|
- /etc/timezone:/etc/timezone:ro
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
|
||||||
- "2222:2222"
|
- "2222:2222"
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ http {
|
|||||||
|
|
||||||
client_max_body_size 10M;
|
client_max_body_size 10M;
|
||||||
|
|
||||||
|
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
|
||||||
|
|
||||||
log_format compact
|
log_format compact
|
||||||
'$remote_addr "$request" $status rt=$request_time';
|
'$remote_addr "$request" $status rt=$request_time';
|
||||||
|
|
||||||
@@ -98,7 +102,28 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT/auth/login {
|
||||||
|
limit_req zone=login burst=3 nodelay;
|
||||||
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT/messages/upload {
|
||||||
|
limit_req zone=upload burst=3 nodelay;
|
||||||
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location $BACKEND_ENDPOINT/ {
|
location $BACKEND_ENDPOINT/ {
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ http {
|
|||||||
|
|
||||||
client_max_body_size 10M;
|
client_max_body_size 10M;
|
||||||
|
|
||||||
|
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
|
||||||
|
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
|
||||||
|
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/m;
|
||||||
|
|
||||||
log_format compact
|
log_format compact
|
||||||
'$remote_addr "$request" $status rt=$request_time';
|
'$remote_addr "$request" $status rt=$request_time';
|
||||||
|
|
||||||
@@ -64,7 +68,28 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT/auth/login {
|
||||||
|
limit_req zone=login burst=3 nodelay;
|
||||||
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location $BACKEND_ENDPOINT/messages/upload {
|
||||||
|
limit_req zone=upload burst=3 nodelay;
|
||||||
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
|
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location $BACKEND_ENDPOINT/ {
|
location $BACKEND_ENDPOINT/ {
|
||||||
|
limit_req zone=api burst=20 nodelay;
|
||||||
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
rewrite ^$BACKEND_ENDPOINT/(.*)$ /$1 break;
|
||||||
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
proxy_pass http://$BACKEND_HOST:$BACKEND_PORT/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@@ -39,6 +39,10 @@ function isImageUrl(url) {
|
|||||||
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url);
|
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSafeFileUrl(url) {
|
||||||
|
return typeof url === "string" && url.startsWith("/uploads/");
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
messagesStore.connect();
|
messagesStore.connect();
|
||||||
});
|
});
|
||||||
@@ -57,7 +61,7 @@ onUnmounted(() => {
|
|||||||
<p v-for="message in messages" :key="message.id">
|
<p v-for="message in messages" :key="message.id">
|
||||||
<span class="text-tertiary">{{ message.authorId }}:</span>
|
<span class="text-tertiary">{{ message.authorId }}:</span>
|
||||||
{{ message.text }}
|
{{ message.text }}
|
||||||
<template v-if="message.fileUrl">
|
<template v-if="message.fileUrl && isSafeFileUrl(message.fileUrl)">
|
||||||
<img v-if="isImageUrl(message.fileUrl)" :src="message.fileUrl"
|
<img v-if="isImageUrl(message.fileUrl)" :src="message.fileUrl"
|
||||||
class="max-w-xs max-h-48 rounded" />
|
class="max-w-xs max-h-48 rounded" />
|
||||||
<a v-else :href="message.fileUrl" target="_blank"
|
<a v-else :href="message.fileUrl" target="_blank"
|
||||||
|
|||||||
Reference in New Issue
Block a user