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
3 changes: 2 additions & 1 deletion backend/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import OtpCode from "models/OtpCode.js";
import MagicLink from "models/MagicLink.js";
import RateLimit from "models/RateLimit.js";
import Log from "models/Log.js";
import TripDocument from "models/TripDocument.js";
import mongoose from "mongoose";

let isConnected = false;
Expand Down Expand Up @@ -48,4 +49,4 @@ export async function connect() {
}
}

export { Trip, User, Participant, IntegrationToken, Session, OtpCode, MagicLink, RateLimit, Log };
export { Trip, User, Participant, IntegrationToken, Session, OtpCode, MagicLink, RateLimit, Log, TripDocument };
8 changes: 8 additions & 0 deletions backend/lib/storage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { nanoId } from "lib/utils.js";

const { S3_KEY_ID, S3_SECRET, S3_ENDPOINT, S3_BUCKET, S3_PUBLIC_URL } = process.env;
Expand All @@ -23,6 +24,13 @@ export function imageUrl(fileName?: string | null): string | null {
return `${publicUrl}/${fileName}`;
}

export async function createUploadUrl(key: string, contentType: string): Promise<string | null> {
if (!client) return null;
return getSignedUrl(client, new PutObjectCommand({ Bucket: S3_BUCKET!, Key: key, ContentType: contentType }), {
expiresIn: 600,
});
}

