Backend: MD5 Perceptual Hash Naming for Images

This commit is contained in:
ADAMJR 2021-11-03 16:34:25 +00:00
parent 1202120d84
commit 28efa87729
13 changed files with 88 additions and 884 deletions

File diff suppressed because it is too large Load Diff

View File

@ -23,9 +23,9 @@
"express-async-errors": "^3.1.1",
"express-rate-limit": "^5.2.6",
"faker": "^5.4.0",
"get-stream": "^6.0.1",
"got": "^11.7.0",
"helmet": "^4.4.1",
"image-hash": "^4.0.1",
"imghash": "^0.0.9",
"jsonwebtoken": "^8.5.1",
"mongoose": "^5.10.7",

View File

@ -11,16 +11,16 @@ export default class Messages extends DBWrapper<string, MessageDocument> {
return message;
}
public async create(authorId: string, channelId: string, { attachmentIds, content }: Partial<Entity.Message>) {
public async create(authorId: string, channelId: string, { attachmentURLs, content }: Partial<Entity.Message>) {
if (!content)
throw new TypeError('Content must be provided');
// TODO: TESTME
if (!content && !attachmentIds?.length)
if (!content && !attachmentURLs?.length)
throw new TypeError('Empty messages are not valid');
return await Message.create({
_id: generateSnowflake(),
attachmentIds,
attachmentURLs,
authorId,
channelId,
content,

View File

@ -14,7 +14,7 @@ export const Message = model<MessageDocument>('message', new Schema({
type: String,
default: generateSnowflake,
},
attachmentIds: [String],
attachmentURLs: [String],
authorId: {
type: String,
required: [true, 'Author ID is required'],

View File

@ -7,32 +7,36 @@ import { User } from '../../data/models/user';
import rateLimiter from '../modules/rate-limiter';
import multer from 'multer';
import { generateSnowflake } from '../../data/snowflake-entity';
import { imageHash } from 'image-hash';
import path, { extname, resolve } from 'path';
import { promisify } from 'util';
import { APIError } from '../modules/api-error';
import crypto from 'crypto';
import getStream from 'get-stream';
function setupMulter(app: Application) {
const storage = multer.diskStorage({
destination: (req, fileMeta, cb) => cb(null, resolve('./assets/upload')),
filename: async (req, fileMeta, cb) => {
if (!fileMeta.mimetype.includes('image'))
destination: (req, file, cb) => cb(null, resolve('./assets/upload')),
filename: async (req, file, cb) => {
if (!file.mimetype.includes('image'))
throw new APIError(400, 'Only images can be uploaded at this time');
// const hash = promisify(imageHash);
// const hashObj = await hash(file.buffer, 16, true) as object;
// console.log(hashObj);
// console.log(file);
cb(null, Date.now() + extname(fileMeta.originalname));
const buffer = await getStream(file.stream);
const hash = crypto
.createHash('md5')
.update(buffer)
.digest('hex');
console.log(hash);
file['newName'] = hash + extname(file.originalname);
cb(null, file['newName']);
},
});
const upload = multer({ storage });
// TODO: validate is logged in, etc.
app.post('/v2/upload', upload.single('file'), (req, res) => {
res.status(201).json({ message: 'Files uploaded' });
const fileName = req.file!['newName'];
res.status(201).json({ url: `${process.env.ROOT_ENDPOINT}/assets/upload/${fileName}` });
});
}
function setupPassport(app: Application) {

View File

@ -1,30 +1,23 @@
import { Socket } from 'socket.io';
import { WebSocket } from '../websocket';
import { WSEvent, } from './ws-event';
import { WSGuard } from '../modules/ws-guard';
import { WS } from '../../types/ws';
import { promisify } from 'util';
import { stat } from 'fs';
import { resolve } from 'path';
import { Channel } from '../../data/models/channel';
const statAsync = promisify(stat);
export default class implements WSEvent<'MESSAGE_CREATE'> {
on = 'MESSAGE_CREATE' as const;
constructor(
private messages = deps.messages,
private guard = deps.wsGuard,
private users = deps.users,
) {}
public async invoke(ws: WebSocket, client: Socket, { attachmentIds, channelId, content, embed }: WS.Params.MessageCreate) {
public async invoke(ws: WebSocket, client: Socket, { attachmentURLs, channelId, content, embed }: WS.Params.MessageCreate) {
const authorId = ws.sessions.userId(client);
const [_, message, author] = await Promise.all([
this.guard.validateCanInChannel(client, channelId, 'SEND_MESSAGES'),
this.messages.create(authorId, channelId, { attachmentIds, content, embed }),
this.users.getSelf(authorId),
deps.wsGuard.validateCanInChannel(client, channelId, 'SEND_MESSAGES'),
deps.messages.create(authorId, channelId, { attachmentURLs, content, embed }),
deps.users.getSelf(authorId),
]);
author.lastReadMessageIds ??= {};

View File

@ -43,9 +43,12 @@ const MessageContent: FunctionComponent<MessageContentProps> = ({ message }) =>
.replace(patterns.blockQuoteMultiline, '<div class="blockquote pl-1">$1</div>')
.replace(defaultPatterns.url, '<a href="$1" target="_blank">$1</div>');
// TODO: get metadata via fetch request, before rendering image
const messageHTML =
`${message.content && format(striptags(message.content))}` +
`${message.attachments?.map(a => `<img src=${a.url} alt=${a.name} title=${a.name} />`)}`;
`${message.attachmentURLs?.map(hash =>
`<img src="${process.env.REACT_APP_CDN_URL}/uploads/${hash}"`)
}`;
return (editingMessageId === message.id)
? <MessageBox

View File

@ -17,7 +17,7 @@ export interface APIArgs {
onSuccess?: string[];
url: string;
/** Callback to handle side effects. */
callback?: (payload: any) => any;
callback?: (payload: any) => any | Promise<any>;
}
export interface WSArgs {
data?: object;

View File

@ -48,28 +48,26 @@ export const fetchMessages = (channelId: string) => (dispatch, getState: () => S
}));
}
export const createMessage = (channelId: string, payload: Partial<Entity.Message>, file?: File) => (dispatch) => {
export const createMessage = (channelId: string, payload: Partial<Entity.Message>, attachmentURLs?: string[]) => (dispatch) => {
dispatch(api.wsCallBegan({
event: 'MESSAGE_CREATE',
data: { ...payload, channelId, attachmentIds: [
] } as WS.Params.MessageCreate,
data: { ...payload, channelId, attachmentURLs } as WS.Params.MessageCreate,
}));
}
// each file is uploaded individually as a separate API call
export const uploadFileAsMessage = (channelId: string, payload: Partial<Entity.Message>, file: File) => (dispatch) => {
const formData = new FormData();
formData.append('file', file, file.name);
formData.append('file', file);
dispatch(api.restCallBegan({
method: 'post',
url: '/upload',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' },
callback: async (resPayload: string[]) =>
dispatch(createMessage(channelId, payload, resPayload)),
}));
dispatch(createMessage(channelId, payload, file));
}
export const updateMessage = (id: string, payload: Partial<Entity.Message>) => (dispatch) => {

View File

@ -2,7 +2,7 @@ import axios from 'axios';
import { actions, APIArgs } from '../api';
import { openDialog } from '../ui';
export default store => next => async action => {
export default (store) => (next) => async (action) => {
if (action.type !== actions.restCallBegan.type)
return next(action);
@ -25,7 +25,7 @@ export default store => next => async action => {
store.dispatch({ type, payload });
// called after dispatch events
callback && callback(payload);
callback && await callback(payload);
} catch (error) {
const response = (error as any).response;
store.dispatch(actions.restCallFailed({ url, response }));

View File

@ -1,7 +1,7 @@
import { actions } from '../api';
import ws from '../../services/ws-service';
export default store => next => async action => {
export default (store) => (next) => async (action) => {
if (action.type !== actions.wsCallBegan.type)
return next(action);

2
types/entity.d.ts vendored
View File

@ -43,7 +43,7 @@ declare namespace Entity {
}
export interface Message {
id: string;
attachmentIds?: string[];
attachmentURLs?: string[];
authorId: string;
channelId: string;
content?: string;

2
types/ws.d.ts vendored
View File

@ -194,7 +194,7 @@ declare namespace WS {
export interface MessageCreate {
channelId: string;
content?: string;
attachmentIds?: string[];
attachmentURLs?: string[];
embed?: MessageTypes.Embed;
}
export interface MessageDelete {