Compare commits

..

No commits in common. "main" and "v0.0.5" have entirely different histories.
main ... v0.0.5

22 changed files with 1332 additions and 1298 deletions

7
.gitignore vendored
View File

@ -20,12 +20,5 @@ Thumbs.db
vite.config.js.timestamp-* vite.config.js.timestamp-*
vite.config.ts.timestamp-* vite.config.ts.timestamp-*
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth
uploads uploads
.direnv .direnv

View File

@ -1,7 +1,7 @@
{ {
"useTabs": true, "useTabs": true,
"singleQuote": true, "singleQuote": true,
"trailingComma": "es5", "trailingComma": "none",
"printWidth": 100, "printWidth": 100,
"plugins": ["prettier-plugin-svelte"], "plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]

View File

@ -1,4 +1,4 @@
FROM node:22-alpine AS builder FROM node:21-alpine AS builder
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
@ -7,12 +7,11 @@ RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
FROM node:22-alpine FROM node:21-alpine
WORKDIR /app WORKDIR /app
RUN chown -R node:node /app RUN chown -R node:node /app
USER node:node USER node:node
COPY ./container/entrypoint.sh /entrypoint.sh COPY ./container/entrypoint.sh /entrypoint.sh
COPY package.json . COPY package.json .
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/build ./build COPY --from=builder /app/build ./build
ENTRYPOINT ["sh", "/entrypoint.sh"] ENTRYPOINT ["sh", "/entrypoint.sh"]

View File

@ -15,19 +15,19 @@ export default [
languageOptions: { languageOptions: {
globals: { globals: {
...globals.browser, ...globals.browser,
...globals.node, ...globals.node
}, }
}, }
}, },
{ {
files: ['**/*.svelte'], files: ['**/*.svelte'],
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
parser: ts.parser, parser: ts.parser
}, }
}, }
}, },
{ {
ignores: ['build/', '.svelte-kit/', 'dist/'], ignores: ['build/', '.svelte-kit/', 'dist/']
}, }
]; ];

2241
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,29 +1,24 @@
{ {
"name": "fotochallenge", "name": "fotochallenge",
"version": "0.0.8", "version": "0.0.5",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "npm run test:integration && npm run test:unit", "test": "vitest",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .", "lint": "prettier --check . && eslint .",
"format": "prettier --write .", "format": "prettier --write ."
"test:integration": "playwright test",
"test:unit": "vitest"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.46.1",
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-node": "^5.2.0", "@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.0.0", "@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/bunyan": "^1.8.11",
"@types/eslint": "^9.0.0", "@types/eslint": "^9.0.0",
"@types/node": "^20.14.11", "@types/node": "^20.14.11",
"@types/uuid": "^10.0.0",
"bulma": "^1.0.1", "bulma": "^1.0.1",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
@ -34,7 +29,7 @@
"sass": "^1.77.5", "sass": "^1.77.5",
"simple-svelte-autocomplete": "^2.5.2", "simple-svelte-autocomplete": "^2.5.2",
"svelte": "^4.2.7", "svelte": "^4.2.7",
"svelte-check": "^4.0.0", "svelte-check": "^3.6.0",
"tslib": "^2.4.1", "tslib": "^2.4.1",
"typescript": "^5.0.0", "typescript": "^5.0.0",
"typescript-eslint": "^8.0.0-alpha.20", "typescript-eslint": "^8.0.0-alpha.20",
@ -44,9 +39,5 @@
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">20" "node": ">20"
},
"dependencies": {
"bunyan": "^1.8.15",
"uuid": "^10.0.0"
} }
} }

View File

@ -1,12 +0,0 @@
import type { PlaywrightTestConfig } from '@playwright/test';
const config: PlaywrightTestConfig = {
webServer: {
command: 'npm run build && npm run preview',
port: 4173,
},
testDir: 'tests',
testMatch: /(.+\.)?(test|spec)\.[jt]s/,
};
export default config;

