Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
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=
1 change: 1 addition & 0 deletions adonisrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
],

/*
Expand Down
55 changes: 55 additions & 0 deletions app/controllers/images/show_images_controller.ts
Original file line number Diff line number Diff line change
@@ -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' })
}
}
}
39 changes: 39 additions & 0 deletions app/controllers/images/upload_images_controller.ts
Original file line number Diff line number Diff line change
@@ -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()
}
}
9 changes: 9 additions & 0 deletions app/models/image.ts
Original file line number Diff line number Diff line change
@@ -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
}
61 changes: 61 additions & 0 deletions app/services/images/image_signature_service.ts
Original file line number Diff line number Diff line change
@@ -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, string | number | undefined>): string {
const payload = this.buildPayload(params)
return createHmac('sha256', this.secret).update(payload).digest('hex')
}

verify(params: Record<string, string | number | undefined>, 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, string | number | undefined>): 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
}
}
126 changes: 126 additions & 0 deletions app/services/images/image_transform_service.ts
Original file line number Diff line number Diff line change
@@ -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<SupportedFormat, string> = {
webp: 'image/webp',
png: 'image/png',
jpeg: 'image/jpeg',
avif: 'image/avif',
}

const FIT_MAP: Record<string, keyof FitEnum> = {
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<TransformResult> {
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<Buffer> {
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<Buffer> {
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<void> {
await mkdir(this.cacheDir, { recursive: true })
await writeFile(cachePath, buffer)
}
}
24 changes: 24 additions & 0 deletions app/services/markdown_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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',
})
})
}
}
}
Loading
Loading