forked from atums.world/atums.world
add thumbnails for images and videos, fix delete route not seing query, fix not using reservation in user info route and add file and folder count
This commit is contained in:
parent
f917849f4e
commit
6a55f9f5a9
11 changed files with 296 additions and 19 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -2,3 +2,4 @@
|
||||||
bun.lock
|
bun.lock
|
||||||
.env
|
.env
|
||||||
/uploads
|
/uploads
|
||||||
|
.idea
|
||||||
|
|
|
@ -13,6 +13,7 @@ const defaultSettings: Setting[] = [
|
||||||
{ key: "require_email_verification", value: "false" },
|
{ key: "require_email_verification", value: "false" },
|
||||||
{ key: "date_format", value: "yyyy-MM-dd_HH-mm-ss" },
|
{ key: "date_format", value: "yyyy-MM-dd_HH-mm-ss" },
|
||||||
{ key: "random_name_length", value: "8" },
|
{ key: "random_name_length", value: "8" },
|
||||||
|
{ key: "enable_thumbnails", value: "true" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
export async function createTable(reservation?: ReservedSQL): Promise<void> {
|
||||||
|
|
|
@ -11,8 +11,10 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.22.0",
|
"@eslint/js": "^9.22.0",
|
||||||
"@types/bun": "^1.2.4",
|
"@types/bun": "^1.2.5",
|
||||||
"@types/ejs": "^3.1.5",
|
"@types/ejs": "^3.1.5",
|
||||||
|
"@types/fluent-ffmpeg": "^2.1.27",
|
||||||
|
"@types/image-thumbnail": "^1.0.4",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
"@typescript-eslint/eslint-plugin": "^8.26.1",
|
||||||
"@typescript-eslint/parser": "^8.26.1",
|
"@typescript-eslint/parser": "^8.26.1",
|
||||||
|
@ -32,6 +34,8 @@
|
||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"exiftool-vendored": "^29.1.0",
|
"exiftool-vendored": "^29.1.0",
|
||||||
"fast-jwt": "^5.0.5",
|
"fast-jwt": "^5.0.5",
|
||||||
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
|
"image-thumbnail": "^1.0.17",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"redis": "^4.7.0"
|
"redis": "^4.7.0"
|
||||||
}
|
}
|
||||||
|
|
|
@ -146,6 +146,6 @@ export function supportsExif(mimeType: string, extension: string): boolean {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function supportsThumbnails(mimeType: string): boolean {
|
export function supportsThumbnail(mimeType: string): boolean {
|
||||||
return /^(image\/(?!svg+xml)|video\/)/i.test(mimeType);
|
return /^(image\/(?!svg+xml)|video\/)/i.test(mimeType);
|
||||||
}
|
}
|
||||||
|
|
|
@ -130,17 +130,17 @@ class Logger {
|
||||||
}
|
}
|
||||||
|
|
||||||
public error(
|
public error(
|
||||||
message: string | Error | (string | Error)[],
|
message: string | Error | ErrorEvent | (string | Error)[],
|
||||||
breakLine: boolean = false,
|
breakLine: boolean = false,
|
||||||
): void {
|
): void {
|
||||||
const stack: string = new Error().stack || "";
|
const stack: string = new Error().stack || "";
|
||||||
const { filename, timestamp } = this.getCallerInfo(stack);
|
const { filename, timestamp } = this.getCallerInfo(stack);
|
||||||
|
|
||||||
const messages: (string | Error)[] = Array.isArray(message)
|
const messages: (string | Error | ErrorEvent)[] = Array.isArray(message)
|
||||||
? message
|
? message
|
||||||
: [message];
|
: [message];
|
||||||
const joinedMessage: string = messages
|
const joinedMessage: string = messages
|
||||||
.map((msg: string | Error): string =>
|
.map((msg: string | Error | ErrorEvent): string =>
|
||||||
typeof msg === "string" ? msg : msg.message,
|
typeof msg === "string" ? msg : msg.message,
|
||||||
)
|
)
|
||||||
.join(" ");
|
.join(" ");
|
||||||
|
|
208
src/helpers/workers/thumbnails.ts
Normal file
208
src/helpers/workers/thumbnails.ts
Normal file
|
@ -0,0 +1,208 @@
|
||||||
|
import { dataType } from "@config/environment.ts";
|
||||||
|
import { logger } from "@helpers/logger.ts";
|
||||||
|
import { type BunFile, s3, sql } from "bun";
|
||||||
|
import ffmpeg from "fluent-ffmpeg";
|
||||||
|
import imageThumbnail from "image-thumbnail";
|
||||||
|
import { join, resolve } from "path";
|
||||||
|
|
||||||
|
declare var self: Worker;
|
||||||
|
|
||||||
|
async function generateVideoThumbnail(
|
||||||
|
filePath: string,
|
||||||
|
thumbnailPath: string,
|
||||||
|
): Promise<ArrayBuffer | null> {
|
||||||
|
return new Promise(
|
||||||
|
(
|
||||||
|
resolve: (value: ArrayBuffer) => void,
|
||||||
|
reject: (reason: Error) => void,
|
||||||
|
): void => {
|
||||||
|
ffmpeg(filePath)
|
||||||
|
.videoFilters("thumbnail")
|
||||||
|
.frames(1)
|
||||||
|
.format("mjpeg")
|
||||||
|
.output(thumbnailPath)
|
||||||
|
.on("error", (error: Error) => {
|
||||||
|
logger.error([
|
||||||
|
"failed to generate thumbnail",
|
||||||
|
error as Error,
|
||||||
|
]);
|
||||||
|
reject(error);
|
||||||
|
})
|
||||||
|
|
||||||
|
.on("end", async () => {
|
||||||
|
const thumbnailFile: BunFile = Bun.file(thumbnailPath);
|
||||||
|
const file: BunFile = Bun.file(filePath);
|
||||||
|
const thumbnailArraybuffer: ArrayBuffer =
|
||||||
|
await thumbnailFile.arrayBuffer();
|
||||||
|
|
||||||
|
await thumbnailFile.unlink();
|
||||||
|
await file.unlink();
|
||||||
|
|
||||||
|
resolve(thumbnailArraybuffer);
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateImageThumbnail(
|
||||||
|
filePath: string,
|
||||||
|
thumbnailPath: string,
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
|
return new Promise(
|
||||||
|
async (
|
||||||
|
resolve: (value: ArrayBuffer) => void,
|
||||||
|
reject: (reason: Error) => void,
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const options: {
|
||||||
|
responseType: "buffer";
|
||||||
|
height: number;
|
||||||
|
jpegOptions: {
|
||||||
|
force: boolean;
|
||||||
|
quality: number;
|
||||||
|
};
|
||||||
|
} = {
|
||||||
|
height: 320,
|
||||||
|
responseType: "buffer",
|
||||||
|
jpegOptions: {
|
||||||
|
force: true,
|
||||||
|
quality: 60,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const thumbnailBuffer: Buffer = await imageThumbnail(
|
||||||
|
filePath,
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Bun.write(thumbnailPath, thumbnailBuffer.buffer);
|
||||||
|
resolve(await Bun.file(thumbnailPath).arrayBuffer());
|
||||||
|
|
||||||
|
await Bun.file(filePath).unlink();
|
||||||
|
await Bun.file(thumbnailPath).unlink();
|
||||||
|
} catch (error) {
|
||||||
|
reject(error as Error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createThumbnails(files: FileEntry[]): Promise<void> {
|
||||||
|
const { type, path } = dataType;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const { id, mime_type } = file;
|
||||||
|
|
||||||
|
let fileArrayBuffer: ArrayBuffer | null = null;
|
||||||
|
const isVideo: boolean = mime_type.startsWith("video/");
|
||||||
|
const fileName: string = `${id}.${file.extension || ""}`;
|
||||||
|
|
||||||
|
if (type === "local") {
|
||||||
|
const filePath: string | undefined = join(path as string, fileName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
fileArrayBuffer = await Bun.file(filePath).arrayBuffer();
|
||||||
|
} catch {
|
||||||
|
logger.error([
|
||||||
|
"Could not generate thumbnail for file:",
|
||||||
|
fileName,
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
fileArrayBuffer = await s3.file(fileName).arrayBuffer();
|
||||||
|
} catch {
|
||||||
|
logger.error([
|
||||||
|
"Could not generate thumbnail for file:",
|
||||||
|
fileName,
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fileArrayBuffer) {
|
||||||
|
logger.error(["Could not generate thumbnail for file:", fileName]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempFilePath: string = resolve("temp", `${id}.tmp`);
|
||||||
|
const tempThumbnailPath: string = resolve("temp", `${id}.jpg`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Bun.write(tempFilePath, fileArrayBuffer, {
|
||||||
|
createPath: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error([
|
||||||
|
"Could not write file to temp path:",
|
||||||
|
fileName,
|
||||||
|
error as Error,
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const thumbnailArrayBuffer: ArrayBuffer | null = isVideo
|
||||||
|
? await generateVideoThumbnail(tempFilePath, tempThumbnailPath)
|
||||||
|
: await generateImageThumbnail(tempFilePath, tempThumbnailPath);
|
||||||
|
|
||||||
|
if (!thumbnailArrayBuffer) {
|
||||||
|
logger.error([
|
||||||
|
"Could not generate thumbnail for file:",
|
||||||
|
fileName,
|
||||||
|
]);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (type === "local") {
|
||||||
|
const thumbnailPath: string = join(
|
||||||
|
path as string,
|
||||||
|
"thumbnails",
|
||||||
|
`${id}.jpg`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await Bun.write(thumbnailPath, thumbnailArrayBuffer, {
|
||||||
|
createPath: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const thumbnailPath: string = `thumbnails/${id}.jpg`;
|
||||||
|
|
||||||
|
await s3.file(thumbnailPath).write(thumbnailArrayBuffer);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error([
|
||||||
|
"Could not write thumbnail to storage:",
|
||||||
|
fileName,
|
||||||
|
error as Error,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sql`UPDATE files SET thumbnail = true WHERE id = ${id}`;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error([
|
||||||
|
"Could not update file with thumbnail status:",
|
||||||
|
fileName,
|
||||||
|
error as Error,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error([
|
||||||
|
"An error occurred while generating thumbnail for file:",
|
||||||
|
fileName,
|
||||||
|
error as Error,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = async (event: MessageEvent): Promise<void> => {
|
||||||
|
await createThumbnails(event.data.files);
|
||||||
|
};
|
||||||
|
|
||||||
|
self.onerror = (error: ErrorEvent): void => {
|
||||||
|
logger.error(error);
|
||||||
|
};
|
|
@ -125,7 +125,7 @@ async function handler(
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin: boolean = request.session.roles.includes("admin");
|
const isAdmin: boolean = request.session.roles.includes("admin");
|
||||||
const { file } = request.params as { file: string };
|
const { query: file } = request.params as { query: string };
|
||||||
let { files } = requestBody as { files: string[] | string };
|
let { files } = requestBody as { files: string[] | string };
|
||||||
// const { password } = request.query as { password: string };
|
// const { password } = request.query as { password: string };
|
||||||
|
|
||||||
|
@ -133,7 +133,7 @@ async function handler(
|
||||||
const successfulFiles: string[] = [];
|
const successfulFiles: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (file) {
|
if (file && !(typeof file === "string" && file.length === 0)) {
|
||||||
await processFile(
|
await processFile(
|
||||||
request,
|
request,
|
||||||
file,
|
file,
|
||||||
|
@ -145,6 +145,16 @@ async function handler(
|
||||||
files = Array.isArray(files)
|
files = Array.isArray(files)
|
||||||
? files
|
? files
|
||||||
: files.split(/[, ]+/).filter(Boolean);
|
: files.split(/[, ]+/).filter(Boolean);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
await processFile(
|
||||||
|
request,
|
||||||
|
file,
|
||||||
|
isAdmin,
|
||||||
|
failedFiles,
|
||||||
|
successfulFiles,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(["Unexpected error", error as Error]);
|
logger.error(["Unexpected error", error as Error]);
|
||||||
|
@ -161,7 +171,7 @@ async function handler(
|
||||||
return Response.json({
|
return Response.json({
|
||||||
success: true,
|
success: true,
|
||||||
code: 200,
|
code: 200,
|
||||||
files: successfulFiles,
|
deleted: successfulFiles,
|
||||||
failed: failedFiles,
|
failed: failedFiles,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -18,6 +18,7 @@ import {
|
||||||
getNewTimeUTC,
|
getNewTimeUTC,
|
||||||
nameWithoutExtension,
|
nameWithoutExtension,
|
||||||
supportsExif,
|
supportsExif,
|
||||||
|
supportsThumbnail,
|
||||||
} from "@/helpers/char";
|
} from "@/helpers/char";
|
||||||
import { logger } from "@/helpers/logger";
|
import { logger } from "@/helpers/logger";
|
||||||
|
|
||||||
|
@ -100,10 +101,7 @@ async function removeExifData(
|
||||||
"-overwrite_original",
|
"-overwrite_original",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const modifiedBuffer: ArrayBuffer =
|
return await Bun.file(tempInputPath).arrayBuffer();
|
||||||
await Bun.file(tempInputPath).arrayBuffer();
|
|
||||||
|
|
||||||
return modifiedBuffer;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(["Error modifying EXIF data:", error as Error]);
|
logger.error(["Error modifying EXIF data:", error as Error]);
|
||||||
return fileBuffer;
|
return fileBuffer;
|
||||||
|
@ -438,6 +436,29 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filesThatSupportThumbnails: FileUpload[] = successfulFiles.filter(
|
||||||
|
(file: FileUpload): boolean =>
|
||||||
|
supportsThumbnail(file.mime_type as string),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
(await getSetting("enable_thumbnails")) === "true" &&
|
||||||
|
filesThatSupportThumbnails.length > 0
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const worker: Worker = new Worker(
|
||||||
|
"./src/helpers/workers/thumbnails.ts",
|
||||||
|
{
|
||||||
|
type: "module",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
worker.postMessage({
|
||||||
|
files: filesThatSupportThumbnails,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(["Error starting thumbnail worker:", error as Error]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Response.json({
|
return Response.json({
|
||||||
success: true,
|
success: true,
|
||||||
code: 200,
|
code: 200,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { isValidUsername } from "@config/sql/users";
|
import { isValidUsername } from "@config/sql/users";
|
||||||
import { sql } from "bun";
|
import { type ReservedSQL, sql } from "bun";
|
||||||
|
|
||||||
import { isUUID } from "@/helpers/char";
|
import { isUUID } from "@/helpers/char";
|
||||||
import { logger } from "@/helpers/logger";
|
import { logger } from "@/helpers/logger";
|
||||||
|
@ -12,7 +12,9 @@ const routeDef: RouteDef = {
|
||||||
|
|
||||||
async function handler(request: ExtendedRequest): Promise<Response> {
|
async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
const { query } = request.params as { query: string };
|
const { query } = request.params as { query: string };
|
||||||
const { invites: showInvites } = request.query as { invites: string };
|
const { invites: showInvites } = request.query as {
|
||||||
|
invites: string;
|
||||||
|
};
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
@ -44,10 +46,12 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reservation: ReservedSQL = await sql.reserve();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result: GetUser[] = isId
|
const result: GetUser[] = isId
|
||||||
? await sql`SELECT * FROM users WHERE id = ${normalized}`
|
? await reservation`SELECT * FROM users WHERE id = ${normalized}`
|
||||||
: await sql`SELECT * FROM users WHERE username = ${normalized}`;
|
: await reservation`SELECT * FROM users WHERE username = ${normalized}`;
|
||||||
|
|
||||||
if (result.length === 0) {
|
if (result.length === 0) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
|
@ -61,15 +65,23 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
}
|
}
|
||||||
|
|
||||||
user = result[0];
|
user = result[0];
|
||||||
|
|
||||||
isSelf = request.session ? user.id === request.session.id : false;
|
isSelf = request.session ? user.id === request.session.id : false;
|
||||||
|
|
||||||
|
const files: { count: bigint }[] =
|
||||||
|
await reservation`SELECT COUNT(*) FROM files WHERE owner = ${user.id}`;
|
||||||
|
const folders: { count: bigint }[] =
|
||||||
|
await reservation`SELECT COUNT(*) FROM folders WHERE owner = ${user.id}`;
|
||||||
|
|
||||||
|
if (files) user.files = Number(files[0].count);
|
||||||
|
if (folders) user.folders = Number(folders[0].count);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(showInvites === "true" || showInvites === "1") &&
|
(showInvites === "true" || showInvites === "1") &&
|
||||||
(isAdmin || isSelf)
|
(isAdmin || isSelf)
|
||||||
) {
|
) {
|
||||||
const invites: Invite[] =
|
user.invites =
|
||||||
await sql`SELECT * FROM invites WHERE created_by = ${user.id}`;
|
await reservation`SELECT * FROM invites WHERE created_by = ${user.id}`;
|
||||||
user.invites = invites;
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error([
|
logger.error([
|
||||||
|
@ -85,6 +97,19 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
},
|
},
|
||||||
{ status: 500 },
|
{ status: 500 },
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
reservation.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
code: 404,
|
||||||
|
error: "User not found",
|
||||||
|
},
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
delete user.password;
|
delete user.password;
|
||||||
|
|
5
types/file.d.ts
vendored
5
types/file.d.ts
vendored
|
@ -25,6 +25,11 @@ type FileUpload = Partial<FileEntry> & {
|
||||||
url?: string;
|
url?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GetFile = Partial<FileEntry> & {
|
||||||
|
url?: string;
|
||||||
|
raw_url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type Folder = {
|
type Folder = {
|
||||||
id: UUID;
|
id: UUID;
|
||||||
owner: UUID;
|
owner: UUID;
|
||||||
|
|
2
types/session.d.ts
vendored
2
types/session.d.ts
vendored
|
@ -52,4 +52,6 @@ type GetUser = {
|
||||||
created_at?: Date;
|
created_at?: Date;
|
||||||
last_seen?: Date;
|
last_seen?: Date;
|
||||||
invites?: Invite[];
|
invites?: Invite[];
|
||||||
|
files: number;
|
||||||
|
folders: number;
|
||||||
};
|
};
|
||||||
|
|
Loading…
Add table
Reference in a new issue