diff --git a/.env.example b/.env.example index e1b9115..4bdf792 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,29 @@ +## APP +APP_URL= +APP_KEY= + +## CONF TZ=UTC PORT=3333 HOST=localhost -LOG_LEVEL=info -APP_KEY= NODE_ENV=development + +## Session SESSION_DRIVER=cookie + +## LOG +LOG_LEVEL=info + +## Database DB_HOST=127.0.0.1 DB_PORT=5432 -DB_USER=root -DB_PASSWORD=root -DB_DATABASE=app \ No newline at end of file +DB_USER=knowledge +DB_PASSWORD=knowledge +DB_DATABASE=knowledge + +### S3 +DRIVE_DISK=s3 +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_REGION= +S3_BUCKET= diff --git a/adonisrc.ts b/adonisrc.ts index 2736a71..1acf2aa 100644 --- a/adonisrc.ts +++ b/adonisrc.ts @@ -53,6 +53,7 @@ export default defineConfig({ () => import('@adonisjs/lucid/database_provider'), () => import('@adonisjs/auth/auth_provider'), () => import('@adonisjs/inertia/inertia_provider'), + () => import('@adonisjs/drive/drive_provider'), ], /* diff --git a/app/controllers/images/show_images_controller.ts b/app/controllers/images/show_images_controller.ts new file mode 100644 index 0000000..d3689ec --- /dev/null +++ b/app/controllers/images/show_images_controller.ts @@ -0,0 +1,55 @@ +// import type { HttpContext } from '@adonisjs/core/http' + +import { inject } from '@adonisjs/core' +import { HttpContext } from '@adonisjs/core/http' +import ImageTransformService from '#services/images/image_transform_service' +import ImageSignatureService from '#services/images/image_signature_service' + +@inject() +export default class ShowImagesController { + constructor( + private imageTransformService: ImageTransformService, + private imageSignatureService: ImageSignatureService + ) {} + + async show({ request, response }: HttpContext) { + const src = request.params()['*'].join('/') + const width = Number(request.input('w')) || undefined + const height = Number(request.input('h')) || undefined + const quality = Number(request.input('q')) || undefined + const format = request.input('f', 'webp') + const fit = request.input('fit', 'cover') + const sig = request.input('sig') + + const isValid = this.imageSignatureService.verify( + { src, w: width, h: height, q: quality, f: format, fit }, + sig + ) + + if (!isValid) { + return response.unauthorized({ error: 'Invalid signature' }) + } + + try { + const { buffer, mimeType, cacheHit } = await this.imageTransformService.transform({ + src, + width, + height, + quality, + format, + fit, + }) + + response.header('Content-Type', mimeType) + response.header('X-Cache', cacheHit ? 'HIT' : 'MISS') + response.header('Cache-Control', 'public, max-age=31536000, immutable') + + return response.send(buffer) + } catch (error) { + if (error.code === 'ENOENT') { + return response.notFound({ error: 'Image not found' }) + } + return response.internalServerError({ error: 'Transform failed' }) + } + } +} diff --git a/app/controllers/images/upload_images_controller.ts b/app/controllers/images/upload_images_controller.ts new file mode 100644 index 0000000..912acdb --- /dev/null +++ b/app/controllers/images/upload_images_controller.ts @@ -0,0 +1,39 @@ +// import type { HttpContext } from '@adonisjs/core/http' + +import { HttpContext } from '@adonisjs/core/http' +import string from '@adonisjs/core/helpers/string' +import Image from '#models/image' +import { v4 as uuidv4 } from 'uuid' +import vine from '@vinejs/vine' + +export default class UploadImagesController { + static validator = vine.compile( + vine.object({ + file: vine.file({ + extnames: ['jpg', 'png', 'jpeg'], + size: '2mb', + }), + }) + ) + + async upload({ request, response, session }: HttpContext) { + const { file } = await request.validateUsing(UploadImagesController.validator) + + if (!file) return response.badRequest('Invalid file') + + const fileName = `${string.uuid()}.${file.extname}` + + await Promise.all([ + file.moveToDisk(`/uploads/${fileName}`), + Image.create({ + id: uuidv4(), + url: fileName, + }), + ]) + + session.flash('success', 'File uploaded successfully') + session.flash('share', `/uploads/${fileName}`) + + return response.redirect().back() + } +} diff --git a/app/models/image.ts b/app/models/image.ts new file mode 100644 index 0000000..3b0f428 --- /dev/null +++ b/app/models/image.ts @@ -0,0 +1,9 @@ +import { BaseModel, column } from '@adonisjs/lucid/orm' + +export default class Image extends BaseModel { + @column({ isPrimary: true }) + declare id: string + + @column() + declare url: string +} diff --git a/app/services/images/image_signature_service.ts b/app/services/images/image_signature_service.ts new file mode 100644 index 0000000..8e842cc --- /dev/null +++ b/app/services/images/image_signature_service.ts @@ -0,0 +1,61 @@ +import { inject } from '@adonisjs/core' +import { createHmac } from 'node:crypto' +import env from '#start/env' + +@inject() +export default class ImageSignatureService { + private readonly secret = env.get('IMAGE_PROXY_SECRET') ?? 'secret-key' + + sign(params: Record): string { + const payload = this.buildPayload(params) + return createHmac('sha256', this.secret).update(payload).digest('hex') + } + + verify(params: Record, signature: string): boolean { + const expected = this.sign(params) + return this.safeCompare(expected, signature) + } + + buildUrl( + src: string, + options: { w?: number; h?: number; q?: number; f?: string; fit?: string } = {} + ): string { + const cleanSrc = src.replace(/^\//, '') + const params = { src: cleanSrc, ...options } + const sig = this.sign(params) + + const query = new URLSearchParams( + Object.fromEntries( + Object.entries({ + w: options.w, + h: options.h, + q: options.q, + f: options.f, + fit: options.fit, + sig, + }) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => [k, String(v)]) + ) + ) + + return `/images/${cleanSrc}?${query}` + } + + private buildPayload(params: Record): string { + return Object.keys(params) + .filter((k) => k !== 'sig' && params[k] !== undefined) + .sort() + .map((k) => `${k}=${params[k]}`) + .join('&') + } + + private safeCompare(a: string, b: string): boolean { + if (a.length !== b.length) return false + let diff = 0 + for (let i = 0; i < a.length; i++) { + diff |= a.charCodeAt(i) ^ b.charCodeAt(i) + } + return diff === 0 + } +} diff --git a/app/services/images/image_transform_service.ts b/app/services/images/image_transform_service.ts new file mode 100644 index 0000000..ee6f273 --- /dev/null +++ b/app/services/images/image_transform_service.ts @@ -0,0 +1,126 @@ +import { inject } from '@adonisjs/core' +import sharp, { type FitEnum } from 'sharp' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import drive from '@adonisjs/drive/services/main' +import { existsSync } from 'node:fs' +import { join } from 'node:path' + +export type SupportedFormat = 'webp' | 'png' | 'jpeg' | 'avif' +export type SupportedFit = keyof FitEnum + +export interface TransformOptions { + src: string + width?: number + height?: number + quality?: number + format?: SupportedFormat + fit?: SupportedFit +} + +export interface TransformResult { + buffer: Buffer + mimeType: string + cacheHit: boolean +} + +const MIME_TYPES: Record = { + webp: 'image/webp', + png: 'image/png', + jpeg: 'image/jpeg', + avif: 'image/avif', +} + +const FIT_MAP: Record = { + cover: 'cover', + contain: 'contain', + fill: 'fill', + inside: 'inside', + outside: 'outside', +} + +@inject() +export default class ImageTransformService { + private readonly cacheDir = './storage/.cache/images' + + async transform(options: TransformOptions): Promise { + const { src, width, height, quality = 80, format = 'webp', fit = 'cover' } = options + + const cacheKey = this.buildCacheKey(src, width, height, quality, format, fit) + const cachePath = join(this.cacheDir, `${cacheKey}.${format}`) + const mimeType = MIME_TYPES[format] + + if (existsSync(cachePath)) { + return { buffer: await readFile(cachePath), mimeType, cacheHit: true } + } + + const original = await this.fetchFromDrive(src) + const buffer = await this.applyTransform(original, { width, height, quality, format, fit }) + + await this.saveToCache(cachePath, buffer) + + return { buffer, mimeType, cacheHit: false } + } + + private buildCacheKey( + src: string, + width?: number, + height?: number, + quality?: number, + format?: string, + fit?: string + ): string { + return createHash('md5') + .update(`${src}-${width}-${height}-${quality}-${format}-${fit}`) + .digest('hex') + } + + private async fetchFromDrive(src: string): Promise { + const stream = await drive.use('s3').getStream(src) + const chunks: Buffer[] = [] + for await (const chunk of stream) chunks.push(Buffer.from(chunk)) + return Buffer.concat(chunks) + } + + private async applyTransform( + original: Buffer, + options: { + width?: number + height?: number + quality?: number + format?: SupportedFormat + fit?: SupportedFit + } + ): Promise { + const { width, height, quality = 80, format = 'webp', fit = 'cover' } = options + const fitValue = FIT_MAP[fit] ?? 'cover' + + let pipeline = sharp(original) + + if (width || height) { + pipeline = pipeline.resize(width ?? null, height ?? null, { + fit: fitValue, + withoutEnlargement: true, + position: 'attention', + }) + } + + switch (format) { + case 'webp': + return pipeline.webp({ quality }).toBuffer() + case 'avif': + return pipeline.avif({ quality }).toBuffer() + case 'jpeg': + return pipeline.jpeg({ quality }).toBuffer() + case 'png': + return pipeline.png({ quality }).toBuffer() + default: + return pipeline.webp({ quality }).toBuffer() + } + } + + private async saveToCache(cachePath: string, buffer: Buffer): Promise { + await mkdir(this.cacheDir, { recursive: true }) + await writeFile(cachePath, buffer) + } +} diff --git a/app/services/markdown_service.ts b/app/services/markdown_service.ts index e0c417b..3a16fd2 100644 --- a/app/services/markdown_service.ts +++ b/app/services/markdown_service.ts @@ -6,19 +6,43 @@ import rehypeRaw from 'rehype-raw' import rehypeShiki from '@shikijs/rehype' import rehypeStringify from 'rehype-stringify' import { inject } from '@adonisjs/core' +import { visit } from 'unist-util-visit' +import type { Root } from 'hast' +import ImageSignatureService from '#services/images/image_signature_service' @inject() export default class MarkdownService { + constructor(private imageSignatureService: ImageSignatureService) {} + async parse(markdown: string): Promise { const result = await unified() .use(remarkParse) .use(remarkGfm) .use(remarkRehype, { allowDangerousHtml: true }) .use(rehypeRaw) + .use(() => this.rehypeImageProxy()) .use(rehypeShiki, { theme: 'catppuccin-macchiato' }) .use(rehypeStringify) .process(markdown) return String(result) } + + private rehypeImageProxy() { + return (tree: Root) => { + visit(tree, 'element', (node) => { + if (node.tagName !== 'img') return + + const src = node.properties?.src as string + if (!src || src.startsWith('/images/')) return + + node.properties.src = this.imageSignatureService.buildUrl(src, { + w: 900, + q: 80, + f: 'webp', + fit: 'inside', + }) + }) + } + } } diff --git a/config/drive.ts b/config/drive.ts new file mode 100644 index 0000000..0ca54c5 --- /dev/null +++ b/config/drive.ts @@ -0,0 +1,30 @@ +import env from '#start/env' +import { defineConfig, services } from '@adonisjs/drive' + +const driveConfig = defineConfig({ + default: env.get('DRIVE_DISK'), + + /** + * The services object can be used to configure multiple file system + * services each using the same or a different driver. + */ + services: { + s3: services.s3({ + credentials: { + accessKeyId: env.get('AWS_ACCESS_KEY_ID'), + secretAccessKey: env.get('AWS_SECRET_ACCESS_KEY'), + }, + region: env.get('AWS_REGION'), + bucket: env.get('S3_BUCKET'), + endpoint: env.get('S3_ENDPOINT'), + visibility: 'private', + forcePathStyle: env.get('S3_FORCE_PATH_STYLE'), + }), + }, +}) + +export default driveConfig + +declare module '@adonisjs/drive/types' { + export interface DriveDisks extends InferDriveDisks {} +} diff --git a/database/migrations/1773423772443_create_images_table.ts b/database/migrations/1773423772443_create_images_table.ts new file mode 100644 index 0000000..31358c3 --- /dev/null +++ b/database/migrations/1773423772443_create_images_table.ts @@ -0,0 +1,16 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'images' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.uuid('id').primary() + table.string('url').notNullable() + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/inertia/components/form/file-upload.tsx b/inertia/components/form/file-upload.tsx new file mode 100644 index 0000000..5e05136 --- /dev/null +++ b/inertia/components/form/file-upload.tsx @@ -0,0 +1,104 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useDropzone } from 'react-dropzone' +import { Loader2 } from 'lucide-react' +import { router } from '@inertiajs/react' + +type Props = { + onUpload: (url: string) => void +} + +export function FileUploader({ onUpload }: Props) { + const [processing, setProcessing] = useState(false) + const [error, setError] = useState(null) + const isMounted = useRef(true) + + useEffect(() => { + isMounted.current = true + return () => { + isMounted.current = false + } + }, []) + + const onDrop = useCallback( + (acceptedFiles: File[]) => { + const file = acceptedFiles[0] + if (!file) return + + const formData = new FormData() + formData.append('file', file) + + setProcessing(true) + setError(null) + + router.post('/upload', formData, { + forceFormData: true, + preserveState: true, + preserveScroll: true, + onSuccess: (page) => { + if (!isMounted.current) return + const share = (page.props as { flash?: { share?: string } }).flash?.share + if (share) { + onUpload(share) + } else { + setError('Upload succeeded but no URL returned') + } + }, + onError: (errors) => { + if (!isMounted.current) return + setError(errors.file ?? 'Upload failed') + }, + onFinish: () => { + if (isMounted.current) setProcessing(false) + }, + }) + }, + [onUpload] + ) + + const onDropRejected = useCallback((rejectedFiles: import('react-dropzone').FileRejection[]) => { + const code = rejectedFiles[0]?.errors[0]?.code + if (code === 'file-too-large') setError('File is too large (max 5mb)') + else if (code === 'file-invalid-type') setError('Invalid file type') + else setError('File rejected') + }, []) + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + onDropRejected, + accept: { 'image/*': ['.jpg', '.jpeg', '.png', '.webp'] }, + maxSize: 5 * 1024 * 1024, + multiple: false, + disabled: processing, + }) + + return ( +
+ + + {processing ? ( +
+ +

Uploading…

+
+ ) : isDragActive ? ( +

Drop the image here

+ ) : ( +
+

Drag & drop an image here

+

or click to browse

+

JPG, PNG, WEBP — max 5mb

+
+ )} + + {error &&

{error}

} +
+ ) +} diff --git a/inertia/components/form/mdeditor.tsx b/inertia/components/form/mdeditor.tsx index b6fec56..b2e3923 100644 --- a/inertia/components/form/mdeditor.tsx +++ b/inertia/components/form/mdeditor.tsx @@ -1,6 +1,8 @@ -import { useEffect, useRef } from 'react' +import { useEffect, useRef, useState } from 'react' import 'easymde/dist/easymde.min.css' import EasyMDE from 'easymde' +import { Dialog, DialogContent } from '../ui/dialog' +import { FileUploader } from '@/components/form/file-upload' type Props = { defaultValue: string @@ -12,6 +14,7 @@ type Props = { export default function MdEditor(props: Props) { const textareaRef = useRef(null) const editorRef = useRef(null) + const [fileExplorerOpen, setFileExplorerOpen] = useState(false) useEffect(() => { if (!textareaRef.current || editorRef.current) { @@ -36,6 +39,12 @@ export default function MdEditor(props: Props) { 'ordered-list', '|', 'link', + { + name: 'image', + action: () => setFileExplorerOpen(true), + className: 'fa fa-image', + title: 'Insert image', + }, 'code', '|', 'side-by-side', @@ -60,6 +69,21 @@ export default function MdEditor(props: Props) { return (