Skip to content
Closed
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
2 changes: 2 additions & 0 deletions api/src/functions/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface MediaPost {
id: string;
mediaType: MediaEntity['mediaType'];
blobUrl: string;
thumbUrl?: string;
lat?: number;
lon?: number;
capturedAt: string;
Expand Down Expand Up @@ -42,6 +43,7 @@ export async function media(request: HttpRequest, context: InvocationContext): P
id: entity.rowKey,
mediaType: entity.mediaType,
blobUrl: entity.blobUrl,
thumbUrl: entity.thumbUrl,
lat: entity.lat,
lon: entity.lon,
capturedAt: entity.capturedAt,
Expand Down
42 changes: 37 additions & 5 deletions api/src/functions/mediaComplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,35 @@ import { checkMediaUploadToken } from '../mediaAuth';
import { ALLOWED_CONTENT_TYPES } from '../mediaTypes';
import { getMediaContainer } from '../mediaBlob';
import { getMediaTable, mediaPartitionKey, generateMediaRowKey, type MediaEntity, type MediaType } from '../mediaTable';
import { normalizeUploaderEmail } from '../mediaEmail';
import { getCurrentTripId } from '../tripId';

// Matches the shape produced by generateMediaBlobPath() in mediaBlob.ts,
// e.g. "2026-07-20/photo-<uuid>.jpg". Rejecting anything else stops a
// crafted blobPath from pointing outside the expected layout.
const BLOB_PATH_PATTERN = /^\d{4}-\d{2}-\d{2}\/(photo|video)-[0-9a-f-]{36}\.[a-z0-9]+$/;
// e.g. "transalpine-2026/2026-07-20/photo-<uuid>.jpg". The trip segment is
// pinned to the *current* trip id (same value used by mediaPartitionKey())
// rather than any trip id - otherwise a caller with the shared upload
// token could point a metadata row at a blob filed under a different
// trip's folder, leaving that trip's data inconsistent.
function blobPathPattern(): RegExp {
const tripId = getCurrentTripId().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${tripId}\\/\\d{4}-\\d{2}-\\d{2}\\/(photo|video)-[0-9a-f-]{36}\\.[a-z0-9]+$`);
}

// Matches generateThumbBlobPath() - always a JPEG regardless of the
// original media type, also pinned to the current trip id.
function thumbBlobPathPattern(): RegExp {
const tripId = getCurrentTripId().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${tripId}\\/\\d{4}-\\d{2}-\\d{2}\\/thumb-[0-9a-f-]{36}\\.jpg$`);
}

interface CompleteBody {
blobPath?: string;
contentType?: string;
lat?: number;
lon?: number;
capturedAt?: string;
uploadedBy?: string;
thumbBlobPath?: string;
}

/**
Expand All @@ -36,11 +53,14 @@ export async function mediaComplete(request: HttpRequest, context: InvocationCon
return { status: 400, body: 'Expected a JSON body' };
}

const { blobPath, contentType, lat, lon, capturedAt } = body;
const { blobPath, contentType, lat, lon, capturedAt, uploadedBy, thumbBlobPath } = body;

if (!blobPath || !BLOB_PATH_PATTERN.test(blobPath)) {
if (!blobPath || !blobPathPattern().test(blobPath)) {
return { status: 400, body: 'Missing or invalid blobPath' };
}
if (thumbBlobPath !== undefined && !thumbBlobPathPattern().test(thumbBlobPath)) {
return { status: 400, body: 'Invalid thumbBlobPath' };
}
if (!contentType || !(contentType in ALLOWED_CONTENT_TYPES)) {
return { status: 400, body: 'Missing or unsupported contentType' };
}
Expand All @@ -50,6 +70,12 @@ export async function mediaComplete(request: HttpRequest, context: InvocationCon
if (lon !== undefined && (typeof lon !== 'number' || !Number.isFinite(lon) || lon < -180 || lon > 180)) {
return { status: 400, body: 'lon must be a finite number between -180 and 180' };
}
// Self-reported (no accounts yet) but required so later edit/delete
// requests have something to check ownership against - see mediaEmail.ts.
const normalizedUploadedBy = normalizeUploaderEmail(uploadedBy);
if (!normalizedUploadedBy) {
return { status: 400, body: 'Missing or invalid uploadedBy email' };
}

const mediaType: MediaType = ALLOWED_CONTENT_TYPES[contentType].mediaType;

Expand All @@ -67,6 +93,10 @@ export async function mediaComplete(request: HttpRequest, context: InvocationCon
// parse as a real date, rather than storing/returning a value that
// could break date sorting/parsing downstream.
const capturedAtValid = capturedAt !== undefined && !Number.isNaN(new Date(capturedAt).getTime());
// Thumbnail is best-effort (see photos.ts) - no exists() check here
// (unlike the main blob above) since a missing/failed thumbnail just
// means the frontend falls back to blobUrl, not a broken post.
const thumbBlobClient = thumbBlobPath ? container.getBlockBlobClient(thumbBlobPath) : undefined;
const entity: MediaEntity = {
partitionKey: mediaPartitionKey(),
rowKey: generateMediaRowKey(),
Expand All @@ -76,7 +106,9 @@ export async function mediaComplete(request: HttpRequest, context: InvocationCon
contentType,
capturedAt: capturedAtValid ? capturedAt! : now,
uploadedAt: now,
uploadedBy: normalizedUploadedBy,
...(lat !== undefined && lon !== undefined ? { lat, lon } : {}),
...(thumbBlobClient ? { thumbBlobPath, thumbUrl: thumbBlobClient.url } : {}),
};

const table = await getMediaTable();
Expand Down
151 changes: 151 additions & 0 deletions api/src/functions/mediaItem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
import type { TableEntity } from '@azure/data-tables';
import { checkMediaUploadToken } from '../mediaAuth';
import { normalizeUploaderEmail } from '../mediaEmail';
import { getMediaTable, mediaPartitionKey, type MediaEntity } from '../mediaTable';
import { getMediaContainer } from '../mediaBlob';

interface PatchBody {
email?: string;
lat?: number;
lon?: number;
capturedAt?: string;
}

/**
* Point-reads the target post and confirms the caller both has the shared
* upload token (in the query string, checked the same way as the other
* write endpoints) and matches the post's self-reported `uploadedBy`.
* Neither check is real auth, but together they stop a random visitor
* (no token) or a different crew member (mismatched email) from touching
* someone else's post. Returns the entity on success, or the
* HttpResponseInit to return immediately on failure.
*/
type OwnedEntityResult =
| { ok: true; table: Awaited<ReturnType<typeof getMediaTable>>; entity: MediaEntity }
| { ok: false; response: HttpResponseInit };