export async function deleteFromStorage(key: string): Promise<void> {
if (!client) return;
await client.send(new DeleteObjectCommand({ Bucket: S3_BUCKET!, Key: key }));
Expand Down
26 changes: 26 additions & 0 deletions backend/models/TripDocument.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { TripDocument } from "@birdplan/shared";
import mongoose, { Schema, model, Model } from "mongoose";
import { nanoId } from "lib/utils.js";

const fields: Record<keyof Omit<TripDocument, "createdAt" | "updatedAt" | "url">, any> = {
_id: { type: String, default: () => nanoId() },
tripId: { type: String, required: true },
name: { type: String, required: true },
key: { type: String, required: true },
size: { type: Number, required: true },
mimeType: { type: String, required: true },
category: { type: String, default: null },
visibility: { type: String, default: "trip" },
uploadedBy: { type: String, required: true },
};

const TripDocumentSchema = new Schema(fields, {
timestamps: true,
});

TripDocumentSchema.index({ tripId: 1, createdAt: 1 });

const TripDocumentModel =
(mongoose.models.TripDocument as Model<TripDocument>) || model<TripDocument>("TripDocument", TripDocumentSchema);

export default TripDocumentModel;
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1075.0",
"@aws-sdk/s3-request-presigner": "^3.1079.0",
"@hono/node-server": "^1.14.4",
"@maphubs/tokml": "^0.6.1",
"axios": "^1.9.0",
Expand Down
172 changes: 172 additions & 0 deletions backend/routes/trips/[tripId]/documents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { Hono } from "hono";
import { HTTPException } from "hono/http-exception";
import { authenticate, authenticateOptional, nanoId, sanitizeFileName } from "lib/utils.js";
import { connect, Trip, TripDocument } from "lib/db.js";
import { isTripEditor, isEditorInRoster, loadActiveRoster } from "lib/participants.js";
import { createUploadUrl, deleteFromStorage, imageUrl } from "lib/storage.js";
import type {
TripDocumentUploadUrlInput,
TripDocumentCreateInput,
TripDocumentUpdateInput,
TripDocumentCategory,
TripDocumentVisibility,
} from "@birdplan/shared";

const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024;
const MAX_DOCUMENTS_PER_TRIP = 50;

const CATEGORIES: TripDocumentCategory[] = ["flights", "lodging", "transport", "permits", "maps", "other"];
const VISIBILITIES: TripDocumentVisibility[] = ["private", "trip", "public"];

const documents = new Hono();

const requireEditor = async (c: any): Promise<string> => {
const session = await authenticate(c);
const tripId = c.req.param("tripId");
if (!tripId) throw new HTTPException(400, { message: "Trip ID is required" });

await connect();
const [trip, isEditor] = await Promise.all([Trip.exists({ _id: tripId }), isTripEditor(tripId, session.userId)]);
if (!trip) throw new HTTPException(404, { message: "Trip not found" });
if (!isEditor) throw new HTTPException(403, { message: "Forbidden" });

return session.userId;
};

const validateName = (name: unknown) => {
if (typeof name !== "string" || !name.trim() || name.length > 200) {
throw new HTTPException(400, { message: "Invalid file name" });
}
};

const validateMeta = (name: unknown, size: unknown, mimeType: unknown) => {
validateName(name);
if (typeof size !== "number" || !Number.isFinite(size) || size <= 0) {
throw new HTTPException(400, { message: "Invalid file size" });
}
if (size > MAX_DOCUMENT_BYTES) {
throw new HTTPException(400, { message: "Files can be up to 10 MB" });
}
if (typeof mimeType !== "string" || !/^[\w.+-]+\/[\w.+-]+$/.test(mimeType)) {
throw new HTTPException(400, { message: "Invalid file type" });
}
};

const findVisibleDocument = async (tripId: string, documentId: string, userId: string) => {
const doc = await TripDocument.findOne({ _id: documentId, tripId }).lean();
if (!doc || (doc.visibility === "private" && doc.uploadedBy !== userId)) {
throw new HTTPException(404, { message: "Document not found" });
}
return doc;
};

documents.get("/", async (c) => {
const session = await authenticateOptional(c);
const tripId = c.req.param("tripId");
if (!tripId) throw new HTTPException(400, { message: "Trip ID is required" });

await connect();
const [trip, roster] = await Promise.all([Trip.findById(tripId).lean(), loadActiveRoster(tripId)]);
if (!trip) throw new HTTPException(404, { message: "Trip not found" });

const isEditor = isEditorInRoster(roster, session?.userId);
if (!isEditor && !trip.isPublic) throw new HTTPException(403, { message: "Forbidden" });

const filter = isEditor
? { tripId, $or: [{ visibility: { $ne: "private" } }, { uploadedBy: session?.userId }] }
: { tripId, visibility: "public" };

const docs = await TripDocument.find(filter).sort({ createdAt: 1 }).lean();
return c.json(docs.map((doc) => ({ ...doc, url: imageUrl(doc.key) })));
});

documents.post("/upload-url", async (c) => {
const tripId = c.req.param("tripId");
await requireEditor(c);

const data = await c.req.json<TripDocumentUploadUrlInput>();
validateMeta(data.name, data.size, data.mimeType);

const dot = data.name.lastIndexOf(".");
const base = dot > 0 ? data.name.slice(0, dot) : data.name;
const ext = dot > 0 ? data.name.slice(dot + 1).toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 10) : "";
const safeBase = sanitizeFileName(base).replace(/ /g, "-").toLowerCase() || "file";
const key = `docs/${tripId}/${nanoId()}/${safeBase}${ext ? `.${ext}` : ""}`;

const uploadUrl = await createUploadUrl(key, data.mimeType);
if (!uploadUrl) throw new HTTPException(500, { message: "File storage is not configured" });

return c.json({ key, uploadUrl });
});

documents.post("/", async (c) => {
const tripId = c.req.param("tripId");
const userId = await requireEditor(c);

const data = await c.req.json<TripDocumentCreateInput>();
validateMeta(data.name, data.size, data.mimeType);
if (typeof data.key !== "string" || !data.key.startsWith(`docs/${tripId}/`)) {
throw new HTTPException(400, { message: "Invalid document key" });
}

const count = await TripDocument.countDocuments({ tripId });
if (count >= MAX_DOCUMENTS_PER_TRIP) {
throw new HTTPException(400, { message: `Trips can have up to ${MAX_DOCUMENTS_PER_TRIP} documents` });
}

const doc = await TripDocument.create({
tripId,
name: data.name.trim(),
key: data.key,
size: data.size,
mimeType: data.mimeType,
uploadedBy: userId,
});

return c.json({ ...doc.toObject(), url: imageUrl(doc.key) });
});