View File

@ -1,12 +1,4 @@
{ {
$schema: 'https://docs.renovatebot.com/renovate-schema.json', $schema: 'https://docs.renovatebot.com/renovate-schema.json',
extends: ['local>renovate-bot/renovate-config'], extends: ['local>renovate-bot/renovate-config']
packageRules: [
{
groupName: 'all non-major dependencies',
groupSlug: 'all-minor-patch',
matchPackageNames: ['*'],
matchUpdateTypes: ['minor', 'patch'],
},
],
} }

6
src/app.d.ts vendored
View File

@ -9,9 +9,3 @@ declare global {
// interface Platform {} // interface Platform {}
} }
} }
declare namespace App {
interface Locals {
requestId: string;
}
}

View File

@ -1,43 +0,0 @@
import { log, timedExecution, requestIdHeader } from '$lib';
import { validate, v7 as uuidv7 } from 'uuid';
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
// use incoming requestId, if it is a valid uuid, else generate one
const reqIdFromRequest = event.request.headers.get(requestIdHeader);
const { requestId, fromRequest } =
reqIdFromRequest && validate(reqIdFromRequest)
? { requestId: reqIdFromRequest, fromRequest: true }
: { requestId: uuidv7(), fromRequest: false };
const context = {
requestId,
route: event.route.id,
method: event.request.method,
userAgent: event.request.headers.get('user-agent'),
clientIP: event.getClientAddress(),
};
if (fromRequest) {
log.trace(context, 'using incoming request-id');
}
// make requestId available to handlers
event.locals.requestId = requestId;
const { executionTime, result: response } = await timedExecution(
async () => await resolve(event)
);
response.headers.set(requestIdHeader, requestId);
log.info(
{
executionTime: `${executionTime}ms`,
status: response.status,
size: response.headers.get('content-length'),
...context,
},
'finished request'
);
return response;
};

20
src/index.test.ts Normal file
View File

@ -0,0 +1,20 @@
import safePath from '$lib';
import { describe, it, expect } from 'vitest';
describe('safe path', () => {
it('reject names with ../', () => {
expect(safePath('./uplodas', '../foobar')).toBe(false);
});
it('accept names with ./', () => {
expect(safePath('./uplodas', './foobar')).toBe(true);
});
it('reject names with /', () => {
expect(safePath('./uplodas', 'foo/bar')).toBe(false);
});
it('accept happy path', () => {
expect(safePath('./uplodas', 'foobar')).toBe(true);
});
});

View File

@ -1,50 +0,0 @@
import safePath, { timedExecution } from '$lib';
import { describe, it, expect } from 'vitest';
describe('safe path', () => {
it('reject names with ../', () => {
expect(safePath('./uplodas', '../foobar')).toBe(false);
});
it('accept names with ./', () => {
expect(safePath('./uplodas', './foobar')).toBe(true);
});
it('reject names with /', () => {
expect(safePath('./uplodas', 'foo/bar')).toBe(false);
});
it('accept happy path', () => {
expect(safePath('./uplodas', 'foobar')).toBe(true);
});
it('accept names starting with `..`', () => {
expect(safePath('./uplodas', '..foobar')).toBe(true);
});
it('accept names ending with `..`', () => {
expect(safePath('./uplodas', 'foobar..')).toBe(true);
});
it('accept names starting and ending with `..`', () => {
expect(safePath('./uplodas', '..foobar..')).toBe(true);
});
});
describe('timedExecution', () => {
const asyncIdentity = async <T>(v: T): Promise<T> => v;
const identity = <T>(v: T): T => v;
it('works with async', async () => {
const { executionTime, result } = await timedExecution(() => asyncIdentity(5));
expect(result).toBe(5);
// execution time is always positive
expect(executionTime).toBeGreaterThanOrEqual(0);
});
it('works with sync', async () => {
const { executionTime, result } = await timedExecution(() => identity(5));
expect(result).toBe(5);
// execution time is always positive
expect(executionTime).toBeGreaterThanOrEqual(0);
});
});