async function loadOwnedEntity(
request: HttpRequest,
context: InvocationContext,
id: string,
): Promise<OwnedEntityResult> {
if (!checkMediaUploadToken(request, context)) {
return { ok: false, response: { status: 401, body: 'Invalid or missing token' } };
}

const email = normalizeUploaderEmail(request.query.get('email'));
if (!email) {
return { ok: false, response: { status: 400, body: 'Missing or invalid email query parameter' } };
}

const table = await getMediaTable();
let entity: MediaEntity;
try {
entity = await table.getEntity<MediaEntity>(mediaPartitionKey(), id);
} catch {
return { ok: false, response: { status: 404, body: 'Post not found' } };
}

if (entity.uploadedBy !== email) {
return { ok: false, response: { status: 403, body: 'This post was uploaded by a different email' } };
}

return { ok: true, table, entity };
}

/**
* DELETE /api/media/{id} - removes both the blob and the metadata row.
*/
export async function mediaItemDelete(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
const id = request.params.id;
if (!id) return { status: 400, body: 'Missing id' };

const loaded = await loadOwnedEntity(request, context, id);
if (!loaded.ok) return loaded.response;
const { table, entity } = loaded;

try {
const container = await getMediaContainer();
await container.getBlockBlobClient(entity.blobPath).deleteIfExists();
if (entity.thumbBlobPath) {
await container.getBlockBlobClient(entity.thumbBlobPath).deleteIfExists();
}
await table.deleteEntity(mediaPartitionKey(), id);
return { status: 204 };
} catch (error) {
context.error('Failed to delete media post', error);
return { status: 500, body: 'Failed to delete media post' };
}
}

/**
* PATCH /api/media/{id} - lets the uploader correct the location and/or
* captured date after the fact. Only the fields present in the body are
* changed (Table Storage "Merge" update).
*/
export async function mediaItemPatch(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
const id = request.params.id;
if (!id) return { status: 400, body: 'Missing id' };

const loaded = await loadOwnedEntity(request, context, id);
if (!loaded.ok) return loaded.response;
const { table, entity } = loaded;

let body: PatchBody;
try {
body = (await request.json()) as PatchBody;
} catch {
return { status: 400, body: 'Expected a JSON body' };
}

const { lat, lon, capturedAt } = body;
const update: TableEntity<Partial<MediaEntity>> = {
partitionKey: entity.partitionKey,
rowKey: entity.rowKey,
};

if (lat !== undefined || lon !== undefined) {
if (typeof lat !== 'number' || !Number.isFinite(lat) || lat < -90 || lat > 90) {
return { status: 400, body: 'lat must be a finite number between -90 and 90' };
}
if (typeof lon !== 'number' || !Number.isFinite(lon) || lon < -180 || lon > 180) {
return { status: 400, body: 'lon must be a finite number between -180 and 180' };
}
update.lat = lat;
update.lon = lon;
}

if (capturedAt !== undefined) {
if (Number.isNaN(new Date(capturedAt).getTime())) {
return { status: 400, body: 'capturedAt must be a valid date' };
}
update.capturedAt = capturedAt;
}

if (Object.keys(update).length <= 2) {
return { status: 400, body: 'Nothing to update - provide lat+lon and/or capturedAt' };
}

try {
await table.updateEntity(update, 'Merge');
return { status: 204 };
} catch (error) {
context.error('Failed to update media post', error);
return { status: 500, body: 'Failed to update media post' };
}
}