documents.patch("/:documentId", async (c) => {
const tripId = c.req.param("tripId");
const documentId = c.req.param("documentId");
const userId = await requireEditor(c);

const data = await c.req.json<TripDocumentUpdateInput>();
validateName(data.name);
const category = data.category || null;
if (category !== null && !CATEGORIES.includes(category)) {
throw new HTTPException(400, { message: "Invalid category" });
}
if (!VISIBILITIES.includes(data.visibility)) {
throw new HTTPException(400, { message: "Invalid visibility" });
}

await findVisibleDocument(tripId!, documentId, userId);
const doc = await TripDocument.findOneAndUpdate(
{ _id: documentId, tripId },
{ name: data.name.trim(), category, visibility: data.visibility },
{ new: true }
).lean();
if (!doc) throw new HTTPException(404, { message: "Document not found" });

return c.json({ ...doc, url: imageUrl(doc.key) });
});

documents.delete("/:documentId", async (c) => {
const tripId = c.req.param("tripId");
const documentId = c.req.param("documentId");
const userId = await requireEditor(c);

const doc = await findVisibleDocument(tripId!, documentId, userId);

await TripDocument.deleteOne({ _id: documentId });
try {
await deleteFromStorage(doc.key);
} catch (error) {
console.error("Failed to delete document from storage", doc.key, error);
}

return c.json({});
});

export default documents;
17 changes: 15 additions & 2 deletions backend/routes/trips/[tripId]/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,23 @@ import {
isDuplicateKeyError,
validateTripDates,
} from "lib/utils.js";
import { connect, Trip, Participant, User, IntegrationToken } from "lib/db.js";
import { connect, Trip, Participant, User, IntegrationToken, TripDocument } from "lib/db.js";
import {
isTripEditor,
isEditorInRoster,
loadActiveRoster,
loadUsersById,
resolveTripLifelist,
} from "lib/participants.js";
import { uploadMapboxImageToStorage, imageUrl } from "lib/storage.js";
import { uploadMapboxImageToStorage, imageUrl, deleteFromStorage } from "lib/storage.js";
import { OPENBIRDING_API_URL, SHARE_CODE_TTL_MINUTES } from "lib/config.js";
import type { TripUpdateInput, TripDatesInput, OpenBirdingLocationResponse } from "@birdplan/shared";
import targetStars from "./targets.js";
import markers from "./markers.js";
import hotspots from "./hotspots.js";
import itinerary from "./itinerary.js";
import participants from "./participants.js";
import documents from "./documents.js";
// @ts-ignore - no type definitions available
import tokml from "@maphubs/tokml";

Expand All @@ -38,6 +39,7 @@ trip.route("/markers", markers);
trip.route("/hotspots", hotspots);
trip.route("/itinerary", itinerary);
trip.route("/participants", participants);
trip.route("/documents", documents);

trip.get("/", async (c) => {
const session = await authenticateOptional(c);
Expand Down Expand Up @@ -229,12 +231,23 @@ trip.delete("/", async (c) => {
throw new HTTPException(403, { message: "Forbidden" });
}

const documents = await TripDocument.find({ tripId }).select("key").lean();

await Promise.all([
Trip.deleteOne({ _id: tripId }),
Participant.deleteMany({ tripId }),
IntegrationToken.deleteMany({ tripId }),
TripDocument.deleteMany({ tripId }),
]);

await Promise.all(
documents.map((doc) =>
deleteFromStorage(doc.key).catch((error) =>
console.error("Failed to delete document from storage", doc.key, error)
)
)
);

return c.json({});
});

Expand Down
2 changes: 2 additions & 0 deletions frontend/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import ManageLifelist from "modals/ManageLifelist";
import GenerateMagicLink from "modals/GenerateMagicLink";
import Share from "modals/Share";
import TripNotes from "modals/TripNotes";
import EditDocument from "modals/EditDocument";

type ModalConfig = {
Component: React.ComponentType<any>;
Expand All @@ -45,6 +46,7 @@ const modals: Record<ModalId, ModalConfig> = {
generateMagicLink: { Component: GenerateMagicLink },
share: { Component: Share },
tripNotes: { Component: TripNotes },
editDocument: { Component: EditDocument },
};

const ModalRoot = () => {
Expand Down
Loading
Loading