init
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
/target
|
||||
/.idea
|
||||
*.db*
|
||||
/media/*
|
||||
@@ -2,7 +2,7 @@
|
||||
import 'highlight.js/styles/github-dark.css'
|
||||
import {computed, nextTick, onMounted, onUnmounted, ref, watch} from 'vue';
|
||||
import {storeToRefs} from 'pinia';
|
||||
import {useMessageStore} from '@/stores/message';
|
||||
import {useMessageStore, type Attachment} from '@/stores/message';
|
||||
import {useServerStore} from '@/stores/server';
|
||||
import {useUserStore} from "@/stores/user.ts";
|
||||
import {useMarkdown} from '@/composables/useMarkdown'
|
||||
@@ -32,6 +32,27 @@ const {renderMarkdown} = useMarkdown()
|
||||
const messageContainer = ref<HTMLElement | null>(null);
|
||||
const {messages, loading, loadingBefore, loadingAfter, hasMoreAfter, isAtBottom, newestId} = storeToRefs(messageStore);
|
||||
const newMessage = ref('');
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
type PendingAttachment = {
|
||||
id: string;
|
||||
file: File;
|
||||
filename: string;
|
||||
mimeType: string;
|
||||
previewUrl: string | null;
|
||||
progress: number | null;
|
||||
status: 'uploading' | 'uploaded' | 'error';
|
||||
attachment: Attachment | null;
|
||||
error: string | null;
|
||||
abortController: AbortController | null;
|
||||
};
|
||||
|
||||
const pendingAttachments = ref<PendingAttachment[]>([]);
|
||||
const sendingMessage = ref(false);
|
||||
const hasUploadingAttachments = computed(() => pendingAttachments.value.some(item => item.status === 'uploading'));
|
||||
const hasFailedAttachments = computed(() => pendingAttachments.value.some(item => item.status === 'error'));
|
||||
const uploadedAttachmentIds = computed(() => pendingAttachments.value
|
||||
.filter(item => item.status === 'uploaded' && item.attachment)
|
||||
.map(item => item.attachment!.id));
|
||||
|
||||
const SCROLL_LOAD_THRESHOLD = 120;
|
||||
const SCROLL_BOTTOM_TOLERANCE = 4;
|
||||
@@ -199,20 +220,119 @@ const handleScroll = async () => {
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!newMessage.value.trim()) return;
|
||||
if (!newMessage.value.trim() && !uploadedAttachmentIds.value.length) return;
|
||||
if (hasUploadingAttachments.value || hasFailedAttachments.value) return;
|
||||
|
||||
const content = newMessage.value;
|
||||
const wasAtBottom = messageStore.isAtBottom;
|
||||
|
||||
try {
|
||||
await messageStore.sendMessage(channelId.value, content);
|
||||
sendingMessage.value = true;
|
||||
await messageStore.sendMessage(channelId.value, content, uploadedAttachmentIds.value);
|
||||
newMessage.value = '';
|
||||
clearPendingAttachments();
|
||||
if (wasAtBottom) await scrollToBottom();
|
||||
} catch (e) {
|
||||
console.error('Erreur lors de l\'envoi du message:', e);
|
||||
} finally {
|
||||
sendingMessage.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openFilePicker = () => fileInput.value?.click();
|
||||
|
||||
const findPendingAttachment = (id: string) =>
|
||||
pendingAttachments.value.find(item => item.id === id);
|
||||
|
||||
const uploadPendingAttachment = async (pending: PendingAttachment, targetChannelId: string) => {
|
||||
const current = findPendingAttachment(pending.id);
|
||||
if (!current) return;
|
||||
current.abortController?.abort();
|
||||
const controller = new AbortController();
|
||||
current.abortController = controller;
|
||||
current.status = 'uploading';
|
||||
current.progress = 0;
|
||||
current.error = null;
|
||||
current.attachment = null;
|
||||
try {
|
||||
const attachment = await messageStore.uploadAttachment(
|
||||
targetChannelId,
|
||||
current.file,
|
||||
progress => {
|
||||
const reactivePending = findPendingAttachment(pending.id);
|
||||
if (reactivePending?.abortController === controller) reactivePending.progress = progress;
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
const reactivePending = findPendingAttachment(pending.id);
|
||||
if (reactivePending?.abortController !== controller) return;
|
||||
reactivePending.attachment = attachment;
|
||||
reactivePending.progress = 100;
|
||||
reactivePending.status = 'uploaded';
|
||||
} catch (error) {
|
||||
const reactivePending = findPendingAttachment(pending.id);
|
||||
if (reactivePending?.abortController !== controller) return;
|
||||
reactivePending.abortController = null;
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
reactivePending.status = 'error';
|
||||
reactivePending.error = error instanceof Error ? error.message : 'Échec de l\'upload';
|
||||
} finally {
|
||||
const reactivePending = findPendingAttachment(pending.id);
|
||||
if (reactivePending?.abortController === controller) reactivePending.abortController = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelection = async (event: Event) => {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const files = Array.from(input.files ?? []);
|
||||
input.value = '';
|
||||
if (!files.length) return;
|
||||
const targetChannelId = channelId.value;
|
||||
const pending = files.map((file, index): PendingAttachment => ({
|
||||
id: `${file.name}-${file.lastModified}-${index}-${crypto.randomUUID()}`,
|
||||
file,
|
||||
filename: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
previewUrl: file.type.startsWith('image/') ? URL.createObjectURL(file) : null,
|
||||
progress: 0,
|
||||
status: 'uploading',
|
||||
attachment: null,
|
||||
error: null,
|
||||
abortController: null,
|
||||
}));
|
||||
pendingAttachments.value.push(...pending);
|
||||
await Promise.all(pending.map(item => uploadPendingAttachment(item, targetChannelId)));
|
||||
};
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
const index = pendingAttachments.value.findIndex(item => item.id === id);
|
||||
if (index === -1) return;
|
||||
const [removed] = pendingAttachments.value.splice(index, 1);
|
||||
removed.abortController?.abort();
|
||||
if (removed.previewUrl) URL.revokeObjectURL(removed.previewUrl);
|
||||
};
|
||||
|
||||
const retryAttachment = (pending: PendingAttachment) => {
|
||||
void uploadPendingAttachment(pending, channelId.value);
|
||||
};
|
||||
|
||||
const clearPendingAttachments = () => {
|
||||
const pendingAttachmentsToClear = pendingAttachments.value;
|
||||
pendingAttachments.value = [];
|
||||
for (const pending of pendingAttachmentsToClear) {
|
||||
pending.abortController?.abort();
|
||||
if (pending.previewUrl) URL.revokeObjectURL(pending.previewUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const isImageAttachment = (attachment: Attachment) => attachment.mime_type.startsWith('image/');
|
||||
const isImagePending = (attachment: PendingAttachment) => attachment.mimeType.startsWith('image/');
|
||||
const formatFileSize = (size: number) => {
|
||||
if (size < 1024) return `${size} o`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} Ko`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} Mo`;
|
||||
};
|
||||
|
||||
const reactionKey = (messageId: string, emojiId: string, skinTone: number | null) =>
|
||||
`${messageId}:${emojiId}:${skinTone ?? 0}`;
|
||||
|
||||
@@ -256,6 +376,7 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
stopReloadAll?.();
|
||||
clearPendingAttachments();
|
||||
});
|
||||
|
||||
// Only explicit initial loads and realtime messages received while at the
|
||||
@@ -272,7 +393,8 @@ watch(isAtBottom, async (atBottom) => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(channelId, async (newChannelId) => {
|
||||
watch(channelId, async (newChannelId, previousChannelId) => {
|
||||
if (previousChannelId && previousChannelId !== newChannelId) clearPendingAttachments();
|
||||
if (newChannelId) {
|
||||
await emojiStore.fetchEmojis(props.serverId ?? null);
|
||||
await messageStore.fetchMessages(newChannelId);
|
||||
@@ -330,6 +452,23 @@ watch(() => props.serverId, (newServerId) => {
|
||||
<div class="markdown-content" v-html="renderMarkdown(msg.content)"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="msg.attachments?.length" class="attachments-grid mt-2">
|
||||
<a
|
||||
v-for="attachment in msg.attachments"
|
||||
:key="attachment.id"
|
||||
class="attachment-card"
|
||||
:class="{ 'attachment-image': isImageAttachment(attachment) }"
|
||||
:href="attachment.url"
|
||||
:download="isImageAttachment(attachment) ? undefined : attachment.filename"
|
||||
target="_blank"
|
||||
>
|
||||
<img v-if="isImageAttachment(attachment)" :src="attachment.url" :alt="attachment.filename" />
|
||||
<v-icon v-else icon="mdi-file-download-outline" size="28" />
|
||||
<span class="attachment-name">{{ attachment.filename }}</span>
|
||||
<span class="text-caption text-grey">{{ formatFileSize(attachment.file_size) }}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="d-flex flex-wrap align-center ga-1 mt-2">
|
||||
<v-chip
|
||||
v-for="reaction in msg.reactions"
|
||||
@@ -375,6 +514,39 @@ watch(() => props.serverId, (newServerId) => {
|
||||
</div>
|
||||
|
||||
<v-sheet class="pa-4 flex-shrink-0" width="100%">
|
||||
<input ref="fileInput" accept="*/*" class="d-none" multiple type="file" @change="handleFileSelection" />
|
||||
<div v-if="pendingAttachments.length" class="selected-attachments mb-2">
|
||||
<div v-for="attachment in pendingAttachments" :key="attachment.id" class="selected-attachment">
|
||||
<img v-if="isImagePending(attachment)" :src="attachment.previewUrl ?? attachment.attachment?.url" :alt="attachment.filename" />
|
||||
<v-icon v-else icon="mdi-file-outline" />
|
||||
<div class="selected-attachment-details">
|
||||
<span class="text-truncate" :title="attachment.filename">{{ attachment.filename }}</span>
|
||||
<div v-if="attachment.status === 'uploading'" class="attachment-progress">
|
||||
<v-progress-linear
|
||||
v-if="attachment.progress !== null"
|
||||
:model-value="attachment.progress"
|
||||
color="primary"
|
||||
height="6"
|
||||
rounded
|
||||
/>
|
||||
<v-progress-linear v-else color="primary" height="6" indeterminate rounded />
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
{{ attachment.progress === null ? 'Envoi en cours…' : `${attachment.progress} %` }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else-if="attachment.status === 'uploaded'" class="text-caption text-success">Envoyé</span>
|
||||
<span v-else class="text-caption text-error">{{ attachment.error }}</span>
|
||||
</div>
|
||||
<v-btn
|
||||
v-if="attachment.status === 'error'"
|
||||
icon="mdi-refresh"
|
||||
size="x-small"
|
||||
variant="text"
|
||||
@click="retryAttachment(attachment)"
|
||||
/>
|
||||
<v-btn icon="mdi-close" size="x-small" variant="text" @click="removeAttachment(attachment.id)" />
|
||||
</div>
|
||||
</div>
|
||||
<v-textarea
|
||||
v-model="newMessage"
|
||||
auto-grow
|
||||
@@ -387,10 +559,11 @@ watch(() => props.serverId, (newServerId) => {
|
||||
rounded="lg"
|
||||
rows="1"
|
||||
variant="solo-filled"
|
||||
:disabled="hasUploadingAttachments || hasFailedAttachments || sendingMessage"
|
||||
@keydown.enter.exact.prevent="sendMessage"
|
||||
>
|
||||
<template v-slot:prepend-inner>
|
||||
<v-btn color="grey-darken-1" density="compact" icon="mdi-plus-circle" variant="text"></v-btn>
|
||||
<v-btn :disabled="sendingMessage" color="grey-darken-1" density="compact" icon="mdi-plus-circle" variant="text" @click="openFilePicker"></v-btn>
|
||||
</template>
|
||||
<template v-slot:append-inner>
|
||||
<v-btn color="grey-darken-1" density="compact" icon="mdi-gift" variant="text"></v-btn>
|
||||
@@ -458,4 +631,94 @@ watch(() => props.serverId, (newServerId) => {
|
||||
height: 18px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.attachments-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attachment-card {
|
||||
display: flex;
|
||||
width: 220px;
|
||||
min-height: 56px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.attachment-card.attachment-image {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: min(420px, 100%);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.attachment-card img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 360px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.selected-attachments {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.selected-attachment {
|
||||
display: flex;
|
||||
max-width: 240px;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 6px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.selected-attachment-details {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.selected-attachment-details > .text-truncate {
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attachment-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.attachment-progress .v-progress-linear {
|
||||
min-width: 80px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.selected-attachment img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,6 +22,20 @@ export interface Message {
|
||||
updated_at: string | null;
|
||||
reply_to_id: string | null;
|
||||
reactions: ReactionGroup[];
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string;
|
||||
filename: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
created_at: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface AttachmentUploadResponse {
|
||||
attachments: Attachment[];
|
||||
}
|
||||
|
||||
export interface ReactionGroup {
|
||||
@@ -242,12 +256,74 @@ export const useMessageStore = defineStore("message", {
|
||||
}
|
||||
},
|
||||
|
||||
async sendMessage(channelId: string, content: string) {
|
||||
uploadAttachment(
|
||||
channelId: string,
|
||||
file: File,
|
||||
onProgress?: (progress: number | null) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Attachment> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
let abortHandler: (() => void) | null = null;
|
||||
const cleanup = () => {
|
||||
if (abortHandler && signal) signal.removeEventListener('abort', abortHandler);
|
||||
abortHandler = null;
|
||||
};
|
||||
|
||||
if (signal?.aborted) {
|
||||
reject(new DOMException('Attachment upload aborted', 'AbortError'));
|
||||
return;
|
||||
}
|
||||
|
||||
xhr.open('POST', '/api/attachments');
|
||||
xhr.withCredentials = true;
|
||||
xhr.upload.addEventListener('progress', event => {
|
||||
onProgress?.(event.lengthComputable ? Math.round((event.loaded / event.total) * 100) : null);
|
||||
});
|
||||
xhr.addEventListener('load', () => {
|
||||
cleanup();
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
reject(new Error(`Attachment upload failed (${xhr.status})`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(xhr.responseText) as AttachmentUploadResponse;
|
||||
const attachment = payload.attachments?.[0];
|
||||
if (!attachment) throw new Error('Attachment upload returned no attachment');
|
||||
resolve(attachment);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('error', () => {
|
||||
cleanup();
|
||||
reject(new Error('Attachment upload failed'));
|
||||
});
|
||||
xhr.addEventListener('abort', () => {
|
||||
cleanup();
|
||||
reject(new DOMException('Attachment upload aborted', 'AbortError'));
|
||||
});
|
||||
|
||||
if (signal) {
|
||||
abortHandler = () => xhr.abort();
|
||||
signal.addEventListener('abort', abortHandler, {once: true});
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append('channel_id', channelId);
|
||||
form.append('files', file, file.name);
|
||||
onProgress?.(0);
|
||||
xhr.send(form);
|
||||
});
|
||||
},
|
||||
|
||||
async sendMessage(channelId: string, content: string, fileIds: string[] = []) {
|
||||
try {
|
||||
const response = await useApi().post("/messages", {
|
||||
channel_id: channelId,
|
||||
content,
|
||||
reply_to_id: null,
|
||||
file_ids: fileIds,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Message sending failed (${response.status})`);
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 42 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -617,7 +617,9 @@ impl MigrationTrait for Migration {
|
||||
.not_null()
|
||||
.primary_key(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("message_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("message_id")).uuid().null())
|
||||
.col(ColumnDef::new(Alias::new("channel_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("user_id")).uuid().not_null())
|
||||
.col(ColumnDef::new(Alias::new("filename")).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("file_size"))
|
||||
@@ -625,6 +627,7 @@ impl MigrationTrait for Migration {
|
||||
.not_null(),
|
||||
)
|
||||
.col(ColumnDef::new(Alias::new("mime_type")).string().not_null())
|
||||
.col(ColumnDef::new(Alias::new("file_path")).string().not_null())
|
||||
.col(
|
||||
ColumnDef::new(Alias::new("created_at"))
|
||||
.timestamp_with_time_zone()
|
||||
@@ -638,6 +641,30 @@ impl MigrationTrait for Migration {
|
||||
.to(Alias::new("message"), Alias::new("id"))
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_attachment_channel")
|
||||
.from(Alias::new("attachment"), Alias::new("channel_id"))
|
||||
.to(Alias::new("channel"), Alias::new("id"))
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.foreign_key(
|
||||
ForeignKey::create()
|
||||
.name("fk_attachment_user")
|
||||
.from(Alias::new("attachment"), Alias::new("user_id"))
|
||||
.to(Alias::new("user"), Alias::new("id"))
|
||||
.on_delete(ForeignKeyAction::Cascade),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
manager
|
||||
.create_index(
|
||||
Index::create()
|
||||
.name("idx_attachment_message_id")
|
||||
.table(Alias::new("attachment"))
|
||||
.col(Alias::new("message_id"))
|
||||
.to_owned(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
+12
-1
@@ -72,7 +72,7 @@ impl App {
|
||||
services
|
||||
.realtime_registry
|
||||
.start_listening(repositories.clone(), event_bus.clone());
|
||||
let gateway = Arc::new(GatewayManager::new(services.clone()));
|
||||
let gateway = Arc::new(GatewayManager::new(services.clone(), repositories.clone()));
|
||||
gateway.start(event_bus.clone());
|
||||
|
||||
let state = AppState {
|
||||
@@ -95,6 +95,17 @@ impl App {
|
||||
|
||||
let config = self.state.config.clone();
|
||||
|
||||
let cleanup_state = self.state.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(3600));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(error) = crate::routes::attachment::handlers::cleanup_expired(&cleanup_state).await {
|
||||
tracing::warn!(%error, "Attachment cleanup failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize HTTP Server
|
||||
let (http_server, http_shutdown_tx) = HttpServer::new(&config.network, self.state.clone());
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ impl Database {
|
||||
}
|
||||
|
||||
connection
|
||||
.execute_unprepared("PRAGMA wal_checkpoint;")
|
||||
.execute_unprepared("PRAGMA wal_checkpoint(TRUNCATE);")
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateAttachmentRequest {}
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct AttachmentResponse {
|
||||
pub id: Uuid,
|
||||
pub filename: String,
|
||||
pub file_size: i64,
|
||||
pub mime_type: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateAttachmentRequest {}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct AttachmentResponse {}
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct AttachmentUploadResponse {
|
||||
pub attachments: Vec<AttachmentResponse>,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::domain::dto::reaction::ReactionGroupResponse;
|
||||
use crate::domain::dto::attachment::AttachmentResponse;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
@@ -15,6 +16,7 @@ pub struct MessageResponse {
|
||||
pub updated_at: Option<DateTime<Utc>>,
|
||||
pub reply_to_id: Option<Uuid>,
|
||||
pub reactions: Vec<ReactionGroupResponse>,
|
||||
pub attachments: Vec<AttachmentResponse>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
@@ -31,6 +33,8 @@ pub struct CreateMessageRequest {
|
||||
pub channel_id: Uuid,
|
||||
pub content: String,
|
||||
pub reply_to_id: Option<Uuid>,
|
||||
#[serde(default)]
|
||||
pub file_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
|
||||
@@ -10,10 +10,13 @@ use sea_orm::prelude::async_trait::async_trait;
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: Uuid,
|
||||
pub message_id: Uuid,
|
||||
pub message_id: Option<Uuid>,
|
||||
pub channel_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub filename: String,
|
||||
pub file_size: i32,
|
||||
pub file_size: i64,
|
||||
pub mime_type: String,
|
||||
pub file_path: String,
|
||||
pub created_at: DateTimeUtc,
|
||||
#[sea_orm(
|
||||
belongs_to,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use super::types::MessageFilter;
|
||||
use crate::models::message;
|
||||
use crate::models::attachment;
|
||||
use crate::repositories::{AnyResult, RepositoryContext};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -30,6 +32,26 @@ impl MessageRepository {
|
||||
.await?)
|
||||
}
|
||||
|
||||
pub async fn attachments_for_messages(
|
||||
&self,
|
||||
message_ids: &[Uuid],
|
||||
) -> AnyResult<HashMap<Uuid, Vec<attachment::Model>>> {
|
||||
if message_ids.is_empty() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let items = attachment::Entity::find()
|
||||
.filter(attachment::Column::MessageId.is_in(message_ids.to_vec()))
|
||||
.all(&self.context.db)
|
||||
.await?;
|
||||
let mut grouped = HashMap::new();
|
||||
for item in items {
|
||||
if let Some(message_id) = item.message_id {
|
||||
grouped.entry(message_id).or_insert_with(Vec::new).push(item);
|
||||
}
|
||||
}
|
||||
Ok(grouped)
|
||||
}
|
||||
|
||||
pub async fn filter(&self, filter: MessageFilter) -> AnyResult<MessagePage> {
|
||||
let limit = filter
|
||||
.limit
|
||||
|
||||
@@ -1,22 +1,109 @@
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::IntoResponse;
|
||||
use crate::core::state::AppState;
|
||||
use crate::domain::dto::attachment::AttachmentUploadResponse;
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::models::attachment;
|
||||
use crate::routes::attachment::mapper;
|
||||
use crate::routes::message::handlers::can_access;
|
||||
use crate::services::media::{self, PendingMediaFile};
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Multipart, Path, State};
|
||||
use axum::http::{StatusCode, header};
|
||||
use axum::response::Response;
|
||||
use chrono::{Duration, Utc};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn get_all() -> impl IntoResponse {
|
||||
StatusCode::NOT_IMPLEMENTED
|
||||
pub async fn cleanup_expired(state: &AppState) -> Result<(), HTTPError> {
|
||||
let cutoff = Utc::now() - Duration::hours(24);
|
||||
let pending = attachment::Entity::find()
|
||||
.filter(attachment::Column::MessageId.is_null())
|
||||
.filter(attachment::Column::CreatedAt.lt(cutoff))
|
||||
.all(&state.db)
|
||||
.await?;
|
||||
for item in pending {
|
||||
let _ = tokio::fs::remove_file(PathBuf::from(&state.config.media.root).join(&item.file_path)).await;
|
||||
attachment::Entity::delete_by_id(item.id).exec(&state.db).await?;
|
||||
}
|
||||
media::cleanup_temporary_files(
|
||||
PathBuf::from(&state.config.media.root).as_path(),
|
||||
std::time::Duration::from_secs(24 * 60 * 60),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| HTTPError::InternalServerError(error.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_by_id() -> impl IntoResponse {
|
||||
StatusCode::NOT_IMPLEMENTED
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/attachments",
|
||||
request_body(content = String, content_type = "multipart/form-data"),
|
||||
responses((status = 201, body = AttachmentUploadResponse)),
|
||||
tag = "Attachments",
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
pub async fn create(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<(StatusCode, axum::Json<AttachmentUploadResponse>), HTTPError> {
|
||||
cleanup_expired(&state).await?;
|
||||
let mut channel_id = None;
|
||||
let mut created = Vec::new();
|
||||
while let Some(mut field) = multipart
|
||||
.next_field()
|
||||
.await
|
||||
.map_err(|error| HTTPError::BadRequest(error.to_string()))?
|
||||
{
|
||||
let name = field.name().unwrap_or_default().to_string();
|
||||
if name == "channel_id" {
|
||||
let value = field.text().await.map_err(|error| HTTPError::BadRequest(error.to_string()))?;
|
||||
channel_id = Some(value.parse::<Uuid>().map_err(HTTPError::UuidError)?);
|
||||
} else if name == "files" || name == "file" {
|
||||
let channel = channel_id.ok_or_else(|| HTTPError::BadRequest("channel_id must precede files".into()))?;
|
||||
let filename = field.file_name().unwrap_or("unnamed").to_string();
|
||||
let mime_type = field.content_type().unwrap_or("application/octet-stream").to_string();
|
||||
if !can_access(&state, channel, user.id).await? { return Err(HTTPError::Forbidden); }
|
||||
let id = Uuid::new_v4();
|
||||
let mut output = PendingMediaFile::begin(
|
||||
PathBuf::from(&state.config.media.root).as_path(),
|
||||
"attachments",
|
||||
id,
|
||||
media::extension_from_filename(&filename).as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| HTTPError::InternalServerError(error.to_string()))?;
|
||||
let mut size = 0_i64;
|
||||
while let Some(chunk) = field.chunk().await.map_err(|error| HTTPError::BadRequest(error.to_string()))? {
|
||||
size += chunk.len() as i64;
|
||||
output.write(&chunk).await.map_err(|error| HTTPError::InternalServerError(error.to_string()))?;
|
||||
}
|
||||
let file_path = output.finish().await.map_err(|error| HTTPError::InternalServerError(error.to_string()))?;
|
||||
let model = attachment::ActiveModel {
|
||||
id: Set(id), message_id: Set(None), channel_id: Set(channel), user_id: Set(user.id),
|
||||
filename: Set(filename), file_size: Set(size), mime_type: Set(mime_type), file_path: Set(file_path.clone()), created_at: Set(Utc::now()),
|
||||
};
|
||||
match model.insert(&state.db).await {
|
||||
Ok(item) => created.push(item),
|
||||
Err(error) => { let _ = tokio::fs::remove_file(PathBuf::from(&state.config.media.root).join(file_path)).await; return Err(HTTPError::Database(error)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
let channel_id = channel_id.ok_or_else(|| HTTPError::BadRequest("channel_id is required".into()))?;
|
||||
if !can_access(&state, channel_id, user.id).await? { return Err(HTTPError::Forbidden); }
|
||||
if created.is_empty() { return Err(HTTPError::BadRequest("at least one file is required".into())); }
|
||||
Ok((StatusCode::CREATED, axum::Json(AttachmentUploadResponse { attachments: created.into_iter().map(mapper::to_response).collect() })))
|
||||
}
|
||||
|
||||
pub async fn create() -> impl IntoResponse {
|
||||
StatusCode::NOT_IMPLEMENTED
|
||||
}
|
||||
|
||||
pub async fn update() -> impl IntoResponse {
|
||||
StatusCode::NOT_IMPLEMENTED
|
||||
}
|
||||
|
||||
pub async fn delete() -> impl IntoResponse {
|
||||
StatusCode::NOT_IMPLEMENTED
|
||||
pub async fn file(State(state): State<AppState>, Path(id): Path<Uuid>) -> Result<Response, HTTPError> {
|
||||
let item = attachment::Entity::find_by_id(id).one(&state.db).await?.ok_or(HTTPError::NotFound)?;
|
||||
let bytes = tokio::fs::read(PathBuf::from(&state.config.media.root).join(&item.file_path)).await.map_err(|_| HTTPError::NotFound)?;
|
||||
let mut response = Response::new(Body::from(bytes));
|
||||
if let Ok(value) = item.mime_type.parse() { response.headers_mut().insert(header::CONTENT_TYPE, value); }
|
||||
if !item.mime_type.starts_with("image/") {
|
||||
let safe_name = item.filename.replace(['\"', '\r', '\n'], "_");
|
||||
if let Ok(value) = format!("attachment; filename=\"{safe_name}\"").parse() { response.headers_mut().insert(header::CONTENT_DISPOSITION, value); }
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
use super::domain::Attachment;
|
||||
use crate::domain::dto::attachment::AttachmentResponse;
|
||||
use crate::models::attachment;
|
||||
|
||||
pub fn to_response(_item: Attachment) -> AttachmentResponse {
|
||||
todo!()
|
||||
pub fn to_response(item: attachment::Model) -> AttachmentResponse {
|
||||
AttachmentResponse {
|
||||
id: item.id,
|
||||
filename: item.filename,
|
||||
file_size: item.file_size,
|
||||
mime_type: item.mime_type,
|
||||
created_at: item.created_at,
|
||||
url: format!("/api/attachments/{}/file", item.id),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
pub mod domain;
|
||||
pub mod handlers;
|
||||
pub mod mapper;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
use axum::{Router, routing::get};
|
||||
|
||||
use axum::{Router, routing::{get, post}, extract::DefaultBodyLimit};
|
||||
use crate::core::state::AppState;
|
||||
use super::handlers;
|
||||
|
||||
pub fn router() -> Router {
|
||||
pub fn secure_router() -> Router<AppState> {
|
||||
Router::new()
|
||||
.route(
|
||||
"/attachments",
|
||||
get(handlers::get_all).post(handlers::create),
|
||||
)
|
||||
.route(
|
||||
"/attachments/{id}",
|
||||
get(handlers::get_by_id)
|
||||
.put(handlers::update)
|
||||
.delete(handlers::delete),
|
||||
)
|
||||
.route("/attachments", post(handlers::create))
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
}
|
||||
|
||||
pub fn public_router() -> Router<AppState> {
|
||||
Router::new().route("/attachments/{id}/file", get(handlers::file))
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::{
|
||||
routes::emoji::mapper,
|
||||
services::emoji::EmojiService,
|
||||
};
|
||||
use crate::services::media;
|
||||
use axum::{
|
||||
Json,
|
||||
body::Body,
|
||||
@@ -126,9 +127,10 @@ pub async fn create(
|
||||
size = Some(bytes.len() as i64);
|
||||
let detected = detect_mime(&bytes)
|
||||
.ok_or_else(|| HTTPError::BadRequest("Unsupported or invalid image format".into()))?;
|
||||
let extension = media::extension_from_mime(&detected);
|
||||
mime = Some(detected);
|
||||
let (p, h) =
|
||||
EmojiService::save_asset(std::path::Path::new(&state.config.media.root), id, &bytes)
|
||||
EmojiService::save_asset(std::path::Path::new(&state.config.media.root), id, &bytes, extension)
|
||||
.await?;
|
||||
path = Some(p);
|
||||
sha = Some(h);
|
||||
|
||||
@@ -6,11 +6,13 @@ use crate::models::user::Model as User;
|
||||
use crate::routes::category::mapper::category_model_to_category_response;
|
||||
use crate::routes::channel::mapper::channel_model_to_channel_response;
|
||||
use crate::routes::message::mapper::{
|
||||
message_model_to_message_response_with_data,
|
||||
message_model_to_message_response_with_reactions,
|
||||
message_model_to_message_response_with_server_id, reaction_model_to_response,
|
||||
reaction_model_to_response,
|
||||
};
|
||||
use crate::routes::server::mapper::server_model_to_server_response;
|
||||
use crate::services::Services;
|
||||
use crate::repositories::Repositories;
|
||||
use axum::extract::ws::Message;
|
||||
use event_bus::EventBus;
|
||||
use events::GatewayEvent;
|
||||
@@ -29,6 +31,7 @@ pub mod routes;
|
||||
pub struct GatewayManager {
|
||||
pub clients: RwLock<HashMap<ConnectionKey, GatewayClient>>,
|
||||
services: Arc<Services>,
|
||||
repositories: Arc<Repositories>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -47,10 +50,11 @@ pub struct GatewayClient {
|
||||
}
|
||||
|
||||
impl GatewayManager {
|
||||
pub fn new(services: Arc<Services>) -> Self {
|
||||
pub fn new(services: Arc<Services>, repositories: Arc<Repositories>) -> Self {
|
||||
Self {
|
||||
clients: RwLock::new(HashMap::new()),
|
||||
services,
|
||||
repositories,
|
||||
}
|
||||
}
|
||||
/// Démarre les routeurs centraux des événements de messages.
|
||||
@@ -59,13 +63,19 @@ impl GatewayManager {
|
||||
event_bus.on_async::<MessageCreatedEvent, _, _>("message_created", move |event| {
|
||||
let manager = Arc::clone(&manager);
|
||||
async move {
|
||||
let message_id = event.message.id;
|
||||
let attachments = manager
|
||||
.repositories
|
||||
.message
|
||||
.attachments_for_messages(&[message_id])
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|mut items| items.remove(&message_id))
|
||||
.unwrap_or_default();
|
||||
manager.broadcast_message(
|
||||
event.channel_id,
|
||||
"add",
|
||||
message_model_to_message_response_with_server_id(
|
||||
event.message,
|
||||
event.server_id,
|
||||
),
|
||||
message_model_to_message_response_with_data(event.message, event.server_id, Vec::new(), attachments),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -67,6 +67,7 @@ pub async fn get_all(
|
||||
.message_reaction
|
||||
.grouped_for_messages(&message_ids)
|
||||
.await?;
|
||||
let mut attachments = state.repositories.message.attachments_for_messages(&message_ids).await?;
|
||||
let oldest_id = page.messages.first().map(|message| message.id);
|
||||
let newest_id = page.messages.last().map(|message| message.id);
|
||||
|
||||
@@ -76,7 +77,8 @@ pub async fn get_all(
|
||||
.into_iter()
|
||||
.map(|message| {
|
||||
let groups = reactions.remove(&message.id).unwrap_or_default();
|
||||
mapper::message_model_to_message_response_with_reactions(message, None, groups)
|
||||
let files = attachments.remove(&message.id).unwrap_or_default();
|
||||
mapper::message_model_to_message_response_with_data(message, None, groups, files)
|
||||
})
|
||||
.collect(),
|
||||
oldest_id,
|
||||
@@ -122,9 +124,10 @@ pub async fn get_by_id(
|
||||
.await?
|
||||
.remove(&id)
|
||||
.unwrap_or_default();
|
||||
let attachments = state.repositories.message.attachments_for_messages(&[id]).await?.remove(&id).unwrap_or_default();
|
||||
|
||||
Ok(Json(
|
||||
mapper::message_model_to_message_response_with_reactions(message, None, reactions),
|
||||
mapper::message_model_to_message_response_with_data(message, None, reactions, attachments),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -159,6 +162,10 @@ pub async fn create(
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
|
||||
if payload.content.trim().is_empty() && payload.file_ids.is_empty() {
|
||||
return Err(HTTPError::BadRequest("content or at least one file is required".into()));
|
||||
}
|
||||
|
||||
// Optionnel: vérifier reply_to_id
|
||||
if let Some(reply_id) = payload.reply_to_id {
|
||||
state
|
||||
@@ -174,13 +181,17 @@ pub async fn create(
|
||||
let message = state
|
||||
.services
|
||||
.message
|
||||
.create_message(payload.channel_id, user.id, payload.content)
|
||||
.await?;
|
||||
.create_message_with_attachments(payload.channel_id, user.id, payload.content, payload.file_ids, payload.reply_to_id)
|
||||
.await
|
||||
.map_err(|error| HTTPError::BadRequest(error.to_string()))?;
|
||||
let attachments = state.repositories.message.attachments_for_messages(&[message.id]).await?.remove(&message.id).unwrap_or_default();
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(mapper::message_model_to_message_response_with_server_id(
|
||||
Json(mapper::message_model_to_message_response_with_data(
|
||||
message,
|
||||
channel.server_id,
|
||||
Vec::new(),
|
||||
attachments,
|
||||
)),
|
||||
))
|
||||
}
|
||||
@@ -236,8 +247,9 @@ pub async fn update(
|
||||
.await?
|
||||
.remove(&id)
|
||||
.unwrap_or_default();
|
||||
let attachments = state.repositories.message.attachments_for_messages(&[id]).await?.remove(&id).unwrap_or_default();
|
||||
Ok(Json(
|
||||
mapper::message_model_to_message_response_with_reactions(message, None, reactions),
|
||||
mapper::message_model_to_message_response_with_data(message, None, reactions, attachments),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -345,7 +357,22 @@ pub async fn delete(
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
|
||||
let attachment_ids: Vec<_> = state
|
||||
.repositories
|
||||
.message
|
||||
.attachments_for_messages(&[id])
|
||||
.await?
|
||||
.remove(&id)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|item| item.file_path)
|
||||
.collect();
|
||||
|
||||
if state.services.message.delete_message(id).await? {
|
||||
for attachment_path in attachment_ids {
|
||||
let path = std::path::PathBuf::from(&state.config.media.root).join(attachment_path);
|
||||
let _ = tokio::fs::remove_file(path).await;
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
Err(HTTPError::NotFound)
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::domain::dto::message::{
|
||||
use crate::domain::dto::reaction::ReactionGroupResponse;
|
||||
use crate::domain::dto::reaction::ReactionResponse;
|
||||
use crate::models::message;
|
||||
use crate::models::attachment;
|
||||
use crate::models::message_reaction;
|
||||
use crate::repositories::types::MessageFilter;
|
||||
use chrono::Utc;
|
||||
@@ -18,13 +19,14 @@ pub fn message_model_to_message_response_with_server_id(
|
||||
model: message::Model,
|
||||
server_id: Option<Uuid>,
|
||||
) -> MessageResponse {
|
||||
message_model_to_message_response_with_reactions(model, server_id, Vec::new())
|
||||
message_model_to_message_response_with_data(model, server_id, Vec::new(), Vec::new())
|
||||
}
|
||||
|
||||
pub fn message_model_to_message_response_with_reactions(
|
||||
pub fn message_model_to_message_response_with_data(
|
||||
model: message::Model,
|
||||
server_id: Option<Uuid>,
|
||||
reactions: Vec<ReactionGroupResponse>,
|
||||
attachments: Vec<attachment::Model>,
|
||||
) -> MessageResponse {
|
||||
MessageResponse {
|
||||
id: model.id,
|
||||
@@ -36,9 +38,18 @@ pub fn message_model_to_message_response_with_reactions(
|
||||
updated_at: model.updated_at,
|
||||
reply_to_id: model.reply_to_id,
|
||||
reactions,
|
||||
attachments: attachments.into_iter().map(crate::routes::attachment::mapper::to_response).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn message_model_to_message_response_with_reactions(
|
||||
model: message::Model,
|
||||
server_id: Option<Uuid>,
|
||||
reactions: Vec<ReactionGroupResponse>,
|
||||
) -> MessageResponse {
|
||||
message_model_to_message_response_with_data(model, server_id, reactions, Vec::new())
|
||||
}
|
||||
|
||||
pub fn create_request_to_am(user_id: Uuid, payload: CreateMessageRequest) -> message::ActiveModel {
|
||||
message::ActiveModel {
|
||||
id: Set(Uuid::now_v7()),
|
||||
|
||||
@@ -27,6 +27,7 @@ pub fn router() -> OxRouter {
|
||||
.merge(conversation::routes::router())
|
||||
.merge(role::routes::router())
|
||||
.merge(message::routes::router())
|
||||
.merge(attachment::routes::secure_router())
|
||||
.merge(user::routes::router())
|
||||
.merge(emoji::routes::router())
|
||||
.layer(axum_middleware::from_fn(middleware::require_auth));
|
||||
@@ -36,11 +37,13 @@ pub fn router() -> OxRouter {
|
||||
.merge(secure_routes)
|
||||
.merge(auth::routes::router())
|
||||
.merge(core::routes::router());
|
||||
let public_attachment_routes = attachment::routes::public_router();
|
||||
|
||||
let ws_routes = Router::new().merge(gateway::routes::router());
|
||||
|
||||
Router::new()
|
||||
.nest("/api", api_routes)
|
||||
.nest("/api", public_attachment_routes)
|
||||
.nest("/ws", ws_routes)
|
||||
.merge(SwaggerUi::new("/swagger").url("/api-docs/openapi.json", openapi::ApiDoc::openapi()))
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ use utoipa::{Modify, OpenApi};
|
||||
message::handlers::delete,
|
||||
message::handlers::add_reaction,
|
||||
message::handlers::remove_reaction,
|
||||
attachment::handlers::create,
|
||||
core::handlers::join,
|
||||
emoji::handlers::get_all,
|
||||
emoji::handlers::get_by_id,
|
||||
@@ -85,6 +86,8 @@ use utoipa::{Modify, OpenApi};
|
||||
crate::domain::dto::reaction::CreateReactionRequest,
|
||||
crate::domain::dto::reaction::ReactionResponse,
|
||||
crate::domain::dto::reaction::ReactionGroupResponse,
|
||||
crate::domain::dto::attachment::AttachmentResponse,
|
||||
crate::domain::dto::attachment::AttachmentUploadResponse,
|
||||
crate::domain::dto::core::JoinRequest,
|
||||
ChannelType,
|
||||
crate::domain::dto::emoji::EmojiResponse,
|
||||
@@ -102,6 +105,7 @@ use utoipa::{Modify, OpenApi};
|
||||
(name = "Channels", description = "Gestion des salons"),
|
||||
(name = "roles", description = "Gestion des rolees"),
|
||||
(name = "Messages", description = "Gestion des messages"),
|
||||
(name = "Attachments", description = "Upload et téléchargement des pièces jointes"),
|
||||
(name = "Core", description = "Endpoints de base (enregistrement, etc.)"),
|
||||
(name = "Emojis", description = "Gestion des emojis Unicode et personnalisés"),
|
||||
)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::models::emoji;
|
||||
use crate::services::ServicesContext;
|
||||
use crate::services::media::PendingMediaFile;
|
||||
use sea_orm::{ActiveModelTrait, Set, TransactionTrait};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
use tokio::fs;
|
||||
@@ -85,17 +86,18 @@ impl EmojiService {
|
||||
root: &Path,
|
||||
id: Uuid,
|
||||
data: &[u8],
|
||||
extension: Option<&str>,
|
||||
) -> Result<(String, String), HTTPError> {
|
||||
let dir = root.join("emoji");
|
||||
fs::create_dir_all(&dir)
|
||||
let mut pending = PendingMediaFile::begin(root, "emoji", id, extension)
|
||||
.await
|
||||
.map_err(|e| HTTPError::InternalServerError(e.to_string()))?;
|
||||
let relative = PathBuf::from("emoji").join(id.to_string());
|
||||
let path = root.join(&relative);
|
||||
fs::write(&path, data)
|
||||
pending.write(data)
|
||||
.await
|
||||
.map_err(|e| HTTPError::InternalServerError(e.to_string()))?;
|
||||
Ok((relative.to_string_lossy().into_owned(), Self::hash(data)))
|
||||
let relative = pending.finish()
|
||||
.await
|
||||
.map_err(|e| HTTPError::InternalServerError(e.to_string()))?;
|
||||
Ok((relative, Self::hash(data)))
|
||||
}
|
||||
pub async fn remove_asset(root: &Path, path: Option<&str>) {
|
||||
if let Some(path) = path {
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, SystemTime};
|
||||
use tokio::fs::{self, File};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct PendingMediaFile {
|
||||
file: File,
|
||||
temporary_path: PathBuf,
|
||||
final_path: PathBuf,
|
||||
relative_final_path: String,
|
||||
}
|
||||
|
||||
impl PendingMediaFile {
|
||||
pub async fn begin(root: &Path, directory: &str, id: Uuid, extension: Option<&str>) -> std::io::Result<Self> {
|
||||
let relative_directory = PathBuf::from(directory);
|
||||
let directory_path = root.join(&relative_directory);
|
||||
fs::create_dir_all(&directory_path).await?;
|
||||
|
||||
let uuid_name = id.to_string();
|
||||
let temporary_name = format!("~{uuid_name}.part");
|
||||
let final_name = match extension.filter(|value| !value.is_empty()) {
|
||||
Some(extension) => format!("{uuid_name}.{extension}"),
|
||||
None => uuid_name,
|
||||
};
|
||||
let temporary_path = directory_path.join(temporary_name);
|
||||
let final_path = directory_path.join(&final_name);
|
||||
let file = File::create(&temporary_path).await?;
|
||||
|
||||
Ok(Self {
|
||||
file,
|
||||
temporary_path,
|
||||
final_path,
|
||||
relative_final_path: relative_directory.join(final_name).to_string_lossy().into_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn write(&mut self, chunk: &[u8]) -> std::io::Result<()> {
|
||||
self.file.write_all(chunk).await
|
||||
}
|
||||
|
||||
pub async fn finish(mut self) -> std::io::Result<String> {
|
||||
self.file.flush().await?;
|
||||
self.file.sync_all().await?;
|
||||
drop(self.file);
|
||||
fs::rename(&self.temporary_path, &self.final_path).await?;
|
||||
Ok(self.relative_final_path)
|
||||
}
|
||||
|
||||
pub async fn remove_final(path: &Path) {
|
||||
let _ = fs::remove_file(path).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extension_from_filename(filename: &str) -> Option<String> {
|
||||
let name = Path::new(filename).file_name()?.to_str()?;
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let extension = ["tar.gz", "tar.bz2", "tar.xz", "tar.zst"]
|
||||
.iter()
|
||||
.find(|candidate| lower.ends_with(&format!(".{candidate}")))
|
||||
.map(|candidate| (*candidate).to_string())
|
||||
.or_else(|| Path::new(name).extension().and_then(|value| value.to_str()).map(str::to_ascii_lowercase))?;
|
||||
if extension.chars().all(|character| character.is_ascii_alphanumeric() || character == '.') {
|
||||
Some(extension)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn extension_from_mime(mime_type: &str) -> Option<&'static str> {
|
||||
match mime_type {
|
||||
"image/png" => Some("png"),
|
||||
"image/gif" => Some("gif"),
|
||||
"image/webp" => Some("webp"),
|
||||
"image/jpeg" => Some("jpg"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn cleanup_temporary_files(root: &Path, max_age: Duration) -> std::io::Result<()> {
|
||||
let mut directories = vec![root.to_path_buf()];
|
||||
let mut root_entries = match fs::read_dir(root).await {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
while let Some(entry) = root_entries.next_entry().await? {
|
||||
if entry.file_type().await?.is_dir() {
|
||||
directories.push(entry.path());
|
||||
}
|
||||
}
|
||||
|
||||
let now = SystemTime::now();
|
||||
for directory in directories {
|
||||
let mut entries = match fs::read_dir(&directory).await {
|
||||
Ok(entries) => entries,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
if !name.starts_with('~') || !name.ends_with(".part") {
|
||||
continue;
|
||||
}
|
||||
let modified = entry.metadata().await?.modified().unwrap_or(now);
|
||||
if now.duration_since(modified).unwrap_or_default() > max_age {
|
||||
let _ = fs::remove_file(entry.path()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::extension_from_filename;
|
||||
|
||||
#[test]
|
||||
fn preserves_compound_extensions() {
|
||||
assert_eq!(extension_from_filename("archive.tar.gz").as_deref(), Some("tar.gz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_simple_extensions() {
|
||||
assert_eq!(extension_from_filename("photo.PNG").as_deref(), Some("png"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn returns_none_without_an_extension() {
|
||||
assert_eq!(extension_from_filename("README").as_deref(), None);
|
||||
}
|
||||
}
|
||||
+47
-2
@@ -1,10 +1,10 @@
|
||||
use crate::domain::events::message::{
|
||||
MessageCreatedEvent, MessageDeletedEvent, MessageUpdatedEvent,
|
||||
};
|
||||
use crate::models::{channel, message};
|
||||
use crate::models::{attachment, channel, message};
|
||||
use crate::services::ServicesContext;
|
||||
use event_bus::Scope;
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, QuerySelect, Set, TransactionTrait};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set, TransactionTrait};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -67,6 +67,51 @@ impl MessageService {
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
pub async fn create_message_with_attachments(
|
||||
&self,
|
||||
channel_id: Uuid,
|
||||
author_id: Uuid,
|
||||
content: String,
|
||||
file_ids: Vec<Uuid>,
|
||||
reply_to_id: Option<Uuid>,
|
||||
) -> Result<message::Model, anyhow::Error> {
|
||||
let db = &self.service_context.repositories.server.context.db;
|
||||
let event_bus = &self.service_context.event_bus;
|
||||
let txn = db.begin().await?;
|
||||
let files = if file_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
attachment::Entity::find()
|
||||
.filter(attachment::Column::Id.is_in(file_ids.clone()))
|
||||
.all(&txn)
|
||||
.await?
|
||||
};
|
||||
if files.len() != file_ids.len()
|
||||
|| files.iter().any(|file| file.channel_id != channel_id || file.user_id != author_id || file.message_id.is_some())
|
||||
{
|
||||
return Err(anyhow::anyhow!("Invalid or already used attachment"));
|
||||
}
|
||||
let msg = message::ActiveModel {
|
||||
channel_id: Set(channel_id), user_id: Set(author_id), content: Set(content),
|
||||
reply_to_id: Set(reply_to_id), ..Default::default()
|
||||
}.insert(&txn).await?;
|
||||
for file in files {
|
||||
let mut active: attachment::ActiveModel = file.into();
|
||||
active.message_id = Set(Some(msg.id));
|
||||
active.update(&txn).await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
let server_id = channel::Entity::find_by_id(msg.channel_id)
|
||||
.select_only().column(channel::Column::ServerId)
|
||||
.into_tuple::<Option<Uuid>>().one(db).await?.flatten();
|
||||
let mut scopes = vec![Scope::uuid("channel", msg.channel_id)];
|
||||
if let Some(server_id) = server_id { scopes.push(Scope::uuid("server", server_id)); }
|
||||
event_bus.emit_scoped("message_created", scopes, MessageCreatedEvent {
|
||||
server_id, channel_id: msg.channel_id, message: msg.clone(),
|
||||
});
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
pub async fn update_message(
|
||||
&self,
|
||||
id: Uuid,
|
||||
|
||||
@@ -18,6 +18,7 @@ pub mod channel;
|
||||
pub mod emoji;
|
||||
pub mod message;
|
||||
pub mod message_reaction;
|
||||
pub mod media;
|
||||
mod permission;
|
||||
pub mod permission_sync;
|
||||
pub mod realtime_registry;
|
||||
|
||||
Reference in New Issue
Block a user