app.http('mediaItemDelete', {
methods: ['DELETE'],
authLevel: 'anonymous',
route: 'media/{id}',
handler: mediaItemDelete,
});

app.http('mediaItemPatch', {
methods: ['PATCH'],
authLevel: 'anonymous',
route: 'media/{id}',
handler: mediaItemPatch,
});
60 changes: 60 additions & 0 deletions api/src/functions/mediaMine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
import { getMediaTable, mediaPartitionKey, type MediaEntity } from '../mediaTable';
import { normalizeUploaderEmail } from '../mediaEmail';
import type { MediaPost } from './media';

const MAX_LIMIT = 500;

/**
* Lists a single uploader's own posts, keyed by their self-reported
* email. Anonymous/public read, same exposure level as `/api/media` -
* this is a "find posts with this uploadedBy" filter, not an
* authenticated "my account" view. Once real sign-in exists, this becomes
* the same query but sourced from a verified session's email.
*/
export async function mediaMine(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
const email = normalizeUploaderEmail(request.query.get('email'));
if (!email) {
return { status: 400, body: 'Missing or invalid email query parameter' };
}

try {
const table = await getMediaTable();
const entities = table.listEntities<MediaEntity>({
queryOptions: {
filter: `PartitionKey eq '${mediaPartitionKey()}' and uploadedBy eq '${email}'`,
},
});

const posts: MediaPost[] = [];
for await (const entity of entities) {
posts.push({
id: entity.rowKey,
mediaType: entity.mediaType,
blobUrl: entity.blobUrl,
thumbUrl: entity.thumbUrl,
lat: entity.lat,
lon: entity.lon,
capturedAt: entity.capturedAt,
uploadedAt: entity.uploadedAt,
});
if (posts.length >= MAX_LIMIT) break;
}

return {
status: 200,
jsonBody: posts,
headers: { 'Cache-Control': 'no-store' },
};
} catch (error) {
context.error('Failed to read own media posts', error);
return { status: 500, body: 'Failed to read own media posts' };
}
}

app.http('mediaMine', {
methods: ['GET'],
authLevel: 'anonymous',
route: 'media/mine',
handler: mediaMine,
});
24 changes: 21 additions & 3 deletions api/src/functions/mediaSas.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
import { checkMediaUploadToken } from '../mediaAuth';
import { ALLOWED_CONTENT_TYPES } from '../mediaTypes';
import { createUploadSasUrl, generateMediaBlobPath, MAX_UPLOAD_BYTES } from '../mediaBlob';
import { createUploadSasUrl, generateMediaBlobPath, generateThumbBlobPath, MAX_UPLOAD_BYTES } from '../mediaBlob';

/**
* Step 1 of the upload flow: issue a short-lived, single-blob, write-only
* SAS URL. The phone then PUTs the file bytes directly to Blob Storage
* using that URL (see /photos), and finally calls /api/media/complete.
* File bytes never pass through this (or any) Function, which is what
* keeps uploads cheap even for video on a Consumption plan.
*
* Also issues a second SAS URL for an optional small thumbnail blob
* (always JPEG) - the client generates the thumbnail itself (canvas
* resize) and PUTs it here too, so map pins never have to download the
* full-size original. Still zero server-side image processing.
*/
export async function mediaSas(request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> {
if (!checkMediaUploadToken(request, context)) {
Expand All @@ -29,12 +34,25 @@ export async function mediaSas(request: HttpRequest, context: InvocationContext)

const { mediaType, extension } = ALLOWED_CONTENT_TYPES[contentType];
const blobPath = generateMediaBlobPath(mediaType, extension);
const thumbBlobPath = generateThumbBlobPath();

try {
const { uploadUrl, blobUrl, expiresOn } = await createUploadSasUrl(blobPath, contentType);
const [main, thumb] = await Promise.all([
createUploadSasUrl(blobPath, contentType),
createUploadSasUrl(thumbBlobPath, 'image/jpeg'),
]);
return {
status: 200,
jsonBody: { uploadUrl, blobUrl, blobPath, mediaType, maxUploadBytes: MAX_UPLOAD_BYTES, expiresOn },
jsonBody: {
uploadUrl: main.uploadUrl,
blobUrl: main.blobUrl,
blobPath,
mediaType,
maxUploadBytes: MAX_UPLOAD_BYTES,
expiresOn: main.expiresOn,
thumbUploadUrl: thumb.uploadUrl,
thumbBlobPath,
},
headers: { 'Cache-Control': 'no-store' },
};
} catch (error) {
Expand Down
Loading