diff --git a/web/cron.sh b/web/cron.sh index ad2706e9..7c67bc01 100644 --- a/web/cron.sh +++ b/web/cron.sh @@ -32,7 +32,7 @@ upload_and_delete() { ls $file # API call to upload file - response=$(curl -b cookie.txt --location --request PUT "$API_URL/trail/upload" --header 'Content-Type: application/gpx+xml' --data-binary "@$file") + response=$(curl -b cookie.txt --location --request PUT "$API_URL/trail/upload" --header 'Content-Type: multipart/form-data' -F "file=@$file" -F "name=$base_name") # Check if API call was successful (status code 200) if [ $? -eq 0 ] && [ "$(echo "$response" | grep -c "author")" -eq 1 ]; then diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts index 0af49078..8fbe4a5f 100644 --- a/web/src/hooks.server.ts +++ b/web/src/hooks.server.ts @@ -4,11 +4,51 @@ import type { Settings } from '$lib/models/settings' import { pb } from '$lib/pocketbase' import { isRouteProtected } from '$lib/util/authorization_util' -import { redirect, type Handle } from '@sveltejs/kit' +import { json, redirect, text, type Handle } from '@sveltejs/kit' +import { sequence } from '@sveltejs/kit/hooks' import { MeiliSearch } from 'meilisearch' import { locale } from 'svelte-i18n' -export const handle: Handle = async ({ event, resolve }) => { + +function csrf(allowedPaths: string[]): Handle { + return async ({ event, resolve }) => { + const { request, url } = event; + const forbidden = + isFormContentType(request) && + (request.method === "POST" || + request.method === "PUT" || + request.method === "PATCH" || + request.method === "DELETE") && + request.headers.get("origin") !== url.origin && + !allowedPaths.includes(url.pathname); + + if (forbidden) { + const message = `Cross-site ${request.method} form submissions are forbidden`; + if (request.headers.get("accept") === "application/json") { + return json({ message }, { status: 403 }); + } + return text(message, { status: 403 }); + } + + return resolve(event); + }; +} + +function isContentType(request: Request, ...types: string[]) { + const type = request.headers.get("content-type")?.split(";", 1)[0].trim() ?? ""; + return types.includes(type.toLowerCase()); +} +function isFormContentType(request: Request) { + return isContentType( + request, + "application/x-www-form-urlencoded", + "multipart/form-data", + "text/plain", + ); +} + + +const auth: Handle = async ({ event, resolve }) => { // load the store data from the request cookie string pb.authStore.loadFromCookie(event.request.headers.get('cookie') || '') @@ -69,4 +109,6 @@ export const handle: Handle = async ({ event, resolve }) => { ) return response -} \ No newline at end of file +} + +export const handle = sequence(csrf(['/api/v1/trail/upload']), auth) \ No newline at end of file diff --git a/web/src/lib/models/summit_log.ts b/web/src/lib/models/summit_log.ts index a34a115e..fa6cb435 100644 --- a/web/src/lib/models/summit_log.ts +++ b/web/src/lib/models/summit_log.ts @@ -5,7 +5,7 @@ class SummitLog { date: string; text?: string; gpx?: string; - _gpx: File | null; + _gpx: File | Blob | null; distance?: number elevation_gain?: number elevation_loss?: number diff --git a/web/src/lib/stores/summit_log_store.ts b/web/src/lib/stores/summit_log_store.ts index e14e5499..6627f904 100644 --- a/web/src/lib/stores/summit_log_store.ts +++ b/web/src/lib/stores/summit_log_store.ts @@ -39,10 +39,10 @@ export async function summit_logs_index(filter?: SummitLogFilter, f: (url: Reque return fetchedSummitLogs; } -export async function summit_logs_create(summitLog: SummitLog) { +export async function summit_logs_create(summitLog: SummitLog, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { summitLog.author = pb.authStore.model!.id - let r = await fetch('/api/v1/summit-log', { + let r = await f('/api/v1/summit-log', { method: 'PUT', body: JSON.stringify(summitLog), }) @@ -58,7 +58,7 @@ export async function summit_logs_create(summitLog: SummitLog) { formData.append("gpx", summitLog._gpx) - r = await fetch(`/api/v1/summit-log/${model.id!}/file`, { + r = await f(`/api/v1/summit-log/${model.id!}/file`, { method: 'POST', body: formData, }) diff --git a/web/src/lib/stores/trail_store.ts b/web/src/lib/stores/trail_store.ts index 3be774b0..1164bb3f 100644 --- a/web/src/lib/stores/trail_store.ts +++ b/web/src/lib/stores/trail_store.ts @@ -186,7 +186,7 @@ export async function trails_create(trail: Trail, photos: File[], gpx: File | Bl trail.waypoints.push(model.id!); } for (const summitLog of trail.expand.summit_logs) { - const model = await summit_logs_create(summitLog); + const model = await summit_logs_create(summitLog, f); trail.summit_logs.push(model.id!); } @@ -373,7 +373,7 @@ export async function trails_upload(file: File, f: (url: RequestInfo | URL, conf } } -export async function fetchGPX(trail: { gpx: string } & Record, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { +export async function fetchGPX(trail: { gpx?: string } & Record, f: (url: RequestInfo | URL, config?: RequestInit) => Promise = fetch) { if (!trail.gpx) { return ""; } diff --git a/web/src/routes/api/v1/trail/upload/+server.ts b/web/src/routes/api/v1/trail/upload/+server.ts index 77e282e8..9100b12e 100644 --- a/web/src/routes/api/v1/trail/upload/+server.ts +++ b/web/src/routes/api/v1/trail/upload/+server.ts @@ -1,3 +1,4 @@ +import { SummitLog } from "$lib/models/summit_log"; import type { Trail } from "$lib/models/trail"; import { trails_create } from "$lib/stores/trail_store"; import { fromFIT, fromKML, fromTCX, gpx2trail, isFITFile } from "$lib/util/gpx_util"; @@ -11,8 +12,8 @@ export async function PUT(event: RequestEvent) { const fileContent = await (data.get("file") as Blob).text(); let gpxData = "" let gpxFile: Blob; - if (isFITFile(fileBuffer)) { - gpxData = await fromFIT(fileBuffer); + if (isFITFile(fileBuffer)) { + gpxData = await fromFIT(fileBuffer); gpxFile = new Blob([gpxData], { type: "application/gpx+xml", }); @@ -37,6 +38,12 @@ export async function PUT(event: RequestEvent) { let trail: Trail; try { trail = (await gpx2trail(gpxData, data.get("name") as string | undefined)).trail; + + const log = new SummitLog(trail.date as string, {}) + log.expand.gpx_data = gpxData; + log._gpx = gpxFile; + + trail.expand.summit_logs.push(log); } catch (e: any) { throw new ClientResponseError({ status: 400, response: { message: "Invalid file" } }) } diff --git a/web/svelte.config.js b/web/svelte.config.js index a88a9d74..35b2d474 100644 --- a/web/svelte.config.js +++ b/web/svelte.config.js @@ -8,6 +8,9 @@ const config = { preprocess: vitePreprocess(), kit: { + csrf: { + checkOrigin: false + }, // 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. // See https://kit.svelte.dev/docs/adapters for more information about adapters.