diff --git a/server/package-lock.json b/server/package-lock.json index 9c04391..1e6e3e7 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -191,6 +191,7 @@ "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-9.4.2.tgz", "integrity": "sha512-MF0042+Z3s8PGZKZLySfhft28bUa3B1iq0e5NSjCvY8gfMi5aIH/iRJGRJa1N9Jz1BNkxYb4yvJ/N9KO8Z6Y+w==", "license": "MIT", + "peer": true, "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -462,29 +463,6 @@ "@dicebear/core": "^9.0.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1637,9 +1615,6 @@ "arm" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1654,9 +1629,6 @@ "arm" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1671,9 +1643,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1688,9 +1657,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1705,9 +1671,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1722,9 +1685,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1739,9 +1699,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1756,9 +1713,6 @@ "ppc64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1773,9 +1727,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1790,9 +1741,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1807,9 +1755,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1824,9 +1769,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1841,9 +1783,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2103,9 +2042,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2120,9 +2056,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2137,9 +2070,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2154,9 +2084,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2171,9 +2098,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2188,9 +2112,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2205,9 +2126,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2222,9 +2140,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2239,9 +2154,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2256,9 +2168,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2467,6 +2376,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3117,6 +3027,7 @@ "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -3173,6 +3084,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -3720,6 +3632,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -4373,6 +4286,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -4517,6 +4431,7 @@ "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4547,6 +4462,7 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/engines": "5.22.0" }, @@ -5259,6 +5175,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.4" }, @@ -5312,6 +5229,7 @@ "integrity": "sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/server/package.json b/server/package.json index 0e552ad..8c8d921 100644 --- a/server/package.json +++ b/server/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "node --env-file=.env --watch src/index.js", + "dev": "node --env-file=.env --watch-path=./src src/index.js", "dev:classic": "node --watch src/index.js", "start": "node src/index.js", "db:migrate": "prisma migrate dev", diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index 76de4c8..cab328b 100644 Binary files a/server/prisma/prisma/dev.db and b/server/prisma/prisma/dev.db differ diff --git a/server/src/lib/generate-avatar.js b/server/src/lib/generate-avatar.js index cbc2680..002344f 100644 --- a/server/src/lib/generate-avatar.js +++ b/server/src/lib/generate-avatar.js @@ -1,7 +1,7 @@ -import { avataaars } from '@dicebear/collection' +import { initials } from '@dicebear/collection' import { createAvatar } from '@dicebear/core' -const DEFAULT_STYLE = avataaars +const DEFAULT_STYLE = initials export async function generateAvatar(seed) { const avatar = createAvatar(DEFAULT_STYLE, { seed: String(seed) }) diff --git a/server/src/lib/image-resize.js b/server/src/lib/image-resize.js index f7fc4ed..5283ed2 100644 --- a/server/src/lib/image-resize.js +++ b/server/src/lib/image-resize.js @@ -49,15 +49,28 @@ export async function getOrCreateResized(uuid, width, format, subdir = '') { await fs.promises.mkdir(path.dirname(cachePath), { recursive: true }) - const sharp = (await import('sharp')).default - let pipeline = sharp(originalPath) - - if (width) { - pipeline = pipeline.resize(width, null, { withoutEnlargement: true }) + let sharpModule + try { + sharpModule = (await import('sharp')).default + } catch (err) { + const msg = `Failed to load sharp image processing library: ${err.message}` + throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_LOAD_ERROR' }) } - const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 } - await pipeline[format](options).toFile(cachePath) + let pipeline + try { + pipeline = sharpModule(originalPath) + + if (width) { + pipeline = pipeline.resize(width, null, { withoutEnlargement: true }) + } + + const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 } + await pipeline[format](options).toFile(cachePath) + } catch (err) { + const msg = `Failed to resize image ${originalPath} to ${width}w ${format}: ${err.message}` + throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_RESIZE_ERROR' }) + } return { path: cachePath, isNew: true } } @@ -75,17 +88,29 @@ export async function generateAllSizes(uuid, subdir, originalPath) { const cacheDir = path.join(CACHE_DIR, cacheSubdir) await fs.promises.mkdir(cacheDir, { recursive: true }) - const sharp = (await import('sharp')).default - const source = sharp(originalPath) + let sharpModule + try { + sharpModule = (await import('sharp')).default + } catch (err) { + const msg = `Failed to load sharp image processing library: ${err.message}` + throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_LOAD_ERROR' }) + } + + const source = sharpModule(originalPath) for (const width of VALID_WIDTHS) { for (const format of SUPPORTED_FORMATS) { const cacheFileName = `${uuid}_w${width}.${format}` const cachePath = path.join(CACHE_DIR, cacheSubdir, cacheFileName) - const pipeline = source.clone().resize(width, null, { withoutEnlargement: true }) - const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 } - await pipeline[format](options).toFile(cachePath) + try { + const pipeline = source.clone().resize(width, null, { withoutEnlargement: true }) + const options = format === 'avif' ? { quality: 75, effort: 4 } : { quality: 80 } + await pipeline[format](options).toFile(cachePath) + } catch (err) { + const msg = `Failed to generate ${width}w ${format} for ${originalPath}: ${err.message}` + throw Object.assign(new Error(msg), { cause: err, code: 'SHARP_RESIZE_ERROR' }) + } } } } diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index cf7185b..2e5ee6f 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -112,7 +112,7 @@ export async function registerAuthRoutes(fastify) { passwordHash, displayName: displayName || null, avatar: avatarUri, - avatarStyle: 'avataaars', + avatarStyle: 'initials', }, }) diff --git a/server/src/routes/oauth-social.js b/server/src/routes/oauth-social.js index faf74b3..7bab24c 100644 --- a/server/src/routes/oauth-social.js +++ b/server/src/routes/oauth-social.js @@ -90,7 +90,7 @@ async function findOrCreateUserFromOAuth({ provider, providerUserId, accessToken email, displayName: norm ? norm.split('@')[0] : 'Пользователь', avatar: await generateAvatar(email), - avatarStyle: 'avataaars', + avatarStyle: 'initials', }, }) await prisma.oAuthAccount.create({ diff --git a/server/src/routes/uploads-resized.js b/server/src/routes/uploads-resized.js index 04a7d56..add5606 100644 --- a/server/src/routes/uploads-resized.js +++ b/server/src/routes/uploads-resized.js @@ -11,62 +11,71 @@ const CACHE_CONTROL_SHORT = 'public, max-age=86400' */ export function registerUploadsResized(fastify) { fastify.get('/uploads-resized/*', async (request, reply) => { - const rawPath = request.params['*'] - const url = new URL(request.url, 'http://localhost') - const widthParam = url.searchParams.get('w') - - // Parse: [subdir/]filename.format - const parts = rawPath.split('/') - let filename, - subdir = '' - - if (parts.length > 1) { - subdir = parts.slice(0, -1).join('/') + '/' - filename = parts[parts.length - 1] - } else { - filename = parts[0] - } - - const dotIdx = filename.lastIndexOf('.') - if (dotIdx === -1) { - return reply.code(400).send({ error: 'Invalid request: no format specified' }) - } - - const uuid = filename.slice(0, dotIdx) - const format = filename.slice(dotIdx + 1).toLowerCase() - - if (!SUPPORTED_FORMATS.has(format)) { - return reply.code(400).send({ error: `Unsupported format: ${format}. Use avif or webp.` }) - } - - // Validate width - let width = null - if (widthParam) { - const w = parseInt(widthParam, 10) - if (!VALID_WIDTHS.includes(w)) { - return reply.code(400).send({ error: `Invalid width: ${widthParam}. Use: ${VALID_WIDTHS.join(', ')}` }) + try { + const rawPath = request.params['*'] + if (typeof rawPath !== 'string') { + return reply.code(400).send({ error: 'Invalid request: missing file path' }) } - width = w - } - // If no width requested, serve original with short cache - if (!width) { - const originalPath = await findOriginalFile(uuid, subdir || undefined) - if (!originalPath) { + const url = new URL(request.url, 'http://localhost') + const widthParam = url.searchParams.get('w') + + // Parse: [subdir/]filename.format + const parts = rawPath.split('/') + let filename, + subdir = '' + + if (parts.length > 1) { + subdir = parts.slice(0, -1).join('/') + '/' + filename = parts[parts.length - 1] + } else { + filename = parts[0] + } + + const dotIdx = filename.lastIndexOf('.') + if (dotIdx === -1) { + return reply.code(400).send({ error: 'Invalid request: no format specified' }) + } + + const uuid = filename.slice(0, dotIdx) + const format = filename.slice(dotIdx + 1).toLowerCase() + + if (!SUPPORTED_FORMATS.has(format)) { + return reply.code(400).send({ error: `Unsupported format: ${format}. Use avif or webp.` }) + } + + // Validate width + let width = null + if (widthParam) { + const w = parseInt(widthParam, 10) + if (!VALID_WIDTHS.includes(w)) { + return reply.code(400).send({ error: `Invalid width: ${widthParam}. Use: ${VALID_WIDTHS.join(', ')}` }) + } + width = w + } + + // If no width requested, serve original with short cache + if (!width) { + const originalPath = await findOriginalFile(uuid, subdir || undefined) + if (!originalPath) { + return reply.code(404).send({ error: 'Image not found' }) + } + reply.header('Cache-Control', CACHE_CONTROL_SHORT) + reply.header('Content-Type', format === 'avif' ? 'image/avif' : 'image/webp') + return reply.send(fs.createReadStream(originalPath)) + } + + const result = await getOrCreateResized(uuid, width, format, subdir || undefined) + if (!result) { return reply.code(404).send({ error: 'Image not found' }) } - reply.header('Cache-Control', CACHE_CONTROL_SHORT) + + reply.header('Cache-Control', CACHE_CONTROL_IMMUTABLE) reply.header('Content-Type', format === 'avif' ? 'image/avif' : 'image/webp') - return reply.send(fs.createReadStream(originalPath)) + return reply.send(fs.createReadStream(result.path)) + } catch (error) { + request.log.error({ err: error, url: request.url }, 'uploads-resized route error') + return reply.code(500).send({ error: error.message || 'Image resize failed' }) } - - const result = await getOrCreateResized(uuid, width, format, subdir || undefined) - if (!result) { - return reply.code(404).send({ error: 'Image not found' }) - } - - reply.header('Cache-Control', CACHE_CONTROL_IMMUTABLE) - reply.header('Content-Type', format === 'avif' ? 'image/avif' : 'image/webp') - return reply.send(fs.createReadStream(result.path)) }) }