View File

@ -1,45 +1,24 @@
// place files you want to import through the `$lib` alias in this folder. // place files you want to import through the `$lib` alias in this folder.
import path from 'path'; import path from 'path';
import bunyan from 'bunyan';
export const log = bunyan.createLogger({ function safePath(basePath: string, name: string): boolean {
name: 'fotochallenge',
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
src: true,
});
const safePath = (basePath: string, name: string): boolean => {
const fullPath = `${basePath}/${name}`; const fullPath = `${basePath}/${name}`;
const relative = path.relative(basePath, fullPath); const relative = path.relative(basePath, fullPath);
return ( return (
!!relative && !!relative &&
// does move out of `basePath` // does move out of `basePath`
!relative.startsWith(`..${path.sep}`) && !relative.startsWith('..') &&
// exactly one layer deep, e.g. no `./uplodas/foo/bar` // exactly one layer deep, e.g. no `./uplodas/foo/bar`
!relative.includes(path.sep) && !relative.includes('/') &&
// result is not an absolute path // result is not an absolute path
!path.isAbsolute(relative) !path.isAbsolute(relative)
); );
}; }
export default safePath;
const defaultPath: string = './uploads'; const defaultPath: string = './uploads';
if (!('STORAGE_PATH' in process.env)) { if (!('STORAGE_PATH' in process.env)) {
log.warn(`'STORAGE_PATH' environment variable is not set. Defaulting to ${defaultPath}`); console.warn(`'STORAGE_PATH' environment variable is not set. Defaulting to ${defaultPath}`);
} }
export const storagePath: string = process.env.STORAGE_PATH ?? defaultPath; export const storagePath: string = process.env.STORAGE_PATH ?? defaultPath;
export const requestIdHeader = 'x-request-id'; export default safePath;
export type MaybePromise<T> = T | Promise<T>;
export async function timedExecution<T>(
fn: () => MaybePromise<T>
): Promise<{ executionTime: number; result: T }> {
const start = process.hrtime();
const result = await fn();
const end = process.hrtime(start);
const executionTime = (end[0] * 1e6 + end[1]) / 1e6;
return { executionTime, result };
}

View File

