diff --git a/.env.example b/.env.example index 0d44f4b..0e74691 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,12 @@ GOOGLE_CLIENT_SECRET= MICROSOFT_CLIENT_ID= MICROSOFT_CLIENT_SECRET= +# Box OAuth credentials (optional - for Box storage integration) +# Create a Box app at https://app.box.com/developers/console +# Set OAuth 2.0 Redirect URI: http://localhost:1284/api/auth/callback/box +BOX_CLIENT_ID= +BOX_CLIENT_SECRET= + # To generate a secret, just run `openssl rand -base64 32` BETTER_AUTH_SECRET= BETTER_AUTH_URL=http://localhost:1284 diff --git a/apps/server/package.json b/apps/server/package.json index 4bdbb27..d6f72dd 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -36,6 +36,7 @@ "@nimbus/db": "workspace:*", "@nimbus/env": "workspace:*", "@nimbus/shared": "workspace:*", + "box-node-sdk": "^3.8.2", "drizzle-orm": "^0.44.2", "google-auth-library": "^10.1.0", "hono": "^4.8.5", diff --git a/apps/server/src/providers/box/box-provider.ts b/apps/server/src/providers/box/box-provider.ts new file mode 100644 index 0000000..2c934fc --- /dev/null +++ b/apps/server/src/providers/box/box-provider.ts @@ -0,0 +1,412 @@ +import { + DEFAULT_MIME_TYPE, + DEFAULT_PAGE_SIZE, + type DownloadFileSchema, + type DriveInfo, + type File, + type FileMetadata, +} from "@nimbus/shared"; +import type { DownloadResult, ListFilesOptions, ListFilesResult } from "../interface/types"; +import type { Provider } from "../interface/provider"; +import { Readable } from "node:stream"; +import BoxSDK from "box-node-sdk"; + +interface BoxItem { + id: string; + name?: string; + type: "file" | "folder"; + size?: string; + parent?: { + id: string; + }; + created_at?: string; + modified_at?: string; + content_created_at?: string; + content_modified_at?: string; + extension?: string; + shared_link?: { + url?: string; + }; +} + +interface BoxClient { + files: { + get(id: string, options?: any): Promise; + uploadFile(parentId: string, name: string, content: Readable, options?: any): Promise<{ entries: BoxItem[] }>; + update(id: string, updates: any): Promise; + delete(id: string, permanent?: boolean): Promise; + copy(sourceId: string, targetParentId: string, options?: any): Promise; + getReadStream(id: string): Promise; + }; + folders: { + get(id: string, options?: any): Promise; + create(parentId: string, name: string, options?: any): Promise; + getItems(parentId: string, options?: any): Promise<{ entries: BoxItem[]; total_count: number }>; + update(id: string, updates: any): Promise; + delete(id: string, permanent?: boolean): Promise; + copy(sourceId: string, targetParentId: string, options?: any): Promise; + }; + users: { + get(id: string, options?: any): Promise<{ space_amount?: number; space_used?: number }>; + }; + search: { + query(query: string, options?: any): Promise<{ entries: BoxItem[]; total_count: number }>; + }; +} + +export class BoxProvider implements Provider { + private client: BoxClient; + private accessToken: string; + + constructor(accessToken: string) { + this.accessToken = accessToken; + + const sdk = new BoxSDK({ + clientID: process.env.BOX_CLIENT_ID || "dummy", + clientSecret: process.env.BOX_CLIENT_SECRET || "dummy", + }); + + this.client = sdk.getBasicClient(accessToken) as BoxClient; + } + + async create(metadata: FileMetadata, content?: Buffer | Readable): Promise { + try { + const parentId = metadata.parentId || "0"; // Root folder in Box is "0" + const isFolder = + metadata.mimeType === "application/vnd.google-apps.folder" || + metadata.mimeType === "application/vnd.microsoft.folder" || + metadata.mimeType === "application/x-directory"; + + if (isFolder) { + const folderData = await this.client.folders.create(parentId, metadata.name, { + description: metadata.description, + }); + + return this.mapToFile(folderData); + } + + if (!content) { + throw new Error("Content is required for file creation"); + } + + let stream: Readable; + if (Buffer.isBuffer(content)) { + stream = Readable.from(content); + } else { + stream = content; + } + + const uploadedFile = await this.client.files.uploadFile(parentId, metadata.name, stream, { + content_type: metadata.mimeType || DEFAULT_MIME_TYPE, + description: metadata.description, + }); + + const firstEntry = uploadedFile.entries[0]; + if (!firstEntry) { + throw new Error("No file entry returned from Box upload"); + } + return this.mapToFile(firstEntry); + } catch (error) { + console.error("Error creating Box item:", error); + throw error; + } + } + + async getById(id: string, _fields?: string[]): Promise { + try { + // Try to get as file first + try { + const fileData = await this.client.files.get(id, { + fields: "id,name,size,created_at,modified_at,content_created_at,content_modified_at,parent,type,extension", + }); + return this.mapToFile(fileData); + } catch { + // If file fetch fails, try as folder + const folderData = await this.client.folders.get(id, { + fields: + "id,name,size,created_at,modified_at,content_created_at,content_modified_at,parent,type,item_collection", + }); + return this.mapToFile(folderData); + } + } catch (error) { + const err = error as Error & { statusCode?: number }; + if (err.statusCode === 404) { + return null; + } + console.error("Error getting Box item:", error); + throw error; + } + } + + async update(id: string, metadata: Partial): Promise { + try { + const existingFile = await this.getById(id); + if (!existingFile) { + return null; + } + + const updates: Record = {}; + if (metadata.name) updates.name = metadata.name; + if (metadata.description) updates.description = metadata.description; + + if (existingFile.type === "folder") { + const updatedFolder = await this.client.folders.update(id, updates); + return this.mapToFile(updatedFolder); + } else { + const updatedFile = await this.client.files.update(id, updates); + return this.mapToFile(updatedFile); + } + } catch (error) { + console.error("Error updating Box item:", error); + throw error; + } + } + + async delete(id: string, permanent = true): Promise { + try { + const existingFile = await this.getById(id); + if (!existingFile) { + throw new Error(`File with id ${id} not found`); + } + + if (existingFile.type === "folder") { + await this.client.folders.delete(id, permanent); + } else { + await this.client.files.delete(id, permanent); + } + + return true; + } catch (error) { + console.error("Error deleting Box item:", error); + throw error; + } + } + + async listChildren(parentId = "0", options: ListFilesOptions = {}): Promise { + try { + const normalizedParentId = !parentId || parentId === "root" || parentId === "/" ? "0" : parentId; + const limit = options.pageSize || DEFAULT_PAGE_SIZE; + const offset = options.pageToken ? parseInt(options.pageToken, 10) : 0; + + const folderItems = await this.client.folders.getItems(normalizedParentId, { + fields: "id,name,size,created_at,modified_at,content_created_at,content_modified_at,parent,type,extension", + limit, + offset, + }); + + const items: File[] = folderItems.entries.map((item: BoxItem) => this.mapToFile(item)); + + const nextPageToken = folderItems.total_count > offset + limit ? String(offset + limit) : undefined; + + return { + items, + nextPageToken, + }; + } catch (error) { + console.error("Error listing Box items:", error); + throw error; + } + } + + async download(fileId: string, _options?: DownloadFileSchema): Promise { + try { + const fileInfo = await this.client.files.get(fileId, { fields: "name,size" }); + const stream = await this.client.files.getReadStream(fileId); + + const chunks: Buffer[] = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + const data = Buffer.concat(chunks); + + return { + data, + filename: fileInfo.name || "download", + mimeType: this.getMimeTypeFromExtension(fileInfo.name || ""), + size: parseInt(fileInfo.size || "0", 10) || data.length, + }; + } catch (error) { + console.error("Error downloading Box file:", error); + return null; + } + } + + async downloadStream(fileId: string): Promise { + try { + const stream = await this.client.files.getReadStream(fileId); + return stream; + } catch (error) { + console.error("Error streaming Box file:", error); + return null; + } + } + + async copy(sourceId: string, targetParentId: string, newName?: string): Promise { + try { + const sourceFile = await this.getById(sourceId); + if (!sourceFile) { + return null; + } + + const fileName = newName || `Copy of ${sourceFile.name}`; + + if (sourceFile.type === "folder") { + const copiedFolder = await this.client.folders.copy(sourceId, targetParentId, { name: fileName }); + return this.mapToFile(copiedFolder); + } else { + const copiedFile = await this.client.files.copy(sourceId, targetParentId, { name: fileName }); + return this.mapToFile(copiedFile); + } + } catch (error) { + console.error("Error copying Box item:", error); + throw error; + } + } + + async move(sourceId: string, targetParentId: string, newName?: string): Promise { + try { + const sourceFile = await this.getById(sourceId); + if (!sourceFile) { + return null; + } + + const updates: Record = { parent: { id: targetParentId } }; + if (newName) updates.name = newName; + + if (sourceFile.type === "folder") { + const movedFolder = await this.client.folders.update(sourceId, updates); + return this.mapToFile(movedFolder); + } else { + const movedFile = await this.client.files.update(sourceId, updates); + return this.mapToFile(movedFile); + } + } catch (error) { + console.error("Error moving Box item:", error); + throw error; + } + } + + async getDriveInfo(): Promise { + try { + const userInfo = await this.client.users.get("me", { fields: "space_amount,space_used" }); + + return { + totalSpace: userInfo.space_amount || 0, + usedSpace: userInfo.space_used || 0, + trashSize: 0, // Box doesn't provide trash size in user info + trashItems: 0, + fileCount: 0, // Would need to iterate through all files to count + }; + } catch (error) { + console.error("Error getting Box user info:", error); + return null; + } + } + + async getShareableLink(id: string, permission: "view" | "edit" = "view"): Promise { + try { + const accessLevel = permission === "edit" ? "edit" : "view"; + const sharedLink = await this.client.files.update(id, { + shared_link: { + access: "open", + permissions: { + can_download: true, + can_preview: true, + can_edit: accessLevel === "edit", + }, + }, + }); + + return sharedLink.shared_link?.url || null; + } catch (error) { + console.error("Error creating Box shared link:", error); + return null; + } + } + + async search(query: string, options: Omit = {}): Promise { + try { + const limit = options.pageSize || DEFAULT_PAGE_SIZE; + const offset = options.pageToken ? parseInt(options.pageToken, 10) : 0; + + const searchResults = await this.client.search.query(query, { + type: "file,folder", + limit, + offset, + fields: "id,name,size,created_at,modified_at,content_created_at,content_modified_at,parent,type,extension", + }); + + const items: File[] = searchResults.entries.map((item: BoxItem) => this.mapToFile(item)); + const nextPageToken = searchResults.total_count > offset + limit ? String(offset + limit) : undefined; + + return { + items, + nextPageToken, + }; + } catch (error) { + console.error("Error searching Box items:", error); + throw error; + } + } + + getAccessToken(): string { + return this.accessToken; + } + + setAccessToken(token: string): void { + this.accessToken = token; + const sdk = new BoxSDK({ + clientID: "dummy", + clientSecret: "dummy", + }); + this.client = sdk.getBasicClient(token) as BoxClient; + } + + private mapToFile(boxItem: BoxItem): File { + const isFolder = boxItem.type === "folder"; + const parent = boxItem.parent; + + return { + id: boxItem.id, + name: boxItem.name || "Untitled", + mimeType: isFolder ? "application/x-directory" : this.getMimeTypeFromExtension(boxItem.name || ""), + size: isFolder ? 0 : parseInt(boxItem.size || "0", 10) || 0, + parentId: parent?.id || "0", + createdTime: boxItem.created_at || boxItem.content_created_at || new Date().toISOString(), + modifiedTime: boxItem.modified_at || boxItem.content_modified_at || new Date().toISOString(), + type: isFolder ? ("folder" as const) : ("file" as const), + }; + } + + private getMimeTypeFromExtension(filename: string): string { + const extension = filename.split(".").pop()?.toLowerCase(); + const mimeTypes: Record = { + txt: "text/plain", + pdf: "application/pdf", + jpg: "image/jpeg", + jpeg: "image/jpeg", + png: "image/png", + gif: "image/gif", + mp4: "video/mp4", + mp3: "audio/mpeg", + zip: "application/zip", + doc: "application/msword", + docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + xls: "application/vnd.ms-excel", + xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ppt: "application/vnd.ms-powerpoint", + pptx: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + js: "application/javascript", + json: "application/json", + html: "text/html", + css: "text/css", + webm: "video/webm", + avi: "video/x-msvideo", + wav: "audio/wav", + tar: "application/x-tar", + gz: "application/gzip", + rar: "application/vnd.rar", + }; + return mimeTypes[extension || ""] || DEFAULT_MIME_TYPE; + } +} diff --git a/apps/server/src/providers/box/index.ts b/apps/server/src/providers/box/index.ts new file mode 100644 index 0000000..54c5f07 --- /dev/null +++ b/apps/server/src/providers/box/index.ts @@ -0,0 +1 @@ +export { BoxProvider } from "./box-provider"; diff --git a/apps/server/src/routes/index.ts b/apps/server/src/routes/index.ts index 3eff63a..49245dc 100644 --- a/apps/server/src/routes/index.ts +++ b/apps/server/src/routes/index.ts @@ -4,6 +4,7 @@ import { OneDriveProvider } from "../providers/microsoft/one-drive"; import type { Provider } from "../providers/interface/provider"; import { sendForbidden, sendUnauthorized } from "./utils"; import { driveProviderSchema } from "@nimbus/shared"; +import { BoxProvider } from "../providers/box"; import { decrypt } from "../utils/encryption"; import { S3Provider } from "../providers/s3"; import waitlistRoutes from "./waitlist"; @@ -80,10 +81,15 @@ const driveProviderRouter = createDriveProviderRouter() return sendUnauthorized(c, "Access token not available. Please re-authenticate."); } - provider = - parsedProviderName.data === "google" - ? new GoogleDriveProvider(accessToken) - : new OneDriveProvider(accessToken); + if (parsedProviderName.data === "google") { + provider = new GoogleDriveProvider(accessToken); + } else if (parsedProviderName.data === "microsoft") { + provider = new OneDriveProvider(accessToken); + } else if (parsedProviderName.data === "box") { + provider = new BoxProvider(accessToken); + } else { + return sendForbidden(c, "Unsupported provider"); + } c.set("provider", provider); } catch (error) { // @ts-ignore diff --git a/apps/web/src/components/auth/shared/social-auth-button.tsx b/apps/web/src/components/auth/shared/social-auth-button.tsx index f43e88a..959ac23 100644 --- a/apps/web/src/components/auth/shared/social-auth-button.tsx +++ b/apps/web/src/components/auth/shared/social-auth-button.tsx @@ -2,6 +2,7 @@ import type { SocialAuthButtonProps } from "@/lib/types"; import { Google, Microsoft } from "@/components/icons"; +import BoxIcon from "@/components/icons/box-icon"; import { Button } from "@/components/ui/button"; const providerConfig = { @@ -13,6 +14,10 @@ const providerConfig = { icon: Microsoft, name: "Microsoft", }, + box: { + icon: BoxIcon, + name: "Box", + }, } as const; export function SocialAuthButton({ diff --git a/apps/web/src/components/auth/signin-account-dialog.tsx b/apps/web/src/components/auth/signin-account-dialog.tsx index d8d7eea..242f688 100644 --- a/apps/web/src/components/auth/signin-account-dialog.tsx +++ b/apps/web/src/components/auth/signin-account-dialog.tsx @@ -2,8 +2,8 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { SocialAuthButton } from "@/components/auth/shared/social-auth-button"; +import { useGoogleAuth, useMicrosoftAuth, useBoxAuth } from "@/hooks/useAuth"; import { S3AccountForm } from "@/components/settings/s3-account-form"; -import { useGoogleAuth, useMicrosoftAuth } from "@/hooks/useAuth"; import { useIsMounted } from "@/hooks/useIsMounted"; import type { DriveProvider } from "@nimbus/shared"; import { Button } from "@/components/ui/button"; @@ -27,9 +27,11 @@ export function SigninAccountDialog({ open, onOpenChange }: SigninAccountDialogP google: false, microsoft: false, s3: false, + box: false, }); const { signInWithGoogleProvider } = useGoogleAuth(); const { signInWithMicrosoftProvider } = useMicrosoftAuth(); + const { signInWithBoxProvider } = useBoxAuth(); useEffect(() => { if (isMounted) { @@ -53,6 +55,8 @@ export function SigninAccountDialog({ open, onOpenChange }: SigninAccountDialogP await signInWithGoogleProvider({ callbackURL }); } else if (provider === "microsoft") { await signInWithMicrosoftProvider({ callbackURL }); + } else if (provider === "box") { + await signInWithBoxProvider({ callbackURL }); } onOpenChange(false); @@ -111,6 +115,15 @@ export function SigninAccountDialog({ open, onOpenChange }: SigninAccountDialogP {isLoading.microsoft && } + handleSocialAuth("box")} + disabled={isLoading.box} + > + {isLoading.box && } + +