diff --git a/docker-compose.yml b/docker-compose.yml index d044605f7..44a731b28 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -111,6 +111,8 @@ services: OTEL_SERVICE_NAME: file-service S3_BUCKET: otterworks-files DYNAMODB_TABLE: otterworks-file-metadata + DYNAMODB_FOLDER_SHARE_LINKS_TABLE: otterworks-folder-share-links + PUBLIC_WEB_URL: http://localhost:3000 SNS_TOPIC_ARN: arn:aws:sns:us-east-1:000000000000:otterworks-events SEARCH_SERVICE_URL: http://search-service:8087 # Failed uploads fire a Grafana-style alert at admin-service's ingest @@ -556,4 +558,3 @@ networks: volumes: postgres_data: null localstack_data: null - diff --git a/frontend/client-app/src/App.tsx b/frontend/client-app/src/App.tsx index fa09b965d..f7ab1e8ae 100644 --- a/frontend/client-app/src/App.tsx +++ b/frontend/client-app/src/App.tsx @@ -20,6 +20,7 @@ import StarredPage from "@/pages/starred"; import TrashPage from "@/pages/trash"; import TermsPage from "@/pages/terms"; import PrivacyPage from "@/pages/privacy"; +import SharedFolderPage from "@/pages/shared-folder"; import BillingPlansPage from "@/features/billing/plans-page"; import BillingEntitlementPage from "@/features/billing/entitlement-page"; import BillingChangePlanPage from "@/features/billing/change-plan-page"; @@ -62,6 +63,7 @@ export default function App() { } /> } /> } /> + } /> {BILLING_FIXTURE_ENABLED && ( <> } /> diff --git a/frontend/client-app/src/components/files/folder-share-dialog.tsx b/frontend/client-app/src/components/files/folder-share-dialog.tsx new file mode 100644 index 000000000..35b34c52a --- /dev/null +++ b/frontend/client-app/src/components/files/folder-share-dialog.tsx @@ -0,0 +1,174 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, Copy, Link2, X } from "lucide-react"; +import toast from "react-hot-toast"; +import { filesApi } from "@/lib/api"; +import { formatRelativeTime } from "@/lib/utils"; + +interface FolderShareDialogProps { + folderId: string; + folderName: string; + onClose: () => void; +} + +function formatExpiry(date: string): string { + const seconds = Math.round((new Date(date).getTime() - Date.now()) / 1000); + const absolute = Math.abs(seconds); + const unit = absolute >= 86_400 ? "day" : absolute >= 3_600 ? "hour" : "minute"; + const divisor = unit === "day" ? 86_400 : unit === "hour" ? 3_600 : 60; + const value = Math.round(seconds / divisor); + return new Intl.RelativeTimeFormat(undefined, { numeric: "auto" }).format(value, unit); +} + +export function FolderShareDialog({ + folderId, + folderName, + onClose, +}: FolderShareDialogProps) { + const queryClient = useQueryClient(); + const [expiresInHours, setExpiresInHours] = useState(24); + const [copiedId, setCopiedId] = useState(null); + + const linksQuery = useQuery({ + queryKey: ["folder-share-links", folderId], + queryFn: () => filesApi.listFolderShareLinks(folderId), + }); + + const createMutation = useMutation({ + mutationFn: () => filesApi.createFolderShareLink(folderId, expiresInHours), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["folder-share-links", folderId] }); + toast.success("Link created"); + }, + onError: () => toast.error("Failed to create link"), + }); + + const revokeMutation = useMutation({ + mutationFn: (linkId: string) => filesApi.revokeFolderShareLink(folderId, linkId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["folder-share-links", folderId] }); + toast.success("Link revoked"); + }, + onError: () => toast.error("Failed to revoke link"), + }); + + const copyLink = async (id: string, url: string) => { + try { + await navigator.clipboard.writeText(url); + setCopiedId(id); + toast.success("Link copied"); + window.setTimeout(() => setCopiedId(null), 2000); + } catch { + toast.error("Failed to copy link"); + } + }; + + return ( + <> +
+
+
event.stopPropagation()} + > +
+
+ +

+ Share “{folderName}” +

+
+ +
+ +
+
+ + +
+ +
+

Active links

