From b7378aa4dc45e65b879d48cf298269334aa61e24 Mon Sep 17 00:00:00 2001 From: Pierre HUBERT Date: Tue, 25 Nov 2025 14:54:02 +0100 Subject: [PATCH] Can retrieve room media --- .../matrix/matrix_event_controller.rs | 70 ++++++++++++++++++- .../matrix/matrix_media_controller.rs | 53 +++++++++----- .../matrix/matrix_room_controller.rs | 2 +- matrixgw_backend/src/controllers/mod.rs | 6 ++ matrixgw_backend/src/main.rs | 6 +- .../src/api/matrix/MatrixApiEvent.ts | 13 ++++ .../src/utils/RoomEventsManager.ts | 3 + .../src/widgets/messages/RoomMessagesList.tsx | 27 +++++-- 8 files changed, 156 insertions(+), 24 deletions(-) diff --git a/matrixgw_backend/src/controllers/matrix/matrix_event_controller.rs b/matrixgw_backend/src/controllers/matrix/matrix_event_controller.rs index 9064ccb..f5eab4c 100644 --- a/matrixgw_backend/src/controllers/matrix/matrix_event_controller.rs +++ b/matrixgw_backend/src/controllers/matrix/matrix_event_controller.rs @@ -1,17 +1,22 @@ use crate::controllers::HttpResult; +use crate::controllers::matrix::matrix_media_controller; +use crate::controllers::matrix::matrix_media_controller::MediaQuery; use crate::controllers::matrix::matrix_room_controller::RoomIdInPath; use crate::extractors::matrix_client_extractor::MatrixClientExtractor; -use actix_web::{HttpResponse, web}; +use actix_web::dev::Payload; +use actix_web::{FromRequest, HttpRequest, HttpResponse, web}; use futures_util::{StreamExt, stream}; use matrix_sdk::Room; use matrix_sdk::deserialized_responses::{TimelineEvent, TimelineEventKind}; +use matrix_sdk::media::MediaEventContent; use matrix_sdk::room::MessagesOptions; use matrix_sdk::room::edit::EditedContent; use matrix_sdk::ruma::events::reaction::ReactionEventContent; use matrix_sdk::ruma::events::relation::Annotation; use matrix_sdk::ruma::events::room::message::{ - RoomMessageEventContent, RoomMessageEventContentWithoutRelation, + MessageType, RoomMessageEvent, RoomMessageEventContent, RoomMessageEventContentWithoutRelation, }; +use matrix_sdk::ruma::events::{AnyMessageLikeEvent, AnyTimelineEvent}; use matrix_sdk::ruma::{MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedUserId, RoomId, UInt}; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; @@ -162,6 +167,67 @@ pub async fn set_text_content( }) } +pub async fn event_file( + req: HttpRequest, + client: MatrixClientExtractor, + path: web::Path, + event_path: web::Path, +) -> HttpResult { + let query = web::Query::::from_request(&req, &mut Payload::None).await?; + + let Some(room) = client.client.client.get_room(&path.room_id) else { + return Ok(HttpResponse::NotFound().json("Room not found!")); + }; + + let event = match room.load_or_fetch_event(&event_path.event_id, None).await { + Ok(event) => event, + Err(e) => { + log::error!("Failed to load event information! {e}"); + return Ok(HttpResponse::InternalServerError() + .json(format!("Failed to load event information! {e}"))); + } + }; + + let event = match event.kind { + TimelineEventKind::Decrypted(dec) => dec.event.deserialize()?, + TimelineEventKind::UnableToDecrypt { event, .. } + | TimelineEventKind::PlainText { event } => event + .deserialize()? + .into_full_event(room.room_id().to_owned()), + }; + + let AnyTimelineEvent::MessageLike(message) = event else { + return Ok(HttpResponse::BadRequest().json("Event is not message like!")); + }; + + let AnyMessageLikeEvent::RoomMessage(message) = message else { + return Ok(HttpResponse::BadRequest().json("Event is not a room message!")); + }; + + let RoomMessageEvent::Original(message) = message else { + return Ok(HttpResponse::BadRequest().json("Event has been redacted!")); + }; + + let (source, thumb_source) = match message.content.msgtype { + MessageType::Audio(c) => (c.source(), c.thumbnail_source()), + MessageType::File(c) => (c.source(), c.thumbnail_source()), + MessageType::Image(c) => (c.source(), c.thumbnail_source()), + MessageType::Location(c) => (c.source(), c.thumbnail_source()), + MessageType::Video(c) => (c.source(), c.thumbnail_source()), + _ => (None, None), + }; + + println!("{source:#?} {thumb_source:#?}"); + + let source = match (query.thumbnail, source, thumb_source) { + (false, Some(s), _) => s, + (true, _, Some(s)) => s, + _ => return Ok(HttpResponse::NotFound().json("Requested file not available!")), + }; + + matrix_media_controller::serve_media(req, source, false).await +} + #[derive(Deserialize)] struct EventReactionBody { key: String, diff --git a/matrixgw_backend/src/controllers/matrix/matrix_media_controller.rs b/matrixgw_backend/src/controllers/matrix/matrix_media_controller.rs index 51c485f..659b910 100644 --- a/matrixgw_backend/src/controllers/matrix/matrix_media_controller.rs +++ b/matrixgw_backend/src/controllers/matrix/matrix_media_controller.rs @@ -4,19 +4,35 @@ use crate::utils::crypt_utils::sha512; use actix_web::dev::Payload; use actix_web::http::header; use actix_web::{FromRequest, HttpRequest, HttpResponse, web}; +use matrix_sdk::crypto::{AttachmentDecryptor, MediaEncryptionInfo}; use matrix_sdk::media::{MediaFormat, MediaRequestParameters, MediaThumbnailSettings}; use matrix_sdk::ruma::events::room::MediaSource; use matrix_sdk::ruma::{OwnedMxcUri, UInt}; +use std::io::{Cursor, Read}; #[derive(serde::Deserialize)] -struct MediaQuery { +pub struct MediaMXCInPath { + mxc: OwnedMxcUri, +} + +/// Serve media resource handler +pub async fn serve_mxc_handler(req: HttpRequest, media: web::Path) -> HttpResult { + serve_mxc_file(req, media.into_inner().mxc).await +} + +#[derive(serde::Deserialize)] +pub struct MediaQuery { #[serde(default)] - thumbnail: bool, + pub thumbnail: bool, +} +pub async fn serve_mxc_file(req: HttpRequest, media: OwnedMxcUri) -> HttpResult { + let query = web::Query::::from_request(&req, &mut Payload::None).await?; + + serve_media(req, MediaSource::Plain(media), query.thumbnail).await } /// Serve a media file -pub async fn serve_media(req: HttpRequest, media: OwnedMxcUri) -> HttpResult { - let query = web::Query::::from_request(&req, &mut Payload::None).await?; +pub async fn serve_media(req: HttpRequest, source: MediaSource, thumbnail: bool) -> HttpResult { let client = MatrixClientExtractor::from_request(&req, &mut Payload::None).await?; let media = client @@ -25,8 +41,8 @@ pub async fn serve_media(req: HttpRequest, media: OwnedMxcUri) -> HttpResult { .media() .get_media_content( &MediaRequestParameters { - source: MediaSource::Plain(media), - format: match query.thumbnail { + source: source.clone(), + format: match thumbnail { true => MediaFormat::Thumbnail(MediaThumbnailSettings::new( UInt::new(100).unwrap(), UInt::new(100).unwrap(), @@ -38,6 +54,21 @@ pub async fn serve_media(req: HttpRequest, media: OwnedMxcUri) -> HttpResult { ) .await?; + // Decrypt file if needed + let media = if let MediaSource::Encrypted(file) = source { + let mut cursor = Cursor::new(media); + let mut decryptor = + AttachmentDecryptor::new(&mut cursor, MediaEncryptionInfo::from(*file))?; + + let mut decrypted_data = Vec::new(); + + decryptor.read_to_end(&mut decrypted_data)?; + + decrypted_data + } else { + media + }; + let digest = sha512(&media); let mime_type = infer::get(&media).map(|x| x.mime_type()); @@ -55,13 +86,3 @@ pub async fn serve_media(req: HttpRequest, media: OwnedMxcUri) -> HttpResult { .insert_header(("cache-control", "max-age=360000")) .body(media)) } - -#[derive(serde::Deserialize)] -pub struct MediaMXCInPath { - mxc: OwnedMxcUri, -} - -/// Save media resource handler -pub async fn serve_media_res(req: HttpRequest, media: web::Path) -> HttpResult { - serve_media(req, media.into_inner().mxc).await -} diff --git a/matrixgw_backend/src/controllers/matrix/matrix_room_controller.rs b/matrixgw_backend/src/controllers/matrix/matrix_room_controller.rs index 20461e9..1886169 100644 --- a/matrixgw_backend/src/controllers/matrix/matrix_room_controller.rs +++ b/matrixgw_backend/src/controllers/matrix/matrix_room_controller.rs @@ -111,5 +111,5 @@ pub async fn room_avatar( return Ok(HttpResponse::NotFound().json("Room has no avatar")); }; - matrix_media_controller::serve_media(req, uri).await + matrix_media_controller::serve_mxc_file(req, uri).await } diff --git a/matrixgw_backend/src/controllers/mod.rs b/matrixgw_backend/src/controllers/mod.rs index cab029c..75ceeed 100644 --- a/matrixgw_backend/src/controllers/mod.rs +++ b/matrixgw_backend/src/controllers/mod.rs @@ -24,6 +24,12 @@ pub enum HttpFailure { ActixError(#[from] actix_web::Error), #[error("Matrix error: {0}")] MatrixError(#[from] matrix_sdk::Error), + #[error("Matrix decryptor error: {0}")] + MatrixDecryptorError(#[from] matrix_sdk::encryption::DecryptorError), + #[error("Serde JSON error: {0}")] + SerdeJSON(#[from] serde_json::Error), + #[error("Standard library error: {0}")] + StdLibError(#[from] std::io::Error), } impl ResponseError for HttpFailure { diff --git a/matrixgw_backend/src/main.rs b/matrixgw_backend/src/main.rs index f79d018..7623f65 100644 --- a/matrixgw_backend/src/main.rs +++ b/matrixgw_backend/src/main.rs @@ -177,6 +177,10 @@ async fn main() -> std::io::Result<()> { "/api/matrix/room/{room_id}/event/{event_id}/set_text_content", web::post().to(matrix_event_controller::set_text_content), ) + .route( + "/api/matrix/room/{room_id}/event/{event_id}/file", + web::get().to(matrix_event_controller::event_file), + ) .route( "/api/matrix/room/{room_id}/event/{event_id}/react", web::post().to(matrix_event_controller::react_to_event), @@ -188,7 +192,7 @@ async fn main() -> std::io::Result<()> { // Matrix media controller .route( "/api/matrix/media/{mxc}", - web::get().to(matrix_media_controller::serve_media_res), + web::get().to(matrix_media_controller::serve_mxc_handler), ) }) .workers(4) diff --git a/matrixgw_frontend/src/api/matrix/MatrixApiEvent.ts b/matrixgw_frontend/src/api/matrix/MatrixApiEvent.ts index 01c2d9a..f24dd1b 100644 --- a/matrixgw_frontend/src/api/matrix/MatrixApiEvent.ts +++ b/matrixgw_frontend/src/api/matrix/MatrixApiEvent.ts @@ -67,4 +67,17 @@ export class MatrixApiEvent { }) ).data; } + + /** + * Get Matrix event file URL + */ + static GetEventFileURL( + room: Room, + event_id: string, + thumbnail: boolean + ): string { + return `${APIClient.ActualBackendURL()}/matrix/room/${ + room.id + }/event/${event_id}/file?thumbnail=${thumbnail}`; + } } diff --git a/matrixgw_frontend/src/utils/RoomEventsManager.ts b/matrixgw_frontend/src/utils/RoomEventsManager.ts index 4f74bb1..5a8f72d 100644 --- a/matrixgw_frontend/src/utils/RoomEventsManager.ts +++ b/matrixgw_frontend/src/utils/RoomEventsManager.ts @@ -1,3 +1,4 @@ +import dayjs from "dayjs"; import type { MatrixEvent, MatrixEventsList, @@ -14,6 +15,7 @@ export interface Message { event_id: string; account: string; time_sent: number; + time_sent_dayjs: dayjs.Dayjs; modified: boolean; reactions: MessageReaction[]; content: string; @@ -80,6 +82,7 @@ export class RoomEventsManager { modified: false, reactions: [], time_sent: evt.time, + time_sent_dayjs: dayjs.unix(evt.time / 1000), image: data.content.file?.url, content: data.content.body, }); diff --git a/matrixgw_frontend/src/widgets/messages/RoomMessagesList.tsx b/matrixgw_frontend/src/widgets/messages/RoomMessagesList.tsx index 1848fcd..1455a43 100644 --- a/matrixgw_frontend/src/widgets/messages/RoomMessagesList.tsx +++ b/matrixgw_frontend/src/widgets/messages/RoomMessagesList.tsx @@ -1,10 +1,11 @@ -import dayjs from "dayjs"; +import { MatrixApiEvent } from "../../api/matrix/MatrixApiEvent"; import type { UsersMap } from "../../api/matrix/MatrixApiProfile"; +import type { Room } from "../../api/matrix/MatrixApiRoom"; import type { Message, RoomEventsManager } from "../../utils/RoomEventsManager"; import { AccountIcon } from "./AccountIcon"; -import { MatrixApiMedia } from "../../api/matrix/MatrixApiMedia"; export function RoomMessagesList(p: { + room: Room; users: UsersMap; mgr: RoomEventsManager; }): React.ReactElement { @@ -20,6 +21,11 @@ export function RoomMessagesList(p: { p.mgr.messages[idx - 1].account === m.account && m.time_sent - p.mgr.messages[idx - 1].time_sent < 60 * 3 * 1000 } + firstMessageOfDay={ + idx === 0 || + m.time_sent_dayjs.startOf("day").unix() != + p.mgr.messages[idx - 1].time_sent_dayjs.startOf("day").unix() + } /> ))} @@ -27,13 +33,20 @@ export function RoomMessagesList(p: { } function RoomMessage(p: { + room: Room; users: UsersMap; message: Message; previousFromSamePerson: boolean; + firstMessageOfDay: boolean; }): React.ReactElement { const user = p.users.get(p.message.account); return ( <> + {p.firstMessageOfDay && ( +
+ {p.message.time_sent_dayjs.format("DD/MM/YYYY")} +
+ )} {!p.previousFromSamePerson && user && (
- {dayjs.unix(p.message.time_sent / 1000).format("HH:mm")}{" "} + {p.message.time_sent_dayjs.format("HH:mm")}{" "} {p.message.image ? ( - + ) : ( p.message.content )}