@ -1,7 +1,7 @@
import { writeFileSync, mkdirSync, existsSync } from 'fs'; import { writeFileSync, mkdirSync, existsSync } from 'fs';
import { fail } from '@sveltejs/kit'; import { fail } from '@sveltejs/kit';
import type { RequestEvent } from './$types'; import type { RequestEvent } from './$types';
import safePath, { storagePath, log } from '$lib'; import safePath, { storagePath } from '$lib';
import { hash } from 'crypto'; import { hash } from 'crypto';
import path from 'path'; import path from 'path';
@ -12,44 +12,34 @@ const mkdirIfNotExists = (path: string) => {
}; };
export const actions = { export const actions = {
default: async ({ request, locals }: RequestEvent) => { default: async ({ request }: RequestEvent) => {
let context: { [key: string]: string | string[] } = { requestId: locals.requestId };
const data = await request.formData(); const data = await request.formData();
const formFiles = data.getAll('files'); const formFiles = data.getAll('files');
if (!formFiles) { if (!formFiles) {
log.debug(context, 'missing files');
return fail(400, { field: 'files', files: formFiles, missing: true }); return fail(400, { field: 'files', files: formFiles, missing: true });
} else if (!(formFiles as File[])) { } else if (!(formFiles as File[])) {
log.debug(context, 'invalid files');
return fail(400, { field: 'files', files: formFiles, incorrect: true }); return fail(400, { field: 'files', files: formFiles, incorrect: true });
} }
const files = formFiles as File[]; const files = formFiles as File[];
const fileNames = files.map((file) => file.name); console.log(files);
context = { fileNames, ...context };
if (files.length === 0) { if (files.length === 0) {
log.debug(context, 'empty files');
return fail(400, { field: 'files', files: formFiles, empty: true }); return fail(400, { field: 'files', files: formFiles, empty: true });
} }
const formName = data.get('name'); const formName = data.get('name');
if (!formName) { if (!formName) {
log.debug(context, 'missing name');
return fail(400, { field: 'name', name: formName, missing: true }); return fail(400, { field: 'name', name: formName, missing: true });
} else if (!(formName as string)) { } else if (!(formName as string)) {
log.debug(context, 'invalid name');
return fail(400, { field: 'name', name: formName, incorrect: true }); return fail(400, { field: 'name', name: formName, incorrect: true });
} }
const name = formName as string; const name = formName as string;
context = { name, ...context };
if (!safePath(storagePath, name)) { if (!safePath(storagePath, name)) {
log.warn(context, 'Supplied name would cause dir traversal. Rejecting...');
return fail(400, { field: 'name', name: name, incorrect: true }); return fail(400, { field: 'name', name: name, incorrect: true });
} }
// const name = safePath(formName as string);
log.info(context, 'Uploading files');
files.forEach(async (file) => { files.forEach(async (file) => {
const outPath = `${storagePath}/${name}`; const outPath = `${storagePath}/${name}`;
@ -57,19 +47,17 @@ export const actions = {
const ext = path.extname(file.name); const ext = path.extname(file.name);
mkdirIfNotExists(outPath); mkdirIfNotExists(outPath);
const filename = `${hash('sha1', content)}${ext}`; const filename = hash('sha1', content);
const fullPath = `${outPath}/${filename}`; const fullPath = `${outPath}/${filename}${ext}`;
context = { file: fullPath, ...context };
if (existsSync(fullPath)) { if (existsSync(fullPath)) {
log.debug(context, 'File has already been uploaded. Skipping...'); console.warn(`${fullPath} has already been uploaded. Skipping...`);
} else { } else {
log.debug(context, 'saving file');
writeFileSync(fullPath, Buffer.from(await file.arrayBuffer()), { flag: 'a+' }); writeFileSync(fullPath, Buffer.from(await file.arrayBuffer()), { flag: 'a+' });
} }
}); });
return { return {
success: true, success: true
}; };
}, }
}; };

View File

@ -13,7 +13,7 @@
const siPrefixes = new Map([ const siPrefixes = new Map([
[1_000_000, 'M'], [1_000_000, 'M'],
[1_000, 'k'], [1_000, 'k']
]); ]);
const fileSize = (files: FileList) => { const fileSize = (files: FileList) => {
const size = Array.from(files) const size = Array.from(files)

View File

@ -1,50 +0,0 @@
import { storagePath } from '$lib';
import { json } from '@sveltejs/kit';
import { stat, access, constants } from 'fs/promises';
import type { RequestHandler } from './$types';
type Status = 'OK' | 'ERROR';
type Result = { status: Status; checks: Checks };
type Checks = { [key: string]: CheckResult };
type CheckResult = true | string;
const fileExists = async (path: string) => !!(await stat(path).catch(() => false));
const isDirectory = async (path: string) =>
!!(await stat(path)
.then((s) => s.isDirectory())
.catch(() => false));
const isWritable = async (path: string) =>
await access(path, constants.W_OK)
.then(() => true)
.catch(() => false);
const checkStoragePath = async (path: string): Promise<CheckResult> => {
if (!fileExists(path)) {
return '`STORAGE_PATH` does not exist';
} else if (!isDirectory(path)) {
return '`STORAGE_PATH` is not a directory';
} else if (!isWritable(path)) {
return '`STORAGE_PATH` is not writable';
}
return true;
};
export const GET: RequestHandler = async () => {
const storagePathResult = await checkStoragePath(storagePath);
const checks: Checks = {
storagePath: storagePathResult,
};
const healthy = Object.values(checks)
.map((r) => (r === true ? true : false))
.reduce((prev, next) => prev && next, true);
const status = healthy ? 'OK' : 'ERROR';
const result: Result = { status, checks };
const httpStatus = healthy ? 200 : 500;
return json(result, { status: httpStatus, headers: { healthy: healthy.toString() } });
};

View File

@ -1,12 +1,12 @@
import { storagePath } from '$lib'; import { storagePath } from '$lib';
import { json } from '@sveltejs/kit'; import { json } from '@sveltejs/kit';
import { readdirSync, statSync } from 'fs'; import { readdirSync, statSync } from 'fs';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = () => { export function GET() {
const names = readdirSync(storagePath) const names = readdirSync(storagePath).filter((f) =>
.filter((f) => statSync(`${storagePath}/${f}`).isDirectory()) statSync(`${storagePath}/${f}`).isDirectory()
.toSorted(); );
names.sort();
return json(names); return json(names);
}; }

View File

@ -11,8 +11,8 @@ const config = {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter. // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters. // See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter(), adapter: adapter()
}, }
}; };
export default config; export default config;

