Add file upload to website and integrate into chat
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:
@@ -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)
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;'
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user