+ {linksQuery.isLoading ? ( +

Loading links…

+ ) : linksQuery.isError ? ( +

Unable to load links

+ ) : linksQuery.data?.length ? ( +
+ {linksQuery.data.map((link) => ( +
+
+

+ {link.url} +

+

+ Expires {formatExpiry(link.expiresAt)} · Created{" "} + {formatRelativeTime(link.createdAt)} +

+
+ + +
+ ))} +
+ ) : ( +

+ No active links +

+ )} +
+
+
+
+ + ); +} diff --git a/frontend/client-app/src/lib/api.ts b/frontend/client-app/src/lib/api.ts index b769bc9a8..aafd53052 100644 --- a/frontend/client-app/src/lib/api.ts +++ b/frontend/client-app/src/lib/api.ts @@ -1,5 +1,5 @@ -import { isAxiosError } from "axios"; -import { apiClient } from "./api-client"; +import axios, { isAxiosError } from "axios"; +import { apiClient, API_BASE_URL } from "./api-client"; import type { User, AuthTokens, @@ -15,6 +15,8 @@ import type { UserSettings, PaginatedResponse, SharedUser, + FolderShareLink, + SharedFolderView, } from "@/types"; // Shape after the axios camelCase interceptor transforms the file-service response @@ -49,6 +51,63 @@ interface RawFileListResponse { pageSize: number; } +interface RawFolderShareLink { + id: string; + folderId: string; + ownerId: string; + token: string; + expiresAt: string; + createdAt: string; + revoked: boolean; + url: string; +} + +function mapFolderShareLink(raw: RawFolderShareLink): FolderShareLink { + return { + id: raw.id, + folderId: raw.folderId, + token: raw.token, + expiresAt: raw.expiresAt, + createdAt: raw.createdAt, + url: raw.url, + }; +} + +function mapSharedFolderView(raw: { + folder: Record; + files: Array>; + expires_at: string; +}): SharedFolderView { + const folder = raw.folder; + return { + folder: normalizeFileItem({ + id: folder.id, + name: folder.name, + parentId: folder.parent_id ?? null, + ownerId: folder.owner_id, + createdAt: folder.created_at, + updatedAt: folder.updated_at, + isFolder: true, + }), + files: (raw.files ?? []).map((file) => + mapRawFile({ + id: file.id as string, + name: file.name as string, + mimeType: file.mime_type as string, + sizeBytes: file.size_bytes as number, + s3Key: file.s3_key as string, + folderId: (file.folder_id ?? null) as string | null, + ownerId: file.owner_id as string, + version: file.version as number, + isTrashed: file.is_trashed as boolean, + createdAt: file.created_at as string, + updatedAt: file.updated_at as string, + }) + ), + expiresAt: raw.expires_at, + }; +} + // Normalize a single file from the file-service format to the frontend FileItem shape function mapRawFile(raw: RawFileItem): FileItem { return { @@ -251,6 +310,33 @@ export const filesApi = { deleteFolder: async (id: string): Promise => { await apiClient.delete(`/folders/${id}`); }, + createFolderShareLink: async ( + folderId: string, + expiresInHours: number + ): Promise => { + const { data } = await apiClient.post( + `/folders/${folderId}/share-links`, + { expires_in_hours: expiresInHours } + ); + return mapFolderShareLink(data); + }, + listFolderShareLinks: async (folderId: string): Promise => { + const { data } = await apiClient.get<{ links: RawFolderShareLink[] }>( + `/folders/${folderId}/share-links` + ); + return (data.links ?? []).map(mapFolderShareLink); + }, + revokeFolderShareLink: async (folderId: string, linkId: string): Promise => { + await apiClient.delete(`/folders/${folderId}/share-links/${linkId}`); + }, + getSharedFolder: async (token: string): Promise => { + const { data } = await axios.get<{ + folder: Record; + files: Array>; + expires_at: string; + }>(`${API_BASE_URL}/folders/shared/${encodeURIComponent(token)}`); + return mapSharedFolderView(data); + }, share: async (id: string, email: string, permission: "view" | "edit"): Promise => { // An email that doesn't resolve to an OtterWorks user is still sent to // file-service (as shared_with_email), which decides whether to reject it. diff --git a/frontend/client-app/src/pages/files.tsx b/frontend/client-app/src/pages/files.tsx index 4841dee45..149d047e0 100644 --- a/frontend/client-app/src/pages/files.tsx +++ b/frontend/client-app/src/pages/files.tsx @@ -23,6 +23,7 @@ import { FolderCard } from "@/components/files/folder-card"; import { FileUploadDropzone } from "@/components/files/file-upload-dropzone"; import type { FileUploadDropzoneHandle } from "@/components/files/file-upload-dropzone"; import { ShareDialog } from "@/components/files/share-dialog"; +import { FolderShareDialog } from "@/components/files/folder-share-dialog"; import { PageLoader } from "@/components/ui/loading-spinner"; import { FileGridSkeleton, FileListSkeleton } from "@/components/ui/skeleton"; import { EmptyState } from "@/components/ui/empty-state"; @@ -61,6 +62,7 @@ function FileBrowserContent() { const [showNewFolder, setShowNewFolder] = useState(false); const [newFolderName, setNewFolderName] = useState(""); const [shareFileId, setShareFileId] = useState(null); + const [shareFolderId, setShareFolderId] = useState(null); const [selectedIds, setSelectedIds] = useState>(new Set()); const [selectionActive, setSelectionActive] = useState(false); @@ -465,6 +467,7 @@ function FileBrowserContent() { folder={folder} view={viewMode} onDelete={(id) => deleteFolderMutation.mutate(id)} + onShare={(id) => setShareFolderId(id)} onRename={(id, name) => renameFolderMutation.mutate({ id, name })} selected={selectedIds.has(folder.id)} onSelect={toggleSelect} @@ -536,6 +539,17 @@ function FileBrowserContent() { /> ); })()} + {shareFolderId && (() => { + const shareFolder = folders.find((folder) => folder.id === shareFolderId); + if (!shareFolder) return null; + return ( + setShareFolderId(null)} + /> + ); + })()}
); } diff --git a/frontend/client-app/src/pages/shared-folder.tsx b/frontend/client-app/src/pages/shared-folder.tsx new file mode 100644 index 000000000..9e1efb80c --- /dev/null +++ b/frontend/client-app/src/pages/shared-folder.tsx @@ -0,0 +1,101 @@ +import { useQuery } from "@tanstack/react-query"; +import { isAxiosError } from "axios"; +import { Clock, FileText, Folder, Loader2, type LucideIcon } from "lucide-react"; +import { useParams } from "react-router-dom"; +import { Logo } from "@/components/ui/logo"; +import { filesApi } from "@/lib/api"; +import { formatFileSize, formatRelativeTime } from "@/lib/utils"; + +export default function SharedFolderPage() { + const { token = "" } = useParams<{ token: string }>(); + const sharedFolderQuery = useQuery({ + queryKey: ["shared-folder", token], + queryFn: () => filesApi.getSharedFolder(token), + enabled: Boolean(token), + }); + + const errorStatus = isAxiosError(sharedFolderQuery.error) + ? sharedFolderQuery.error.response?.status + : undefined; + + return ( +
+
+
+ + OtterWorks +
+
+ +
+ {sharedFolderQuery.isLoading ? ( +
+ +
+ ) : errorStatus === 410 ? ( + + ) : errorStatus === 404 ? ( + + ) : sharedFolderQuery.isError ? ( + + ) : sharedFolderQuery.data ? ( +
+
+
+
+ +
+
+

+ {sharedFolderQuery.data.folder.name} +

+

+ Shared folder · link expires{" "} + {new Date(sharedFolderQuery.data.expiresAt).toLocaleString()} +

+
+
+
+ +
+ {sharedFolderQuery.data.files.length ? ( +
+ {sharedFolderQuery.data.files.map((file) => ( +
+ +
+

{file.name}

+

+ {formatFileSize(file.size)} · Updated {formatRelativeTime(file.updatedAt)} +

+
+
+ ))} +
+ ) : ( +

+ This folder has no files. +

+ )} +
+
+ ) : null} +
+
+ ); +} + +function StatusMessage({ + icon: Icon = FileText, + title, +}: { + icon?: LucideIcon; + title: string; +}) { + return ( +
+ +

{title}

+
+ ); +} diff --git a/frontend/client-app/src/types/index.ts b/frontend/client-app/src/types/index.ts index 8e6c16bcb..d42cb9af7 100644 --- a/frontend/client-app/src/types/index.ts +++ b/frontend/client-app/src/types/index.ts @@ -50,6 +50,21 @@ export interface FileItem { versions: FileVersion[]; } +export interface FolderShareLink { + id: string; + folderId: string; + token: string; + expiresAt: string; + createdAt: string; + url: string; +} + +export interface SharedFolderView { + folder: FileItem; + files: FileItem[]; + expiresAt: string; +} + export interface FileVersion { id: string; fileId: string; diff --git a/infrastructure/terraform/main.tf b/infrastructure/terraform/main.tf index bf5837f23..116da7eb1 100644 --- a/infrastructure/terraform/main.tf +++ b/infrastructure/terraform/main.tf @@ -188,6 +188,8 @@ module "irsa" { "${module.database.file_versions_table_arn}/index/*", module.database.file_shares_table_arn, "${module.database.file_shares_table_arn}/index/*", + module.database.folder_share_links_table_arn, + "${module.database.folder_share_links_table_arn}/index/*", ] }, ] diff --git a/infrastructure/terraform/modules/database/main.tf b/infrastructure/terraform/modules/database/main.tf index 97089c404..1f165c723 100644 --- a/infrastructure/terraform/modules/database/main.tf +++ b/infrastructure/terraform/modules/database/main.tf @@ -302,3 +302,33 @@ resource "aws_dynamodb_table" "file_shares" { # nosemgrep: terraform.aws.securit Service = "file-service" }) } + +# --- DynamoDB: Folder Share Links (file-service) --- + +resource "aws_dynamodb_table" "folder_share_links" { # nosemgrep: terraform.aws.security.aws-dynamodb-table-unencrypted.aws-dynamodb-table-unencrypted + name = "${var.project}-folder-share-links-${var.environment}" + billing_mode = "PAY_PER_REQUEST" + hash_key = "token" + + server_side_encryption { + enabled = true + } + + attribute { + name = "token" + type = "S" + } + + ttl { + attribute_name = "ttl_epoch" + enabled = true + } + + point_in_time_recovery { + enabled = var.environment != "dev" + } + + tags = merge(local.common_tags, { + Service = "file-service" + }) +} diff --git a/infrastructure/terraform/modules/database/outputs.tf b/infrastructure/terraform/modules/database/outputs.tf index 93fd52048..6ffbaae27 100644 --- a/infrastructure/terraform/modules/database/outputs.tf +++ b/infrastructure/terraform/modules/database/outputs.tf @@ -67,3 +67,13 @@ output "file_shares_table_arn" { description = "DynamoDB table ARN for file shares" value = aws_dynamodb_table.file_shares.arn } + +output "folder_share_links_table_name" { + description = "DynamoDB table name for folder share links" + value = aws_dynamodb_table.folder_share_links.name +} + +output "folder_share_links_table_arn" { + description = "DynamoDB table ARN for folder share links" + value = aws_dynamodb_table.folder_share_links.arn +} diff --git a/infrastructure/terraform/outputs.tf b/infrastructure/terraform/outputs.tf index 6cd10e880..408159b23 100644 --- a/infrastructure/terraform/outputs.tf +++ b/infrastructure/terraform/outputs.tf @@ -52,6 +52,11 @@ output "dynamodb_file_shares_table" { value = module.database.file_shares_table_name } +output "dynamodb_folder_share_links_table" { + description = "DynamoDB table name for folder share links" + value = module.database.folder_share_links_table_name +} + # --- Messaging --- output "sqs_notification_queue_url" { diff --git a/scripts/localstack-init.sh b/scripts/localstack-init.sh index c5e84e5ad..b6206ccd2 100755 --- a/scripts/localstack-init.sh +++ b/scripts/localstack-init.sh @@ -103,4 +103,10 @@ table_exists otterworks-file-shares || awslocal dynamodb create-table \ --key-schema AttributeName=id,KeyType=HASH \ --billing-mode PAY_PER_REQUEST +table_exists otterworks-folder-share-links || awslocal dynamodb create-table \ + --table-name otterworks-folder-share-links \ + --attribute-definitions AttributeName=token,AttributeType=S \ + --key-schema AttributeName=token,KeyType=HASH \ + --billing-mode PAY_PER_REQUEST + echo "LocalStack initialization complete!" diff --git a/services/api-gateway/internal/middleware/jwt.go b/services/api-gateway/internal/middleware/jwt.go index 54513d12d..fd3b3f86a 100644 --- a/services/api-gateway/internal/middleware/jwt.go +++ b/services/api-gateway/internal/middleware/jwt.go @@ -43,6 +43,7 @@ func DefaultPrefixPaths() []string { "/health", "/metrics", "/socket.io", + "/api/v1/folders/shared", } } diff --git a/services/api-gateway/internal/middleware/jwt_test.go b/services/api-gateway/internal/middleware/jwt_test.go index 8c14eabe0..e69db41ba 100644 --- a/services/api-gateway/internal/middleware/jwt_test.go +++ b/services/api-gateway/internal/middleware/jwt_test.go @@ -79,6 +79,7 @@ func TestJWTAuth_SubPathsOfExactMatchRequireAuth(t *testing.T) { prefixSubPaths := []string{ "/health/ready", "/metrics/prometheus", + "/api/v1/folders/shared/abc", } for _, path := range prefixSubPaths { @@ -89,6 +90,13 @@ func TestJWTAuth_SubPathsOfExactMatchRequireAuth(t *testing.T) { assert.Equal(t, http.StatusOK, rec.Code, "prefix path %s should not require auth", path) }) } + + t.Run("folder routes remain protected", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/folders/abc", nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + assert.Equal(t, http.StatusUnauthorized, rec.Code) + }) } func TestJWTAuth_MissingToken(t *testing.T) { diff --git a/services/file-service/src/config.rs b/services/file-service/src/config.rs index bb10898fd..57a76c33f 100644 --- a/services/file-service/src/config.rs +++ b/services/file-service/src/config.rs @@ -14,6 +14,7 @@ pub struct AppConfig { pub struct ServerConfig { pub port: u16, pub max_upload_bytes: u64, + pub public_web_url: String, /// When true, every upload is routed to a nonexistent S3 bucket so the /// request fails with a 500. Off unless explicitly enabled per tenant. pub upload_always_fail: bool, @@ -31,6 +32,7 @@ pub struct AwsConfig { pub dynamodb_folders_table: String, pub dynamodb_versions_table: String, pub dynamodb_shares_table: String, + pub dynamodb_folder_share_links_table: String, } #[derive(Clone, Debug)] @@ -64,6 +66,8 @@ impl ServerConfig { .unwrap_or_else(|_| "104857600".into()) // 100 MB .parse() .unwrap_or(104_857_600), + public_web_url: env::var("PUBLIC_WEB_URL") + .unwrap_or_else(|_| "http://localhost:3000".into()), upload_always_fail: parse_bool_env("FILE_UPLOAD_ALWAYS_FAIL", false), seed_demo_docs: parse_bool_env("FILE_SEED_DEMO_DOCS", false), } @@ -98,6 +102,8 @@ impl AwsConfig { .unwrap_or_else(|_| "otterworks-file-versions".into()), dynamodb_shares_table: env::var("DYNAMODB_SHARES_TABLE") .unwrap_or_else(|_| "otterworks-file-shares".into()), + dynamodb_folder_share_links_table: env::var("DYNAMODB_FOLDER_SHARE_LINKS_TABLE") + .unwrap_or_else(|_| "otterworks-folder-share-links".into()), } } } diff --git a/services/file-service/src/errors.rs b/services/file-service/src/errors.rs index dae7aff92..05bf81189 100644 --- a/services/file-service/src/errors.rs +++ b/services/file-service/src/errors.rs @@ -15,6 +15,12 @@ pub enum ServiceError { #[error("Share not found: {0}")] ShareNotFound(String), + #[error("Share link not found: {0}")] + ShareLinkNotFound(String), + + #[error("Share link expired")] + ShareLinkExpired, + #[error("Bad request: {0}")] BadRequest(String), @@ -55,6 +61,13 @@ impl ResponseError for ServiceError { ServiceError::ShareNotFound(_) => { (actix_web::http::StatusCode::NOT_FOUND, "share_not_found") } + ServiceError::ShareLinkNotFound(_) => ( + actix_web::http::StatusCode::NOT_FOUND, + "share_link_not_found", + ), + ServiceError::ShareLinkExpired => { + (actix_web::http::StatusCode::GONE, "share_link_expired") + } ServiceError::BadRequest(_) => { (actix_web::http::StatusCode::BAD_REQUEST, "bad_request") } diff --git a/services/file-service/src/handlers.rs b/services/file-service/src/handlers.rs index 0d56662a6..a8f3ec1d3 100644 --- a/services/file-service/src/handlers.rs +++ b/services/file-service/src/handlers.rs @@ -1,7 +1,7 @@ use actix_multipart::Multipart; use actix_web::{web, HttpRequest, HttpResponse}; use bytes::BytesMut; -use chrono::Utc; +use chrono::{DateTime, Duration, Utc}; use futures_util::StreamExt; use uuid::Uuid; @@ -18,13 +18,36 @@ use crate::metadata::MetadataClient; use crate::middleware; use crate::models::{ ActivityItem, ActivityQuery, ActivityResponse, CreateFolderRequest, DownloadResponse, - FileDetailResponse, FileMetadata, FileShare, FileVersion, Folder, HealthResponse, - ListFilesQuery, ListFilesResponse, ListFoldersQuery, ListFoldersResponse, ListVersionsResponse, - MoveFileRequest, RenameFileRequest, ShareFileRequest, ShareFileResponse, UpdateFolderRequest, - UploadResponse, + FileDetailResponse, FileMetadata, FileShare, FileVersion, Folder, FolderShareLink, + HealthResponse, ListFilesQuery, ListFilesResponse, ListFoldersQuery, ListFoldersResponse, + ListVersionsResponse, MoveFileRequest, RenameFileRequest, ShareFileRequest, ShareFileResponse, + UpdateFolderRequest, UploadResponse, }; use crate::storage::S3Client; +#[derive(Debug, serde::Deserialize)] +pub struct CreateFolderShareLinkRequest { + pub expires_in_hours: u32, +} + +#[derive(Debug, serde::Serialize)] +pub struct FolderShareLinkResponse { + #[serde(flatten)] + pub link: FolderShareLink, + pub url: String, +} + +#[derive(Debug, serde::Serialize)] +pub struct SharedFolderResponse { + pub folder: Folder, + pub files: Vec, + pub expires_at: DateTime, +} + +pub fn is_expired(link: &FolderShareLink, now: DateTime) -> bool { + link.expires_at <= now +} + // -- Health & Metrics -- pub async fn health() -> HttpResponse { @@ -259,6 +282,14 @@ fn resolve_owner_id(req: &HttpRequest, query_owner_id: Option) -> Option Result { + req.headers() + .get("X-User-ID") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::().ok()) + .ok_or_else(|| ServiceError::BadRequest("missing owner context".into())) +} + pub async fn list_files( req: HttpRequest, meta: web::Data, @@ -757,6 +788,140 @@ pub async fn delete_folder( Ok(HttpResponse::NoContent().finish()) } +fn folder_share_link_response( + link: FolderShareLink, + public_web_url: &str, +) -> FolderShareLinkResponse { + FolderShareLinkResponse { + url: format!( + "{}/shared/folder/{}", + public_web_url.trim_end_matches('/'), + link.token + ), + link, + } +} + +pub async fn create_folder_share_link( + req: HttpRequest, + config: web::Data, + meta: web::Data, + path: web::Path, + body: web::Json, +) -> Result { + if !(1..=720).contains(&body.expires_in_hours) { + return Err(ServiceError::BadRequest( + "expires_in_hours must be between 1 and 720".into(), + )); + } + + let owner_id = required_owner_id(&req)?; + let folder_id: Uuid = path + .into_inner() + .parse() + .map_err(|e| ServiceError::BadRequest(format!("invalid folder id: {e}")))?; + let folder = meta.get_folder(&folder_id).await?; + if folder.owner_id != owner_id { + return Err(ServiceError::Forbidden( + "folder owner access required".into(), + )); + } + + let created_at = Utc::now(); + let link = FolderShareLink { + id: Uuid::new_v4(), + folder_id, + owner_id, + token: Uuid::new_v4().simple().to_string(), + expires_at: created_at + Duration::hours(body.expires_in_hours as i64), + created_at, + revoked: false, + }; + meta.put_folder_share_link(&link).await?; + tracing::info!(folder_id = %folder_id, link_id = %link.id, "Folder share link created"); + + Ok(HttpResponse::Created().json(folder_share_link_response( + link, + &config.server.public_web_url, + ))) +} + +pub async fn list_folder_share_links( + req: HttpRequest, + config: web::Data, + meta: web::Data, + path: web::Path, +) -> Result { + let owner_id = required_owner_id(&req)?; + let folder_id: Uuid = path + .into_inner() + .parse() + .map_err(|e| ServiceError::BadRequest(format!("invalid folder id: {e}")))?; + let folder = meta.get_folder(&folder_id).await?; + if folder.owner_id != owner_id { + return Err(ServiceError::Forbidden( + "folder owner access required".into(), + )); + } + + let links = meta.list_folder_share_links(&folder_id).await?; + let links = links + .into_iter() + .filter(|link| !link.revoked && !is_expired(link, Utc::now())) + .map(|link| folder_share_link_response(link, &config.server.public_web_url)) + .collect::>(); + Ok(HttpResponse::Ok().json(serde_json::json!({ "links": links }))) +} + +pub async fn revoke_folder_share_link( + req: HttpRequest, + meta: web::Data, + path: web::Path<(String, String)>, +) -> Result { + let owner_id = required_owner_id(&req)?; + let (folder_id_str, link_id_str) = path.into_inner(); + let folder_id: Uuid = folder_id_str + .parse() + .map_err(|e| ServiceError::BadRequest(format!("invalid folder id: {e}")))?; + let link_id: Uuid = link_id_str + .parse() + .map_err(|e| ServiceError::BadRequest(format!("invalid share link id: {e}")))?; + let folder = meta.get_folder(&folder_id).await?; + if folder.owner_id != owner_id { + return Err(ServiceError::Forbidden( + "folder owner access required".into(), + )); + } + + meta.revoke_folder_share_link(&folder_id, &link_id).await?; + tracing::info!(folder_id = %folder_id, link_id = %link_id, "Folder share link revoked"); + Ok(HttpResponse::NoContent().finish()) +} + +pub async fn get_shared_folder( + meta: web::Data, + path: web::Path, +) -> Result { + let token = path.into_inner(); + let link = meta.get_folder_share_link_by_token(&token).await?; + if link.revoked { + return Err(ServiceError::ShareLinkNotFound(token)); + } + if is_expired(&link, Utc::now()) { + return Err(ServiceError::ShareLinkExpired); + } + + let folder = meta.get_folder(&link.folder_id).await?; + let files = meta + .list_files(Some(link.folder_id), Some(folder.owner_id), false) + .await?; + Ok(HttpResponse::Ok().json(SharedFolderResponse { + folder, + files, + expires_at: link.expires_at, + })) +} + // -- Activity Handler -- pub async fn list_activity( @@ -839,4 +1004,21 @@ mod tests { let resp = metrics().await; assert_eq!(resp.status(), actix_web::http::StatusCode::OK); } + + #[test] + fn test_share_link_expiry_detection() { + let now = Utc::now(); + let link = FolderShareLink { + id: Uuid::new_v4(), + folder_id: Uuid::new_v4(), + owner_id: Uuid::new_v4(), + token: "token".into(), + expires_at: now, + created_at: now - Duration::hours(1), + revoked: false, + }; + + assert!(is_expired(&link, now)); + assert!(!is_expired(&link, now - Duration::seconds(1))); + } } diff --git a/services/file-service/src/main.rs b/services/file-service/src/main.rs index a02551f26..93bdbee9c 100644 --- a/services/file-service/src/main.rs +++ b/services/file-service/src/main.rs @@ -91,6 +91,22 @@ async fn main() -> std::io::Result<()> { ) .service( web::scope("/api/v1/folders") + .route( + "/shared/{token}", + web::get().to(handlers::get_shared_folder), + ) + .route( + "/{folder_id}/share-links", + web::post().to(handlers::create_folder_share_link), + ) + .route( + "/{folder_id}/share-links", + web::get().to(handlers::list_folder_share_links), + ) + .route( + "/{folder_id}/share-links/{link_id}", + web::delete().to(handlers::revoke_folder_share_link), + ) .route("", web::get().to(handlers::list_folders)) .route("", web::post().to(handlers::create_folder)) .route("/{folder_id}", web::get().to(handlers::get_folder)) diff --git a/services/file-service/src/metadata.rs b/services/file-service/src/metadata.rs index 25439c0de..7dcdf6ff0 100644 --- a/services/file-service/src/metadata.rs +++ b/services/file-service/src/metadata.rs @@ -4,7 +4,9 @@ use uuid::Uuid; use crate::config::AwsConfig; use crate::errors::ServiceError; -use crate::models::{FileMetadata, FileShare, FileVersion, Folder, SharePermission}; +use crate::models::{ + FileMetadata, FileShare, FileVersion, Folder, FolderShareLink, SharePermission, +}; /// Check if an AWS SDK error is a ConditionalCheckFailedException. fn is_conditional_check_failed( @@ -22,6 +24,7 @@ pub struct MetadataClient { pub folders_table: String, pub versions_table: String, pub shares_table: String, + pub folder_share_links_table: String, } impl MetadataClient { @@ -42,6 +45,7 @@ impl MetadataClient { folders_table: config.dynamodb_folders_table.clone(), versions_table: config.dynamodb_versions_table.clone(), shares_table: config.dynamodb_shares_table.clone(), + folder_share_links_table: config.dynamodb_folder_share_links_table.clone(), } } @@ -442,6 +446,127 @@ impl MetadataClient { Ok(folders) } + // -- Folder Share Links -- + + pub async fn put_folder_share_link(&self, link: &FolderShareLink) -> Result<(), ServiceError> { + let mut item = std::collections::HashMap::new(); + item.insert("id".into(), AttributeValue::S(link.id.to_string())); + item.insert( + "folder_id".into(), + AttributeValue::S(link.folder_id.to_string()), + ); + item.insert( + "owner_id".into(), + AttributeValue::S(link.owner_id.to_string()), + ); + item.insert("token".into(), AttributeValue::S(link.token.clone())); + item.insert( + "expires_at".into(), + AttributeValue::S(link.expires_at.to_rfc3339()), + ); + item.insert( + "created_at".into(), + AttributeValue::S(link.created_at.to_rfc3339()), + ); + item.insert("revoked".into(), AttributeValue::Bool(link.revoked)); + item.insert( + "ttl_epoch".into(), + AttributeValue::N(link.expires_at.timestamp().to_string()), + ); + + self.client + .put_item() + .table_name(&self.folder_share_links_table) + .set_item(Some(item)) + .send() + .await + .map_err(|e| ServiceError::DynamoError(e.to_string()))?; + + Ok(()) + } + + pub async fn get_folder_share_link_by_token( + &self, + token: &str, + ) -> Result { + let result = self + .client + .get_item() + .table_name(&self.folder_share_links_table) + .key("token", AttributeValue::S(token.to_string())) + .send() + .await + .map_err(|e| ServiceError::DynamoError(e.to_string()))?; + + let item = result + .item() + .ok_or_else(|| ServiceError::ShareLinkNotFound(token.to_string()))?; + + parse_folder_share_link(item) + } + + pub async fn list_folder_share_links( + &self, + folder_id: &Uuid, + ) -> Result, ServiceError> { + let mut paginator = self + .client + .scan() + .table_name(&self.folder_share_links_table) + .filter_expression("folder_id = :folder_id") + .expression_attribute_values(":folder_id", AttributeValue::S(folder_id.to_string())) + .into_paginator() + .send(); + + let mut links = Vec::new(); + while let Some(page) = paginator.next().await { + let page = page.map_err(|e| ServiceError::DynamoError(e.to_string()))?; + for item in page.items() { + links.push(parse_folder_share_link(item)?); + } + } + links.sort_by_key(|link| std::cmp::Reverse(link.created_at)); + Ok(links) + } + + pub async fn revoke_folder_share_link( + &self, + folder_id: &Uuid, + link_id: &Uuid, + ) -> Result<(), ServiceError> { + let mut paginator = self + .client + .scan() + .table_name(&self.folder_share_links_table) + .filter_expression("folder_id = :folder_id AND id = :id") + .expression_attribute_values(":folder_id", AttributeValue::S(folder_id.to_string())) + .expression_attribute_values(":id", AttributeValue::S(link_id.to_string())) + .into_paginator() + .send(); + + let mut token = None; + while let Some(page) = paginator.next().await { + let page = page.map_err(|e| ServiceError::DynamoError(e.to_string()))?; + if let Some(item) = page.items().first() { + token = Some(get_s(item, "token")?); + break; + } + } + + let token = token.ok_or_else(|| ServiceError::ShareLinkNotFound(link_id.to_string()))?; + self.client + .update_item() + .table_name(&self.folder_share_links_table) + .key("token", AttributeValue::S(token)) + .update_expression("SET revoked = :revoked") + .expression_attribute_values(":revoked", AttributeValue::Bool(true)) + .send() + .await + .map_err(|e| ServiceError::DynamoError(e.to_string()))?; + + Ok(()) + } + // -- File Versions -- pub async fn put_version(&self, version: &FileVersion) -> Result<(), ServiceError> { @@ -736,6 +861,20 @@ fn parse_folder( }) } +fn parse_folder_share_link( + item: &std::collections::HashMap, +) -> Result { + Ok(FolderShareLink { + id: parse_uuid(&get_s(item, "id")?)?, + folder_id: parse_uuid(&get_s(item, "folder_id")?)?, + owner_id: parse_uuid(&get_s(item, "owner_id")?)?, + token: get_s(item, "token")?, + expires_at: parse_datetime(&get_s(item, "expires_at")?)?, + created_at: parse_datetime(&get_s(item, "created_at")?)?, + revoked: get_bool(item, "revoked")?, + }) +} + fn parse_file_version( item: &std::collections::HashMap, ) -> Result { diff --git a/services/file-service/src/models.rs b/services/file-service/src/models.rs index dae3d258e..e6d939756 100644 --- a/services/file-service/src/models.rs +++ b/services/file-service/src/models.rs @@ -38,6 +38,17 @@ pub struct Folder { pub updated_at: DateTime, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FolderShareLink { + pub id: Uuid, + pub folder_id: Uuid, + pub owner_id: Uuid, + pub token: String, + pub expires_at: DateTime, + pub created_at: DateTime, + pub revoked: bool, +} + // ── File Version ─────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)]