Add file upload to website and integrate into chat
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled

This commit is contained in:
2026-03-09 13:47:38 +00:00
parent 77e2c272cb
commit 4c396ef30f
8 changed files with 69 additions and 1 deletions

View File

@@ -112,6 +112,7 @@ func main() {
// MESSAGES // MESSAGES
r.GET("/ws", store.ConnectWebSocket) r.GET("/ws", store.ConnectWebSocket)
r.POST("/messages/upload", store.UploadMessageFile)
// NOTES // NOTES
r.GET("/notes/*path", store.GetNoteFile) r.GET("/notes/*path", store.GetNoteFile)

View File

@@ -31,6 +31,7 @@ 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"` AuthorID uint `json:"authorId"`
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"`
} }

View File

@@ -4,6 +4,7 @@ networks:
volumes: volumes:
dbdata: dbdata:
uploads:
services: services:
nginx: nginx:
@@ -25,6 +26,7 @@ services:
volumes: volumes:
- ./certbot/conf:/etc/letsencrypt - ./certbot/conf:/etc/letsencrypt
- ./certbot/www:/var/www/certbot - ./certbot/www:/var/www/certbot
- uploads:/uploads
certbot: certbot:
image: certbot/certbot image: certbot/certbot
@@ -55,6 +57,7 @@ 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
db: db:
image: postgres:16 image: postgres:16
@@ -85,6 +88,8 @@ services:
gitea-runner: gitea-runner:
image: gitea/act_runner:latest image: gitea/act_runner:latest
container_name: "${GITEA_RUNNER_HOST}" container_name: "${GITEA_RUNNER_HOST}"
profiles:
- disabled
environment: environment:
GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME} GITEA_RUNNER_NAME: ${GITEA_RUNNER_NAME}
CONFIG_FILE: /config.yaml CONFIG_FILE: /config.yaml
@@ -110,6 +115,8 @@ services:
- GITEA__database__NAME=${POSTGRES_GITEA_DB} - GITEA__database__NAME=${POSTGRES_GITEA_DB}
- GITEA__database__USER=${POSTGRES_USER} - GITEA__database__USER=${POSTGRES_USER}
- GITEA__database__PASSWD=${POSTGRES_PASSWORD} - GITEA__database__PASSWD=${POSTGRES_PASSWORD}
- USER_UID=1000
- USER_GID=1000
restart: always restart: always
volumes: volumes:
- ./gitea/data:/var/lib/gitea - ./gitea/data:/var/lib/gitea

View File

@@ -17,5 +17,8 @@ else
envsubst '${DOMAIN}' < /etc/nginx/nginx_setup.conf.template > /etc/nginx/nginx.conf envsubst '${DOMAIN}' < /etc/nginx/nginx_setup.conf.template > /etc/nginx/nginx.conf
fi fi
# Ensure uploads are readable by nginx worker processes
chmod -R a+rX /uploads 2>/dev/null || true
# Start nginx # Start nginx
nginx -g 'daemon off;' nginx -g 'daemon off;'

View File

@@ -9,6 +9,8 @@ http {
server_tokens off; server_tokens off;
charset utf-8; charset utf-8;
client_max_body_size 10M;
log_format compact log_format compact
'$remote_addr "$request" $status rt=$request_time'; '$remote_addr "$request" $status rt=$request_time';
@@ -55,6 +57,13 @@ 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;
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;
} }

View File

@@ -9,6 +9,8 @@ http {
server_tokens off; server_tokens off;
charset utf-8; charset utf-8;
client_max_body_size 10M;
log_format compact log_format compact
'$remote_addr "$request" $status rt=$request_time'; '$remote_addr "$request" $status rt=$request_time';
@@ -25,6 +27,13 @@ http {
root /etc/nginx/html; root /etc/nginx/html;
index index.html; index index.html;
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;
} }

View File

@@ -8,6 +8,7 @@ const messagesStore = useMessagesStore();
const messages = computed(() => messagesStore.messages); const messages = computed(() => messagesStore.messages);
const messageInput = ref(""); const messageInput = ref("");
const messagesContainer = ref(null); const messagesContainer = ref(null);
const fileInput = ref(null);
function scrollToBottom() { function scrollToBottom() {
nextTick(() => { nextTick(() => {
@@ -27,6 +28,17 @@ function sendMessage() {
messageInput.value = ""; messageInput.value = "";
} }
async function onFileSelected(e) {
const file = e.target.files[0];
if (!file) return;
await messagesStore.uploadAndSendFile(file);
fileInput.value.value = "";
}
function isImageUrl(url) {
return /\.(jpg|jpeg|png|gif|webp)$/i.test(url);
}
onMounted(() => { onMounted(() => {
messagesStore.connect(); messagesStore.connect();
}); });
@@ -45,9 +57,20 @@ 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">
<img v-if="isImageUrl(message.fileUrl)" :src="message.fileUrl"
class="max-w-xs max-h-48 rounded" />
<a v-else :href="message.fileUrl" target="_blank"
class="underline">{{ message.fileUrl.split('/').pop() }}</a>
</template>
</p> </p>
</div> </div>
<input v-model="messageInput" @keyup.enter="sendMessage" /> <input v-model="messageInput" @keyup.enter="sendMessage" />
<Button @click="sendMessage">Send</Button> <input ref="fileInput" type="file" class="hidden"
@change="onFileSelected" />
<div class="flex gap-2">
<Button @click="sendMessage">Send</Button>
<Button @click="fileInput.click()">Attach</Button>
</div>
</div> </div>
</template> </template>

View File

@@ -1,5 +1,6 @@
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import axios from "axios";
function getWebSocketURL() { function getWebSocketURL() {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
@@ -59,6 +60,19 @@ export const useMessagesStore = defineStore("messages", () => {
messages.value = []; messages.value = [];
} }
async function uploadAndSendFile(file) {
try {
const formData = new FormData();
formData.append("file", file);
const res = await axios.post("/api/messages/upload", formData);
const { url } = res.data;
if (!socket.value || !isConnected.value) return;
socket.value.send(JSON.stringify({ text: "", fileUrl: url }));
} catch (err) {
lastError.value = err;
}
}
return { return {
messages, messages,
isConnected, isConnected,
@@ -70,5 +84,6 @@ export const useMessagesStore = defineStore("messages", () => {
disconnect, disconnect,
sendMessage, sendMessage,
clearMessages, clearMessages,
uploadAndSendFile,
}; };
}); });