diff --git a/backend/src/internal/cloud/objectstorage/container/worker_router.go b/backend/src/internal/container/worker_router.go similarity index 100% rename from backend/src/internal/cloud/objectstorage/container/worker_router.go rename to backend/src/internal/container/worker_router.go diff --git a/backend/src/internal/cloud/objectstorage/container/worker_router_test.go b/backend/src/internal/container/worker_router_test.go similarity index 100% rename from backend/src/internal/cloud/objectstorage/container/worker_router_test.go rename to backend/src/internal/container/worker_router_test.go diff --git a/backend/src/internal/middleware/auth_middleware.go b/backend/src/internal/middleware/auth_middleware.go index cbeebc73..b5fed873 100644 --- a/backend/src/internal/middleware/auth_middleware.go +++ b/backend/src/internal/middleware/auth_middleware.go @@ -2,6 +2,7 @@ package middleware import ( "backend/src/internal/auth" + "context" "net/http" "strings" ) @@ -13,7 +14,7 @@ func AuthMiddleware(a auth.Authenticator) func(http.Handler) http.Handler { panic("not implemented") } -func Protect(a *auth.Authenticator, next http.Handler) http.Handler { +func Protect(ctx context.Context, a *auth.Authenticator, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := r.Header.Get("Authorization") if !strings.HasPrefix(h, "Bearer ") { @@ -22,13 +23,13 @@ func Protect(a *auth.Authenticator, next http.Handler) http.Handler { } token := strings.TrimPrefix(h, "Bearer ") - ok, err := a.ValidateJWT(token) + newCtx, ok, err := a.ValidateJWT(ctx, token) if err != nil || !ok { http.Error(w, "unauthorized, invalid token", http.StatusUnauthorized) return } - next.ServeHTTP(w, r) + next.ServeHTTP(w, r.WithContext(newCtx)) }) } diff --git a/backend/src/usecase/files/service/service.go b/backend/src/usecase/files/service/service.go index b6b64a3d..fbffec85 100644 --- a/backend/src/usecase/files/service/service.go +++ b/backend/src/usecase/files/service/service.go @@ -1,11 +1,13 @@ package files import ( + "backend/src/internal/auth" data "backend/src/usecase/files/data" repository "backend/src/usecase/files/repository" "context" "crypto/sha256" "encoding/json" + "errors" "io" "log" "net/http" @@ -43,10 +45,10 @@ type multipartMetadata struct { CheckSum []byte `json:"checkSum"` } -func (svc *Service) Upload(r *http.Request, ctx context.Context) error { +func (svc *Service) Upload(r *http.Request, ctx context.Context) ([]data.MetaData, error) { mr, err := r.MultipartReader() if err != nil { - return err + return nil, err } metadataByID := make(map[string]data.MetaData) @@ -57,7 +59,7 @@ func (svc *Service) Upload(r *http.Request, ctx context.Context) error { break } if err != nil { - return err + return nil, err } name := part.FormName() @@ -70,12 +72,16 @@ func (svc *Service) Upload(r *http.Request, ctx context.Context) error { // decode + build metadata var decodedRequest multipartMetadata if err := json.NewDecoder(part).Decode(&decodedRequest); err != nil { - return err + return nil, err } - ownerID, err := uuid.Parse(decodedRequest.OwnerID) + userId, ok := auth.UserIDFromCtx(r.Context()) + if !ok { + return nil, errors.New("could not get userID from context") + } + ownerID, err := uuid.Parse(userId) if err != nil { - return err + return nil, err } metadataByID[idStr] = data.MetaData{ @@ -102,7 +108,7 @@ func (svc *Service) Upload(r *http.Request, ctx context.Context) error { io.TeeReader(part, hash), part.FileName(), ); err != nil { - return err + return nil, err } md := metadataByID[idStr] md.CheckSum = hash.Sum(nil) @@ -110,13 +116,16 @@ func (svc *Service) Upload(r *http.Request, ctx context.Context) error { } } // Persist file metadata + var newMetadata []data.MetaData for _, md := range metadataByID { - if err := svc.repo.SaveMetaData(md, ctx); err != nil { - return err + newMd, err := svc.repo.SaveMetaData(md, ctx) + if err != nil { + return nil, err } + newMetadata = append(newMetadata, newMd) } - return nil + return newMetadata, nil } func (svc *Service) GetAll(ctx context.Context, request data.GetAllMetadataRequest) ([]data.MetaDataResponse, error) { @@ -159,8 +168,17 @@ func (svc *Service) FindMetadata(ctx context.Context, request data.FindMetadataR } func (svc *Service) Delete(ctx context.Context, request data.DeleteRequest) error { + userID, ok := auth.UserIDFromCtx(ctx) + if !ok { + return errors.New("unable to get userID from context") + } - err := svc.repo.DeleteMetadata(ctx, request.ID, request.OwnerID) + ownerID, err := uuid.Parse(userID) + if err != nil { + log.Printf("unable to parse userID string to uuid") + } + + err = svc.repo.DeleteMetadata(ctx, request.ID, ownerID) if err != nil { log.Printf("could not delete file metadata, %v", err) return err @@ -170,7 +188,17 @@ func (svc *Service) Delete(ctx context.Context, request data.DeleteRequest) erro } func (svc *Service) MoveToRubbish(ctx context.Context, request data.DeleteRequest) error { - err := svc.repo.MarkForDeletion(ctx, request.ID, request.OwnerID) + userID, ok := auth.UserIDFromCtx(ctx) + if !ok { + return errors.New("unable to get userID from context") + } + + ownerID, err := uuid.Parse(userID) + if err != nil { + log.Printf("unable to parse userID string to uuid") + } + + err = svc.repo.MarkForDeletion(ctx, request.ID, ownerID) if err != nil { log.Printf("unable to move file or metadata to rubbish bin, %v", err) return err diff --git a/backend/src/core/files/service/service_test.go b/backend/src/usecase/files/service/service_test.go similarity index 100% rename from backend/src/core/files/service/service_test.go rename to backend/src/usecase/files/service/service_test.go diff --git a/frontend/src/app/features/files/components/dialogs/file-dialog.tsx b/frontend/src/app/features/files/components/dialogs/file-dialog.tsx deleted file mode 100644 index 18ff45f5..00000000 --- a/frontend/src/app/features/files/components/dialogs/file-dialog.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import {Dialog, DialogContent, DialogTitle, DialogTrigger} from "@/components/ui/dialog.tsx"; -import type {Metadata} from "@/app/features/files/hooks/types.ts"; -import {useState} from "react"; -import {Button} from "@/components/ui/button.tsx"; -import {EllipsisVertical} from "lucide-react"; - -interface FileDialogProps{ - open: boolean, - onOpenChange: (open: boolean) => void, - metadata: Metadata, - ipfsLink: string, - spaceName: string, - spaceDid: string -} - -export function FileDialog({ - open, - onOpenChange, - metadata, - }: FileDialogProps) { - return ( - - - -

Last modified at: {metadata.modified_at}

-

Last modified at: {metadata.modified_at}

-
-
- ) -} \ No newline at end of file diff --git a/frontend/src/app/features/files/components/dropdowns/file-dropdown.tsx b/frontend/src/app/features/files/components/dropdowns/file-dropdown.tsx deleted file mode 100644 index 18429690..00000000 --- a/frontend/src/app/features/files/components/dropdowns/file-dropdown.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import {EllipsisVertical, Info, Moon, Settings, Sun, Trash2} from "lucide-react" - -import { Button } from "@/components/ui/button" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, DropdownMenuPortal, DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" -import { useTheme } from "@/components/theme-provider" - -export function FileDropdown() { - const { setTheme } = useTheme() - - return ( - - - - - - deleteFile(id)}> - {}{

Delete

} -
- - - {}{

File Info

} -
- - - {}{

File Settings

} -
-
-
- ) -} \ No newline at end of file diff --git a/frontend/src/app/features/files/components/file-dialog.tsx b/frontend/src/app/features/files/components/file-dialog.tsx new file mode 100644 index 00000000..1211d13f --- /dev/null +++ b/frontend/src/app/features/files/components/file-dialog.tsx @@ -0,0 +1,69 @@ +import {Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger} from "@/components/ui/dialog.tsx"; +import type {Metadata} from "@/app/features/files/types.ts"; +import {useState} from "react"; +import {Button} from "@/components/ui/button.tsx"; +import {EllipsisVertical} from "lucide-react"; +import {DialogDescription} from "@radix-ui/react-dialog"; + +interface FileDialogProps{ + open: boolean, + onOpenChange: (open: boolean) => void, + metadata: Metadata, + ipfsLink: string, + spaceName: string, + spaceDid: string +} + +export function FileDialog({ + open, + onOpenChange, + metadata, + ipfsLink, + spaceName, + spaceDid, + }: FileDialogProps) { + return ( + + + + {metadata.file_name} + + File info and backup uris + + + +

File size: {metadata.size / 1024} KB

+

Last modified at: {formatDate(metadata.modified_at)}

+

+ Uploaded at:{" "} + {metadata.uploaded_at + ? formatDate(metadata.uploaded_at) + : "11/02/2026 11:38:42"} +

+

{metadata.uuid}

+

{metadata.owner_id}

+ +

Backup

+

Space: test-space

+

Visibility: PUBLIC

+

DID: bafybeia7wkemsgryogneimjafwwkb33ifwh2oo3djba3lqfeg3lkrqn464

+

Shards: bagbaierahrldusuunn3mt3xcgfue3aav6zcynk7ynpwzhgyi4l6muyp4hjhq

+

Recovery

+ +
+
+ ); +} + +//TODO: remove this duplcate function at some point +function formatDate(date: string): string { + const d = new Date(date); + + if (isNaN(d.getTime())) { + return "11/02/2026 11:38AM"; + } + + return d.toLocaleString(); +} diff --git a/frontend/src/app/features/files/components/file-dropdown.tsx b/frontend/src/app/features/files/components/file-dropdown.tsx new file mode 100644 index 00000000..a4c7372d --- /dev/null +++ b/frontend/src/app/features/files/components/file-dropdown.tsx @@ -0,0 +1,81 @@ +import { EllipsisVertical, Info, Settings, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useAuthStore } from "@/security/auth/authstore/auth-store"; +import { RestHandler } from "@/app/features/shared/api/rest/rest-handler"; + +interface FileDropdownProps { + fileId: string; + onDeleted: () => void; +} + +export function FileDropdown({ fileId, onDeleted }: FileDropdownProps) { + const userId = useAuthStore((s) => s.userId); + + async function handleDelete(e: React.MouseEvent) { + e.stopPropagation(); + + if (!userId) { + console.error("No user ID found"); + return; + } + + try { + const api = new RestHandler("http://localhost:8081"); + + await api.handlePost< + { id: string; }, + void + >("api/files/delete", { + id: fileId, + }); + + onDeleted(); + + } catch (error) { + console.error("Failed to delete file on server:", error); + } + } + + return ( + + + + + + + + + Delete + + + + + + + File Info + + + + + File Settings + + + + ); +} diff --git a/frontend/src/app/features/shared/components/tables/file-table.tsx b/frontend/src/app/features/files/components/file-table.tsx similarity index 54% rename from frontend/src/app/features/shared/components/tables/file-table.tsx rename to frontend/src/app/features/files/components/file-table.tsx index a561a9cf..0a0c1a0e 100644 --- a/frontend/src/app/features/shared/components/tables/file-table.tsx +++ b/frontend/src/app/features/files/components/file-table.tsx @@ -6,71 +6,75 @@ import { TableHeader, TableRow, } from "@/components/ui/table" -import { useEffect, useMemo, useState, useOptimistic } from "react" +import { useEffect, useMemo, useState } from "react" import { Checkbox } from "@/components/ui/checkbox" import { UploadDialog } from "@/app/features/shared/components/dialog/upload-dialog" import { Button } from "@/components/ui/button" -import { Clock, FolderPlus, Star, EllipsisVertical } from "lucide-react" +import { Clock, FolderPlus, Star } from "lucide-react" import { type CursorReq, getAllMetadata, - type GetAllMetadataReq, -} from "@/app/features/files/hooks/handler" -import type { Metadata } from "@/app/features/files/hooks/types" +} from "@/app/features/files/handlers.ts" +import type { Metadata } from "@/app/features/files/types.ts" import { useAuthStore } from "@/security/auth/authstore/auth-store" import { getIconForFile } from "@react-symbols/icons/utils" -import {DialogTrigger} from "@/components/ui/dialog.tsx"; -import {FileDialog} from "@/app/features/files/components/dialogs/file-dialog.tsx"; -import {FileDropdown} from "@/app/features/files/components/dropdowns/file-dropdown.tsx"; - - +import { FileDialog } from "@/app/features/files/components/file-dialog.tsx"; +import { FileDropdown } from "@/app/features/files/components/file-dropdown.tsx"; +/** + * Main file table component in files page. + * */ export function FileTable() { const [dialogOpen, setDialogOpen] = useState(false) const [activeFile, setActiveFile] = useState(null) - - function openDialog(file: Metadata) { - setActiveFile(file) - setDialogOpen(true) - } - const userId = useAuthStore((s) => s.userId) - - const cursor = useMemo( - () => ({ modified_at: null, id: null }), - [] - ) - - const req = useMemo( - () => ({ - user_id: userId, - cursor, - limit: 20, - }), - [userId, cursor] - ) - const [files, setFiles] = useState([]) - const [optimisticFiles, addOptimisticFiles] = useOptimistic< - Metadata[], - Metadata[] - >(files, (state, action) => [...action, ...state]) - const [selected, setSelected] = useState([]) + const { request } = usePagination(userId, 20); + + /** + * Main refresh file function. + * */ + async function refreshFiles() { + if (!userId || !request.user_id) return; + const resp = await getAllMetadata(request); + setFiles(resp.metadata); + } useEffect(() => { - if (!userId) return - getAllMetadata(req).then((resp) => setFiles(resp.metadata)) - }, [req, userId]) + refreshFiles(); + }, [request, userId]); + /** + * Handles opening FileDialog component on file. + * */ + function openDialog(file: Metadata) { + setActiveFile(file) + setDialogOpen(true) + } + + /** + * Handles checkbox toggle in file table. + * */ const toggleSelect = (id: string) => { setSelected((prev) => prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id] ) } + /** + * Handles the selection of all file checkboxes within the file table. + * */ const selectAll = (checked: boolean) => { - setSelected(checked ? optimisticFiles.map((f) => f.uuid) : []) + setSelected(checked ? files.map((f) => f.uuid) : []) + } + + /** + * Upon file deletion, refreshes files from backend. + * This allows for instant UI update post event. + * */ + async function handleFileDeleted() { + await refreshFiles(); } return ( @@ -78,27 +82,16 @@ export function FileTable() {

