thumbnailing #7
13 changed files with 257 additions and 22 deletions
10
.env.example
10
.env.example
|
@ -1 +1,11 @@
|
|||
BASE_URL=
|
||||
DATABASE_URL=
|
||||
BODY_SIZE_LIMIT=
|
||||
|
||||
MINIO_URL=
|
||||
MINIO_ACCESS_KEY=
|
||||
MINIO_SECRET_KEY=
|
||||
MINIO_BUCKET=
|
||||
|
||||
THUMBOR_ENDPOINT=
|
||||
THUMBOR_SECRET=
|
|
@ -7,3 +7,17 @@ services:
|
|||
MARIADB_ROOT_PASSWORD: development
|
||||
ports:
|
||||
- '3306:3306'
|
||||
thumbor:
|
||||
build:
|
||||
dockerfile: thumbor.Dockerfile
|
||||
restart: always
|
||||
ports:
|
||||
- '8888:80'
|
||||
extra_hosts:
|
||||
- 'host.docker.internal:host-gateway'
|
||||
environment:
|
||||
- 'SECURITY_KEY=development'
|
||||
- 'ALLOW_UNSAFE_URL=False'
|
||||
- 'STORAGE=thumbor.storages.no_storage'
|
||||
- 'ENGINE=thumbor_video_engine.engines.video'
|
||||
# - 'FILTERS=[thumbor_video_engine.filters.format,thumbor_video_engine.filters.still]'
|
||||
|
|
12
prisma/migrations/20240829084742_thumbnails/migration.sql
Normal file
12
prisma/migrations/20240829084742_thumbnails/migration.sql
Normal file
|
@ -0,0 +1,12 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE `Thumbnail` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`uploadId` VARCHAR(191) NOT NULL,
|
||||
`placeholder` VARCHAR(191) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `Thumbnail_uploadId_key`(`uploadId`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `Thumbnail` ADD CONSTRAINT `Thumbnail_uploadId_fkey` FOREIGN KEY (`uploadId`) REFERENCES `Upload`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
10
prisma/migrations/20240829085742_thumbnails/migration.sql
Normal file
10
prisma/migrations/20240829085742_thumbnails/migration.sql
Normal file
|
@ -0,0 +1,10 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `placeholder` on the `Thumbnail` table. All the data in the column will be lost.
|
||||
- Added the required column `fileName` to the `Thumbnail` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE `Thumbnail` DROP COLUMN `placeholder`,
|
||||
ADD COLUMN `fileName` LONGTEXT NOT NULL;
|
11
prisma/migrations/20240829095034_thumbnail_id/migration.sql
Normal file
11
prisma/migrations/20240829095034_thumbnail_id/migration.sql
Normal file
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `Thumbnail` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to alter the column `id` on the `Thumbnail` table. The data in that column could be lost. The data in that column will be cast from `VarChar(191)` to `Int`.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE `Thumbnail` DROP PRIMARY KEY,
|
||||
MODIFY `id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
ADD PRIMARY KEY (`id`);
|
|
@ -58,6 +58,7 @@ model Upload {
|
|||
size Int
|
||||
public Boolean @default(true)
|
||||
uploaded DateTime @default(now())
|
||||
thumbnail Thumbnail?
|
||||
}
|
||||
|
||||
model APIKey {
|
||||
|
@ -69,6 +70,13 @@ model APIKey {
|
|||
permissions Int
|
||||
}
|
||||
|
||||
model Thumbnail {
|
||||
id Int @id @default(autoincrement())
|
||||
upload Upload @relation(fields: [uploadId], references: [id])
|
||||
uploadId String @unique
|
||||
fileName String @db.LongText
|
||||
}
|
||||
|
||||
enum Role {
|
||||
ADMINISTRATOR
|
||||
USER
|
||||
|
|
35
src/lib/components/Upload.svelte
Normal file
35
src/lib/components/Upload.svelte
Normal file
|
@ -0,0 +1,35 @@
|
|||
<script>
|
||||
import { bytesToHumanReadable } from '$lib';
|
||||
import { File } from 'lucide-svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
export let upload, i;
|
||||
let image = true;
|
||||
</script>
|
||||
|
||||
<a
|
||||
in:fade|global={{ duration: 500, delay: 100 * i }}
|
||||
href="/file/{upload.id}"
|
||||
class="flex flex-col gap-2 p-2 mx-auto w-full rounded-lg shadow-lg bg-crust"
|
||||
>
|
||||
<div class="flex w-full rounded-lg aspect-video bg-mantle">
|
||||
{#if image}
|
||||
<img
|
||||
class="object-cover mx-auto w-full rounded-lg"
|
||||
src="/api/v1/thumbnail/{upload.id}"
|
||||
alt="Thumbnail"
|
||||
on:error={() => {
|
||||
image = false;
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<File size="32" class="m-auto text-surface2"></File>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold overflow-ellipsis whitespace-nowrap overflow-x-clip">
|
||||
{upload.fileName}
|
||||
</p>
|
||||
<p class="text-sm text-overlay1">{bytesToHumanReadable(upload.size)}</p>
|
||||
</div>
|
||||
</a>
|
|
@ -124,7 +124,8 @@ export async function getUpload(id: string) {
|
|||
internalName: true,
|
||||
public: true,
|
||||
uploaded: true,
|
||||
uploader: true
|
||||
uploader: true,
|
||||
thumbnail: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
48
src/lib/server/thumbnail.ts
Normal file
48
src/lib/server/thumbnail.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import type { Upload } from '@prisma/client';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { createHmac, randomBytes } from 'node:crypto';
|
||||
import prisma from './database';
|
||||
import minio, { BUCKET } from './minio';
|
||||
|
||||
const processing = new Set();
|
||||
|
||||
export function createSignature(opts: string) {
|
||||
return createHmac('SHA1', env.THUMBOR_KEY || 'development')
|
||||
.update(opts)
|
||||
.digest();
|
||||
}
|
||||
|
||||
export async function createThumbnail(upload: Upload) {
|
||||
if (processing.has(upload.id)) return false;
|
||||
processing.add(upload.id);
|
||||
|
||||
const ticket = randomBytes(16).toString('hex');
|
||||
const ticketSig = createSignature(ticket).toString('hex');
|
||||
|
||||
const url = `${env.BASE_URL}/download/${upload.id}/${ticket}.${ticketSig}`;
|
||||
const options = '200x0/filters:format(webp):quality(50)';
|
||||
|
||||
const SIGNATURE = createSignature(`${options}/${url}`)
|
||||
.toString('base64')
|
||||
.replaceAll('+', '-')
|
||||
.replaceAll('/', '_');
|
||||
|
||||
const response = await fetch(`${env.THUMBOR_ENDPOINT}/${SIGNATURE}/${options}/${url}`);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
|
||||
const filePath = `thumbnails/${Date.now()}-${upload.id}.webp`;
|
||||
|
||||
const record = await prisma.thumbnail.create({
|
||||
data: {
|
||||
uploadId: upload.id,
|
||||
fileName: filePath
|
||||
}
|
||||
});
|
||||
|
||||
await minio.putObject(BUCKET, filePath, Buffer.from(arrayBuffer), arrayBuffer.byteLength, {
|
||||
'Content-Type': 'image/webp'
|
||||
});
|
||||
|
||||
processing.delete(upload.id);
|
||||
return record;
|
||||
}
|
|
@ -1,11 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { File, ChevronLeft, ChevronRight } from 'lucide-svelte';
|
||||
import { bytesToHumanReadable } from '$lib';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
import { writable, get } from 'svelte/store';
|
||||
import { goto } from '$app/navigation';
|
||||
import { browser } from '$app/environment';
|
||||
import { page } from '$app/stores';
|
||||
import Upload from '$lib/components/Upload.svelte';
|
||||
|
||||
export let data;
|
||||
|
||||
|
@ -21,21 +21,7 @@
|
|||
{#if uploads.length}
|
||||
<div class="grid grid-cols-5 grid-rows-3 gap-2">
|
||||
{#each uploads as upload, i}
|
||||
<a
|
||||
in:fade|global={{ duration: 500, delay: 100 * i }}
|
||||
href="/file/{upload.id}"
|
||||
class="flex flex-col gap-2 p-2 mx-auto w-full rounded-lg shadow-lg bg-crust"
|
||||
>
|
||||
<div class="flex w-full rounded-lg aspect-video bg-mantle">
|
||||
<File size="32" class="m-auto text-surface2"></File>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-bold overflow-ellipsis whitespace-nowrap overflow-x-clip">
|
||||
{upload.fileName}
|
||||
</p>
|
||||
<p class="text-sm text-overlay1">{bytesToHumanReadable(upload.size)}</p>
|
||||
</div>
|
||||
</a>
|
||||
<Upload {upload} {i}></Upload>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
|
|
44
src/routes/api/v1/thumbnail/[id]/+server.ts
Normal file
44
src/routes/api/v1/thumbnail/[id]/+server.ts
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { getUpload } from '$lib/server/database.js';
|
||||
import minio, { BUCKET } from '$lib/server/minio';
|
||||
import { createThumbnail } from '$lib/server/thumbnail';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const GET = async ({ request, locals, params }) => {
|
||||
let upload = await getUpload(params.id);
|
||||
if (!upload) throw error(404, { status: 404, message: 'File Not Found' });
|
||||
if (!upload.thumbnail) {
|
||||
const record = await createThumbnail(upload as any);
|
||||
if (!record) throw error(425, { status: 425, message: 'Too Early' });
|
||||
upload.thumbnail = record;
|
||||
}
|
||||
|
||||
if (!upload.public && locals?.user?.id !== upload.uploader.id)
|
||||
throw error(403, { status: 403, message: 'Forbidden' });
|
||||
|
||||
const object = await minio.getObject(BUCKET, upload.thumbnail.fileName);
|
||||
const metadata = await minio.statObject(BUCKET, upload.thumbnail.fileName);
|
||||
|
||||
const ac = new AbortController();
|
||||
ac.signal.onabort = () => object.destroy;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
object.on('data', (chunk) => {
|
||||
controller.enqueue(chunk);
|
||||
});
|
||||
object.on('end', () => {
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
ac.abort();
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': metadata.metaData['content-type'],
|
||||
'Content-Length': metadata.size.toString()
|
||||
}
|
||||
});
|
||||
};
|
53
src/routes/download/[id]/[ticket]/+server.ts
Normal file
53
src/routes/download/[id]/[ticket]/+server.ts
Normal file
|
@ -0,0 +1,53 @@
|
|||
import { getUpload } from '$lib/server/database.js';
|
||||
import minio, { BUCKET } from '$lib/server/minio';
|
||||
import { createSignature } from '$lib/server/thumbnail.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { timingSafeEqual } from 'crypto';
|
||||
|
||||
function verifyTicket(data: string, sig: string) {
|
||||
return timingSafeEqual(createSignature(data), Buffer.from(sig, 'hex'));
|
||||
}
|
||||
|
||||
export const GET = async ({ params, locals }) => {
|
||||
let id: any = params.id.split('.');
|
||||
if (id.length > 1) id.pop();
|
||||
id = id.join('');
|
||||
|
||||
const data = params.ticket.split('.')?.[0];
|
||||
const sig = params.ticket.split('.')?.[1];
|
||||
|
||||
if (!data || !sig) throw error(403, { status: 403, message: 'Forbidden' });
|
||||
|
||||
const file = await getUpload(id);
|
||||
if (!file) throw error(404, { status: 404, message: 'File Not Found' });
|
||||
|
||||
if (!verifyTicket(data, sig)) throw error(403, { status: 403, message: 'Forbidden' });
|
||||
|
||||
const object = await minio.getObject(BUCKET, `${file.uploader.id}/${file.internalName}`);
|
||||
const metadata = await minio.statObject(BUCKET, `${file.uploader.id}/${file.internalName}`);
|
||||
|
||||
const ac = new AbortController();
|
||||
ac.signal.onabort = () => object.destroy;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
object.on('data', (chunk) => {
|
||||
controller.enqueue(chunk);
|
||||
});
|
||||
object.on('end', () => {
|
||||
controller.close();
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
ac.abort();
|
||||
}
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Disposition': `attachment; filename="${file.fileName}"`,
|
||||
'Content-Type': metadata.metaData['content-type'],
|
||||
'Content-Length': metadata.size.toString()
|
||||
}
|
||||
});
|
||||
};
|
3
thumbor.Dockerfile
Normal file
3
thumbor.Dockerfile
Normal file
|
@ -0,0 +1,3 @@
|
|||
FROM ghcr.io/minimalcompact/thumbor:latest
|
||||
|
||||
RUN pip install thumbor-video-engine
|
Loading…
Reference in a new issue