Merge pull request #913 from Hexastack/fix/attachment-issues
Some checks are pending
Build and Push Docker API Image / build-and-push (push) Waiting to run
Build and Push Docker Base Image / build-and-push (push) Waiting to run
Build and Push Docker UI Image / build-and-push (push) Waiting to run

Fix: attachment issues
This commit is contained in:
Yassine
2025-04-25 08:01:11 +01:00
committed by GitHub
11 changed files with 244 additions and 56 deletions

View File

@@ -308,21 +308,20 @@ export default abstract class ChannelHandler<
* @return A signed URL string for downloading the specified attachment.
*/
public async getPublicUrl(attachment: AttachmentRef | Attachment) {
const [name, _suffix] = this.getName().split('-');
if ('id' in attachment) {
if (!attachment.id) {
throw new TypeError(
'Attachment ID is empty, unable to generate public URL.',
);
if (!attachment || !attachment.id) {
return buildURL(config.apiBaseUrl, `/webhook/${name}/not-found`);
}
const resource = await this.attachmentService.findOne(attachment.id);
if (!resource) {
throw new NotFoundException('Unable to find attachment');
this.logger.warn('Unable to find attachment sending fallback image');
return buildURL(config.apiBaseUrl, `/webhook/${name}/not-found`);
}
const token = this.jwtService.sign({ ...resource }, this.jwtSignOptions);
const [name, _suffix] = this.getName().split('-');
return buildURL(
config.apiBaseUrl,
`/webhook/${name}/download/${resource.name}?t=${encodeURIComponent(token)}`,
@@ -331,7 +330,7 @@ export default abstract class ChannelHandler<
// In case the url is external
return attachment.url;
} else {
throw new TypeError('Unable to resolve the attachment public URL.');
return buildURL(config.apiBaseUrl, `/webhook/${name}/not-found`);
}
}

View File

@@ -88,4 +88,10 @@ export class WebhookController {
this.logger.log('Channel notification : ', req.method, channel);
return await this.channelService.handle(channel, req, res);
}
@Roles('public')
@Get(':channel/not-found')
async handleNotFound(@Res() res: Response) {
return res.status(404).send({ error: 'Not found!' });
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@@ -11,6 +11,9 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { MongooseModule } from '@nestjs/mongoose';
import { AttachmentModule } from '@/attachment/attachment.module';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { ChannelModule } from '@/channel/channel.module';
import { CmsModule } from '@/cms/cms.module';
import { UserModule } from '@/user/user.module';
@@ -58,6 +61,7 @@ import { SubscriberService } from './services/subscriber.service';
SubscriberModel,
ConversationModel,
SubscriberModel,
AttachmentModel,
]),
forwardRef(() => ChannelModule),
CmsModule,
@@ -92,6 +96,8 @@ import { SubscriberService } from './services/subscriber.service';
ConversationService,
ChatService,
BotService,
AttachmentService,
AttachmentRepository,
],
exports: [
SubscriberService,

View File

@@ -11,8 +11,14 @@ import {
InternalServerErrorException,
Optional,
} from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { Document, Query } from 'mongoose';
import { Attachment } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import { DeleteResult } from '@/utils/generics/base-repository';
import { BaseService } from '@/utils/generics/base-service';
import { TFilterQuery } from '@/utils/types/filter.types';
import {
SocketGet,
SocketPost,
@@ -39,6 +45,7 @@ export class MessageService extends BaseService<
constructor(
private readonly messageRepository: MessageRepository,
private attachmentService: AttachmentService,
@Optional() gateway?: WebsocketGateway,
) {
super(messageRepository);
@@ -127,4 +134,40 @@ export class MessageService extends BaseService<
return lastMessages.reverse();
}
@OnEvent('hook:attachment:preDelete')
async handleDeleteImage(
_query: Query<
DeleteResult,
Document<Attachment, any, any>,
unknown,
Attachment,
'deleteOne' | 'deleteMany'
>,
criteria: TFilterQuery<Attachment>,
) {
try {
this.logger.log(
'deleting attachment messages containing deleted images',
criteria,
);
const foundAttachments = await this.attachmentService.find(criteria);
for (const attachment of foundAttachments) {
await this.updateMany(
{
'message.attachment.payload.id': attachment.id,
},
{
['message.attachment.payload.id' as any]: null,
},
);
}
} catch (error) {
this.logger.error(
'Unable to cleanup old messages with attachment ids',
error,
);
}
}
}

View File

@@ -116,7 +116,11 @@
"select_category": "Select a flow",
"logout_failed": "Something went wrong during logout",
"duplicate_labels_not_allowed": "Duplicate labels are not allowed",
"duplicate_block_error": "Something went wrong while duplicating block"
"duplicate_block_error": "Something went wrong while duplicating block",
"image_error": "Image not found",
"file_error": "File not found",
"audio_error": "Audio not found",
"video_error": "Video not found"
},
"menu": {
"terms": "Terms of Use",

View File

@@ -116,7 +116,11 @@
"select_category": "Sélectionner une catégorie",
"logout_failed": "Une erreur s'est produite lors de la déconnexion",
"duplicate_labels_not_allowed": "Les étiquettes en double ne sont pas autorisées",
"duplicate_block_error": "Une erreur est survenue lors de la duplication du bloc"
"duplicate_block_error": "Une erreur est survenue lors de la duplication du bloc",
"image_error": "Image introuvable",
"file_error": "Fichier introuvable",
"audio_error": "Audio introuvable",
"video_error": "Vidéo introuvable"
},
"menu": {
"terms": "Conditions d'utilisation",

View File

@@ -8,7 +8,7 @@
import DownloadIcon from "@mui/icons-material/Download";
import { Box, Button, Typography } from "@mui/material";
import { FC } from "react";
import { FC, useState } from "react";
import { useDialogs } from "@/hooks/useDialogs";
import { useGetAttachmentMetadata } from "@/hooks/useGetAttachmentMetadata";
@@ -30,11 +30,17 @@ interface AttachmentInterface {
const componentMap: { [key in FileType]: FC<AttachmentInterface> } = {
[FileType.image]: ({ url }: AttachmentInterface) => {
const dialogs = useDialogs();
const [imageErrored, setImageErrored] = useState(false);
const { t } = useTranslate();
if (imageErrored || !url) {
return <p>{t("message.image_error")}</p>;
}
if (url)
return (
// eslint-disable-next-line @next/next/no-img-element
<img
onError={() => setImageErrored(true)}
width="auto"
height={200}
style={{ objectFit: "contain", cursor: "pointer" }}
@@ -59,11 +65,24 @@ const componentMap: { [key in FileType]: FC<AttachmentInterface> } = {
);
},
[FileType.audio]: (props: AttachmentInterface) => {
return <audio controls src={props.url} />;
const [audioErrored, setAudioErrored] = useState(false);
const { t } = useTranslate();
if (audioErrored || !props.url) {
return <p>{t("message.audio_error")}</p>;
}
return (
<audio controls src={props.url} onError={() => setAudioErrored(true)} />
);
},
[FileType.file]: (props: AttachmentInterface) => {
const { t } = useTranslate();
if (!props.url) {
return <p>{t("message.file_error")}</p>;
}
return (
<Box>
<Typography
@@ -84,11 +103,20 @@ const componentMap: { [key in FileType]: FC<AttachmentInterface> } = {
</Box>
);
},
[FileType.video]: ({ url }: AttachmentInterface) => (
<video controls width="250">
<source src={url} />
</video>
),
[FileType.video]: ({ url }: AttachmentInterface) => {
const [videoErrored, setVideoErrored] = useState(false);
const { t } = useTranslate();
if (videoErrored) {
return <p>{t("message.video_error")}</p>;
}
return (
<video controls width="250">
<source src={url} onError={() => setVideoErrored(true)} />
</video>
);
},
[FileType.unknown]: ({ url }: AttachmentInterface) => <>Unknown Type:{url}</>,
};

View File

@@ -10,6 +10,15 @@
video {
max-width: 100%;
}
.error-message {
margin: 0;
// padding: 10px 0 10px 10px;
padding: 10px;
border-radius: 5px;
color: #666;
width: 100%;
display: block;
}
}
.sc-message--content.sent .sc-message--file {

View File

@@ -1,20 +1,52 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import React from "react";
import React, { useState } from "react";
import { useTranslation } from "../../hooks/useTranslation";
import { useColors } from "../../providers/ColorProvider";
import { Direction, TMessage } from "../../types/message.types";
import FileIcon from "../icons/FileIcon";
import "./FileMessage.scss";
const getFileMessageType = (
message: TMessage,
): "image" | "audio" | "video" | "file" | "unknown" => {
if (!("type" in message.data)) {
throw new Error("Unable to detect type for file message");
}
const type = message.data.type;
if (type === "unknown") {
return "unknown";
}
if (["image", "audio", "video", "file"].includes(type)) {
return type as "image" | "audio" | "video" | "file";
}
throw new Error("Unknown type for file message");
};
const hasUrl = (
message: TMessage,
): message is TMessage & { data: { url: string } } => {
return (
typeof message === "object" &&
message !== null &&
"data" in message &&
typeof message.data === "object" &&
message.data !== null &&
"url" in message.data &&
typeof message.data.url === "string"
);
};
interface FileMessageProps {
message: TMessage;
}
@@ -23,19 +55,25 @@ const FileMessage: React.FC<FileMessageProps> = ({ message }) => {
const { t } = useTranslation();
const { colors: allColors } = useColors();
const colors = allColors[message.direction || Direction.received];
const [videoErrored, setVideoErrored] = useState(false);
const [audioErrored, setAudioErrored] = useState(false);
const [imageErrored, setImageErrored] = useState(false);
const type = getFileMessageType(message);
if (!("type" in message.data)) {
throw new Error("Unable to detect type for file message");
}
if (
message.data &&
message.data.type !== "image" &&
message.data.type !== "audio" &&
message.data.type !== "video" &&
message.data.type !== "file"
) {
throw new Error("Uknown type for file message");
if (type === "unknown") {
return (
<div
className="sc-message--file"
style={{
color: colors.text,
backgroundColor: colors.bg,
}}
>
<p className="error-message">
{t("messages.file_message.unsupported_file_type")}
</p>
</div>
);
}
return (
@@ -46,28 +84,65 @@ const FileMessage: React.FC<FileMessageProps> = ({ message }) => {
backgroundColor: colors.bg,
}}
>
{message.data.type === "image" && (
{type === "image" && (
<div className="sc-message--file-icon">
<img src={message.data.url || ""} className="sc-image" alt="File" />
{imageErrored ? (
<p
className="error-message"
style={{
backgroundColor: colors.bg,
}}
>
{t("messages.file_message.image_error")}
</p>
) : (
<img
onError={() => setImageErrored(true)}
src={hasUrl(message) ? message.data.url : ""}
className="sc-image"
alt="File"
/>
)}
</div>
)}
{message.data.type === "audio" && (
{type === "audio" && (
<div className="sc-message--file-audio">
<audio controls>
<source src={message.data.url} />
{t("messages.file_message.browser_audio_unsupport")}
</audio>
{audioErrored ? (
<p
className="error-message"
style={{
backgroundColor: colors.bg,
}}
>
{t("messages.file_message.audio_error")}
</p>
) : (
<audio controls onError={() => setAudioErrored(true)}>
<source src={hasUrl(message) ? message.data.url : ""} />
</audio>
)}
</div>
)}
{message.data.type === "video" && (
{type === "video" && (
<div className="sc-message--file-video">
<video controls width="100%">
<source src={message.data.url} />
{t("messages.file_message.browser_video_unsupport")}
</video>
{videoErrored ? (
<p
className="error-message"
style={{
backgroundColor: colors.bg,
}}
>
{t("messages.file_message.video_error")}
</p>
) : (
<video controls width="100%" onError={() => setVideoErrored(true)}>
<source src={hasUrl(message) ? message.data.url : ""} />
{t("messages.file_message.browser_video_unsupport")}
</video>
)}
</div>
)}
{message.data.type === "file" && (
{type === "file" && (
<div
className="sc-message--file-download"
style={{
@@ -75,15 +150,21 @@ const FileMessage: React.FC<FileMessageProps> = ({ message }) => {
backgroundColor: colors.bg,
}}
>
<a
href={message.data.url ? message.data.url : "#"}
target="_blank"
rel="noopener noreferrer"
download
>
<FileIcon />
{t("messages.file_message.download")}
</a>
{!hasUrl(message) ? (
<p className="error-message" style={{ padding: 0 }}>
{t("messages.file_message.file_error")}
</p>
) : (
<a
href={hasUrl(message) ? message.data.url : "#"}
target="_blank"
rel="noopener noreferrer"
download
>
<FileIcon />
{t("messages.file_message.download")}
</a>
)}
</div>
)}
</div>

View File

@@ -17,7 +17,11 @@
"browser_video_unsupport": "Browser does not support the video element.",
"download": "Download",
"unsupported_file_type": "This file type is not supported.",
"unsupported_file_size": "This file size is not supported."
"unsupported_file_size": "This file size is not supported.",
"image_error": "Image not found",
"file_error": "File not found",
"audio_error": "Audio not found",
"video_error": "Video not found"
}
}
}

View File

@@ -17,7 +17,11 @@
"browser_video_unsupport": "Le navigateur ne prend pas en charge l'élément vidéo.",
"download": "Télécharger",
"unsupported_file_type": "Ce type de fichier n'est pas pris en charge.",
"unsupported_file_size": "Cette taille de fichier n'est pas prise en charge."
"unsupported_file_size": "Cette taille de fichier n'est pas prise en charge.",
"image_error": "Image introuvable",
"file_error": "Fichier introuvable",
"audio_error": "Audio introuvable",
"video_error": "Vidéo introuvable"
}
}
}