All files

@@ -106,23 +99,20 @@ export function FileTable() { 0 && - selected.length === optimisticFiles.length - } + checked={files.length > 0 && selected.length === files.length} onCheckedChange={(v) => selectAll(v === true)} /> - Name - Last Modified + Name + Last Modified - {optimisticFiles.map((file) => ( - + {files.map((file) => ( + openDialog(file)}> -

- {file.file_name} -

+

{file.file_name}

openDialog(file)}> -

- {formatDate(file.modified_at)} -

+

{formatDate(file.modified_at)}

- - + +
))}
+ {activeFile && ( (null); + const request = useMemo(() => ({ user_id: userId, cursor, limit }), [userId, cursor, limit]); + return { request }; +} + +/** + * Helper function to format date to human-readable format, backend + * returns a timestamp. + * @param date - date as a string. + * @return string - date as string. + * */ function formatDate(date: string): string { return new Date(date).toLocaleString() } diff --git a/frontend/src/app/features/home/hooks/handler.ts b/frontend/src/app/features/files/handlers.ts similarity index 85% rename from frontend/src/app/features/home/hooks/handler.ts rename to frontend/src/app/features/files/handlers.ts index 707bfdc7..84fdbd3c 100644 --- a/frontend/src/app/features/home/hooks/handler.ts +++ b/frontend/src/app/features/files/handlers.ts @@ -26,4 +26,10 @@ export async function getAllMetadata(request: GetAllMetadataReq): Promise void; + onUploaded?: () => void; }; -const api = new RestHandler(`http://localhost:8081`); - - -export function UploadDialog({onUploaded}: UploadDialogProps) { - const [alert, setAlertVisible] = useState(false); +export function UploadDialog({ onUploaded }: UploadDialogProps) { const [open, setDialogOpen] = useState(false); const [files, setFiles] = useState(null); + const [isUploading, setIsUploading] = useState(false); const handleDrop = (newFiles: File[]) => { - setFiles(prev => { + setFiles((prev) => { const existing = prev ?? []; const merged = [...existing, ...newFiles]; return Array.from( - new Map(merged.map(f => [`${f.name}-${f.size}-${f.lastModified}`, f])).values() + new Map( + merged.map((f) => [ + `${f.name}-${f.size}-${f.lastModified}`, + f, + ]) + ).values() ); }); }; async function handleDialogUpload(): Promise { - if (!files || files.length === 0) { - alert("Can't upload an empty file."); - return; - } + if (!files || files.length === 0) return; try { + setIsUploading(true); + const upload = new UploadForm(files); await upload.prepare(); + await upload.send(); - console.log("Sending upload form data...") - const optimistic: Metadata[] = files.map((f) => ({ - uuid: crypto.randomUUID(), // temp ID - file_name: f.name, - path: "", - size: f.size, - file_type: f.type, - modified_at: new Date().toISOString(), - created_at: new Date().toISOString(), - owner_id: "", // or userId if available - access_to: [], - group_id: [], - checksum: new Uint8Array(), - version: new Date().toISOString(), - })); - const response = await upload.send(); - - onUploaded?.(optimistic); - - - const bytes = new Uint8Array(100); - crypto.getRandomValues(bytes); - - const testMetadata: Metadata[] = [ - { - id: crypto.randomUUID(), - ownerId: "test-user", - checkSum: "deadbeef", - - path: "blegowe.bin", - relativePath: "blwgo.bin", - lastModified: Date.now(), - lastModifiedDate: new Date().toISOString(), - size: 1024, - fileType: "application/octet-stream", - } - ] - - await uploadObject([bytes], testMetadata, ObjectType.FILE); + onUploaded?.(); setDialogOpen(false); setFiles(null); } catch (err) { - console.log(err); + console.error("Upload failed:", err); + } finally { + setIsUploading(false); } } return ( - { - setDialogOpen(isOpen) - if (!isOpen) setFiles(null) - }}> + { + setDialogOpen(isOpen); + if (!isOpen) setFiles(null); + }} + > Upload File - Upload a file or folder. + + Upload a file or folder. + - - + + -
-
); } - diff --git a/frontend/src/app/features/shared/components/layout/layout.tsx b/frontend/src/app/features/shared/components/layout/layout.tsx index 682280f2..8150b46e 100644 --- a/frontend/src/app/features/shared/components/layout/layout.tsx +++ b/frontend/src/app/features/shared/components/layout/layout.tsx @@ -2,6 +2,7 @@ import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar" import { AppSidebar } from "@/app/features/shared/components/navigation/sidebars/app-sidebar" import { Outlet } from "react-router-dom" import { AppTopbar } from "../navigation/topbars/app-topbar" +import {SecondarySidebar} from "@/app/features/shared/components/navigation/sidebars/secondary-sidebar.tsx"; interface LayoutProps { children?: React.ReactNode; @@ -10,18 +11,20 @@ interface LayoutProps { export default function Layout({ children }: LayoutProps) { return ( -
+
-
+ +
-
+
{children}
+
) -} \ No newline at end of file +} diff --git a/frontend/src/app/features/shared/files/upload.ts b/frontend/src/app/features/shared/files/upload.ts index b3ff8680..c356c97b 100644 --- a/frontend/src/app/features/shared/files/upload.ts +++ b/frontend/src/app/features/shared/files/upload.ts @@ -45,33 +45,32 @@ export class UploadForm { }) } - public async send(): Promise { + public async send(): Promise { this.buildFormData(); - const url = "http://localhost:8081/api/files/upload" - - + const url = "http://localhost:8081/api/files/upload"; const token = useAuthStore.getState().token; - console.log(token); - //const url = `${this.baseURL}/${endpoint}`; - const options: RequestInit = { + const response = await fetch(url, { + method: "POST", headers: { Authorization: `Bearer ${token}`, }, - method: "POST", - body: this.formData}; + body: this.formData, + }); - const response = await fetch(url, options); const contentType = response.headers.get("content-type"); - if (contentType && contentType.includes("application/json")) { + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `Upload failed (${response.status})`); + } + + if (contentType?.includes("application/json")) { return await response.json(); } - // Might be better off returning null here, need to rethink. - await this.handleFailedUpload(response); - return await response.json(); + return null; } private async handleFailedUpload(response: Response): Promise {