View File

@ -1,13 +0,0 @@
import { expect, test } from '@playwright/test';
test('healthy test', async ({ playwright }) => {
const context = await playwright.request.newContext();
const response = await context.get('health');
await expect(response.status()).toBe(200);
await expect(response.headers()).toHaveProperty('healthy');
await expect(response.headers()['healthy']).toBe('true');
const body = await response.json();
await expect(body).toHaveProperty('status');
await expect(body['status']).toBe('OK');
});

View File

@ -1,10 +0,0 @@
import { expect, test } from '@playwright/test';
test('contains header text', async ({ playwright }) => {
const context = await playwright.request.newContext();
const response = await context.get('');
await expect(response.status()).toBe(200);
const body = (await response.body()).toString();
await expect(body).toContain('Gabi und Hannes Fotochallenge');
});

View File

@ -1,37 +0,0 @@
import { expect, test } from '@playwright/test';
import { validate, NIL } from 'uuid';
import { requestIdHeader } from '$lib';
test('response contains request-id header', async ({ playwright }) => {
const context = await playwright.request.newContext();
const response = await context.get('health');
const headers = response.headers();
await expect(headers).toHaveProperty(requestIdHeader);
});
test('request-id is valid uuid', async ({ playwright }) => {
const context = await playwright.request.newContext();
const response = await context.get('health');
const headers = response.headers();
const requestId = headers[requestIdHeader];
await expect(validate(requestId)).toBe(true);
});
test('reuse valid incoming uuid', async ({ playwright }) => {
const context = await playwright.request.newContext();
const response = await context.get('health', { headers: { [requestIdHeader]: NIL } });
const headers = response.headers();
const requestId = headers[requestIdHeader];
await expect(requestId).toBe(NIL);
});
test('ignore invalid incoming uuid', async ({ playwright }) => {
const invalid = '00000000-0000-0000-0000-00000000000z';
const context = await playwright.request.newContext();
const response = await context.get('health', { headers: { [requestIdHeader]: invalid } });
const headers = response.headers();
const requestId = headers[requestIdHeader];
await expect(requestId).not.toBe(invalid);
});

View File

@ -5,14 +5,14 @@ export default defineConfig({
plugins: [sveltekit()], plugins: [sveltekit()],
test: { test: {
include: ['src/**/*.{test,spec}.{js,ts}'], include: ['src/**/*.{test,spec}.{js,ts}']
}, },
css: { css: {
preprocessorOptions: { preprocessorOptions: {
scss: { scss: {
additionalData: '@use "src/variables.scss" as *;', additionalData: '@use "src/variables.scss" as *;'
}, }
}, }
}, }
}); });