diff --git a/apps/web/hooks/useBucket.tsx b/apps/web/hooks/useBucket.tsx index 3ff244f..195020b 100644 --- a/apps/web/hooks/useBucket.tsx +++ b/apps/web/hooks/useBucket.tsx @@ -2,6 +2,7 @@ import { DriveFile, DriveFolder, Provider, Tag, UploadingFile } from "@util/type import useFirebase from "./useFirebase"; import useKeys from "./useKeys"; import useS3 from "./useS3"; +import useSamba from "./useSamba"; import useS3Shared from "./sharedBuckets/useS3Shared"; export type ContextValue = { @@ -44,6 +45,8 @@ export default function useBucket(): ContextValue { case Provider.cloudflare: case Provider.scaleway: return keys.permissions === "owned" ? useS3() : useS3Shared(); + case Provider.samba: + return useSamba(); default: return null; } diff --git a/apps/web/hooks/useSamba.tsx b/apps/web/hooks/useSamba.tsx new file mode 100644 index 0000000..b99f1f4 --- /dev/null +++ b/apps/web/hooks/useSamba.tsx @@ -0,0 +1,152 @@ +import { createContext, PropsWithChildren, useContext, useEffect, useRef, useState } from "react"; +import SambaClient from "samba-client"; +import { ContextValue, ROOT_FOLDER } from "./useBucket"; +import { Drive } from "@prisma/client"; +import { DriveFile, DriveFolder, UploadingFile } from "@util/types"; +import toast from "react-hot-toast"; +import useUser from "./useUser"; +import mime from "mime-types"; + +const SambaContext = createContext(null); +export default () => useContext(SambaContext); + +type Props = { + data: Drive & { keys: any }; + fullPath?: string; +}; +export const SambaProvider: React.FC> = ({ data, fullPath, children }) => { + const sambaClient = new SambaClient({ + address: data.keys.address, + username: "" || data.keys.username, + password: "" || data.keys.password, + domain: "WORKGROUP" || data.keys.domain, + maxProtocol: "SMB3", + }) + const { user } = useUser(); + const [loading, setLoading] = useState(false); + const [currentFolder, setCurrentFolder] = useState(null); + const [folders, setFolders] = useState(null); + const [uploadingFiles, setUploadingFiles] = useState([]); + const [files, setFiles] = useState(null); + const isMounted = useRef(false); + const enableTags = false; + + const addFolder = async (name: string) => { + const path = + currentFolder.fullPath !== "" + ? decodeURIComponent(currentFolder.fullPath) + name + "/" + : name + "/"; + const newFolder: DriveFolder = { + name, + fullPath: path, + parent: currentFolder.fullPath + } + setFolders((folders) => [...folders, newFolder]) + await sambaClient.mkdir(newFolder.name, path); + const localFolders = localStorage.getItem(`local_folders_${data.id}`); + const folders: DriveFolder[] = localFolders ? JSON.parse(localFolders) : []; + localStorage.setItem(`local_folders_${data.id}`, JSON.stringify([...folders, newFolder])); + }; + + const removeFolder = async (folder: DriveFolder) => { + setFolders((folders) => folders.filter((f) => f.fullPath !== folder.fullPath)); + const localFolders = localStorage.getItem(`local_folders_${data.id}`); + if (localFolders) { + const folders = JSON.parse(localFolders); + const filtered = folders.filter((f) => !f.fullPath.includes(folder.fullPath)); + localStorage.setItem(`local_folders_${data.id}`, JSON.stringify(filtered)); + } + await sambaClient.deleteFile(folder.fullPath) + } + + const addFile = async (filesToUpload: File[] | FileList) => { + Array.from(filesToUpload).forEach(async (file) => { + if (/[#\$\[\]\*/]/.test(file.name)) + return toast.error("File name cannot contain special characters (#$[]*/)."); + + const Key = + currentFolder === ROOT_FOLDER + ? file.name + : `${decodeURIComponent(currentFolder.fullPath)}${file.name}`; + if (await sambaClient.fileExists(file.name, Key)) { + return toast.error("File with same name already exists."); + } + await sambaClient.sendFile(file.name, Key) + }) + } + + const removeFile = async (file: DriveFile) => { + setFiles((files) => files.filter((f) => f.fullPath !== file.fullPath)); + await sambaClient.deleteFile(file.fullPath); + return true; + } + + useEffect(() => { + if (!user?.email) return; + setFiles(null); + setFolders(null); + + if (fullPath === "" || !fullPath) { + setCurrentFolder(ROOT_FOLDER); + return; + } + + setCurrentFolder({ + fullPath: fullPath + "/", + name: fullPath.split("/").pop(), + parent: fullPath.split("/").shift() + "/", + }); + }, [fullPath, user]); + + // get files and folders + useEffect(() => { + if (!user?.email || !currentFolder) return; + setLoading(true); + + (async () => { + try { + if (!files) { + var results = await sambaClient.list(currentFolder.fullPath) + results.forEach(async (result) => { + const driveFile: DriveFile = { + fullPath: currentFolder.fullPath + "/" + result.name, + name: result.name.split("/").pop(), + parent: currentFolder.fullPath, + createdAt: result.modifyTime.toISOString(), + size: result.size.toString(), + contentType: mime.lookup(result.type) || "", + }; + + setFiles((files) => (files ? [...files, driveFile] : [driveFile])); + }); + + const localFolders = localStorage.getItem(`local_folders_${data.id}`); + setLoading(false); + } + } + catch (err) { + console.error(err); + } + }) + }, [currentFolder, user]); + + return ( + + {children} + + ); +}; diff --git a/apps/web/package.json b/apps/web/package.json index bc8e3bf..7f9a0f0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -50,6 +50,7 @@ "react-loading-overlay": "^1.0.1", "resend": "^0.17.1", "sendgrid": "^5.2.3", + "samba-client": "^7.2.0", "swr": "^1.1.2-beta.0", "tabler-icons-react": "^1.45.0", "underscore": "^1.13.2", diff --git a/apps/web/pages/drives/[id].tsx b/apps/web/pages/drives/[id].tsx index d674f26..f594202 100644 --- a/apps/web/pages/drives/[id].tsx +++ b/apps/web/pages/drives/[id].tsx @@ -2,6 +2,7 @@ import Dashboard from "@components/Dashboard"; import { FirebaseProvider } from "@hooks/useFirebase"; import { KeysProvider } from "@hooks/useKeys"; import { S3Provider } from "@hooks/useS3"; +import { SambaProvider } from "@hooks/useSamba"; import { S3SharedProvider } from "@hooks/sharedBuckets/useS3Shared"; import useUser from "@hooks/useUser"; import { Drive, Role } from "@prisma/client"; @@ -58,6 +59,10 @@ const DrivePage: React.FC = ({ data, role }) => { ) + ) : data.type === "samba" ? ( + + + ) : (

No provider found.

)} diff --git a/apps/web/pages/new/samba.tsx b/apps/web/pages/new/samba.tsx new file mode 100644 index 0000000..8849f23 --- /dev/null +++ b/apps/web/pages/new/samba.tsx @@ -0,0 +1,219 @@ +import { Bucket, ListBucketsCommandOutput } from "@aws-sdk/client-s3"; +import { + Box, + Button, + Container, + Divider, + Flex, + Heading, + IconButton, + Input, + Select, + Text, +} from "@chakra-ui/react"; +import VideoModal from "@components/ui/VideoModal"; +import useUser from "@hooks/useUser"; +import axios from "axios"; +import Head from "next/head"; +import { useRouter } from "next/router"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { ArrowNarrowLeft } from "tabler-icons-react"; +import "video-react/dist/video-react.css"; +import validator from "validator"; + +const NewSamba = () => { + const { user } = useUser(); + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [keyId, setKeyId] = useState(""); + const [applicationKey, setApplicationKey] = useState(""); + const [endpoint, setEndpoint] = useState(""); + const [bucketName, setBucketName] = useState(""); + const [buckets, setBuckets] = useState([]); + const [selectedBucket, setSelectedBucket] = useState("Not Selected"); + + const listBuckets = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + + try { + if (!user?.email) throw new Error("You need to login to perform this action!"); + + if (!keyId.trim() || !applicationKey.trim() || !endpoint.trim()) + throw new Error("One or more fields are missing!"); + + if ( + !validator.isURL(endpoint, { require_protocol: true, protocols: ["https"] }) || + !/^(https:\/\/s3\.).+(\.backblazeb2.com)$/g.test(endpoint) + ) + throw new Error("Endpoint URL does not match the required format!"); + + const { data } = await axios.post("/api/s3/list-buckets", { + accessKey: keyId, + secretKey: applicationKey, + endpoint, + region: endpoint.split(".")[1], + }); + + setBuckets(data.Buckets); + } catch (err) { + console.error(err); + toast.error(err?.response?.data?.error || err.message); + } + + setLoading(false); + }; + + const createBucket = async () => { + setLoading(true); + + try { + if (!user?.email) throw new Error("You need to login to perform this action!"); + + if (!keyId.trim() || !applicationKey.trim()) + throw new Error("One or more fields are missing!"); + + if ( + !validator.isURL(endpoint, { require_protocol: true, protocols: ["https"] }) || + !/^(https:\/\/s3\.).+(\.backblazeb2.com)$/g.test(endpoint) + ) + throw new Error("Endpoint URL does not match the required format!"); + + if ((selectedBucket === "Not Selected" && !bucketName.trim()) || !endpoint.trim()) + throw new Error("Select an existing bucket or enter a new bucket name!"); + + if ( + (selectedBucket === "Not Selected" && bucketName.trim().length < 3) || + bucketName.trim().length > 63 + ) + throw new Error("Bucket name must be between 3 and 63 characters!"); + + const Bucket = selectedBucket !== "Not Selected" ? selectedBucket : bucketName.trim(); + + await axios.post("/api/drive", { + data: { + accessKey: keyId, + secretKey: applicationKey, + Bucket, + bucketUrl: `https://${Bucket}.s3.${endpoint.split(".")[1]}.backblazeb2.com`, + endpoint, + region: endpoint.split(".")[1], + }, + name: Bucket, + type: "backblaze", + }); + + toast.success("Drive created successfully!"); + router.push("/"); + } catch (err) { + console.error(err); + toast.error(err?.response?.data?.error || err.message); + } + + setLoading(false); + }; + + return ( + <> + + Backblaze | Firefiles + + + } + mr="3" + onClick={() => router.push("/new")} + /> + + Enter your Backblaze keys + + + + + setKeyId(e.target.value)} + required + /> + setApplicationKey(e.target.value)} + required + /> + setEndpoint(e.target.value)} + required + /> + + + + {buckets?.length > 0 ? ( + <> + + + + Found {buckets.length} buckets: + + Choose a bucket: + + + OR + + Create New: + { + // Bucket name must not contain spaces or uppercase letters + const text = e.target.value.replace(" ", "").toLowerCase(); + setBucketName(text); + }} + required + /> + + + + ) : null} + + + ); +}; + +export default NewSamba; diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index c4e5a29..c78b096 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -125,6 +125,9 @@ dependencies: resend: specifier: ^0.17.1 version: 0.17.1 + samba-client: + specifier: ^7.2.0 + version: 7.2.0 sendgrid: specifier: ^5.2.3 version: 5.2.3 @@ -4347,6 +4350,15 @@ packages: which: 1.3.1 dev: false + /cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + dev: false + /crypto-js@4.1.1: resolution: {integrity: sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==} dev: false @@ -4701,6 +4713,21 @@ packages: strip-eof: 1.0.0 dev: false + /execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + dependencies: + cross-spawn: 7.0.3 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + dev: false + /extend-shallow@2.0.1: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} @@ -4943,6 +4970,11 @@ packages: pump: 3.0.0 dev: false + /get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + dev: false + /git-config-path@1.0.1: resolution: {integrity: sha512-KcJ2dlrrP5DbBnYIZ2nlikALfRhKzNSX0stvv3ImJ+fvC4hXKoV+U+74SV0upg+jlQZbrtQzc0bu6/Zh+7aQbg==} engines: {node: '>=0.10.0'} @@ -5293,6 +5325,11 @@ packages: resolution: {integrity: sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q==} dev: false + /human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + dev: false + /iconv-lite@0.4.24: resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} engines: {node: '>=0.10.0'} @@ -5552,6 +5589,11 @@ packages: engines: {node: '>=0.10.0'} dev: false + /is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + dev: false + /is-typedarray@1.0.0: resolution: {integrity: sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==} dev: false @@ -6498,6 +6540,13 @@ packages: path-key: 2.0.1 dev: false + /npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + dependencies: + path-key: 3.1.1 + dev: false + /nprogress@0.2.0: resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==} dev: false @@ -6687,6 +6736,11 @@ packages: engines: {node: '>=4'} dev: false + /path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + dev: false + /path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} dev: false @@ -7359,6 +7413,13 @@ packages: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} dev: false + /samba-client@7.2.0: + resolution: {integrity: sha512-3wVq4UiHcJ3ZArsbDRsZ9W1P4fAgXU2CPjldsDuLDH3xzN7sd3MbtuNlTjc0Vkfq7+Bl4vcHqcpZEWRBMltHtw==} + engines: {node: '>=14.0.0'} + dependencies: + execa: 5.1.1 + dev: false + /scheduler@0.23.0: resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} dependencies: @@ -7459,11 +7520,23 @@ packages: shebang-regex: 1.0.0 dev: false + /shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + dependencies: + shebang-regex: 3.0.0 + dev: false + /shebang-regex@1.0.0: resolution: {integrity: sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==} engines: {node: '>=0.10.0'} dev: false + /shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + dev: false + /sigmund@1.0.1: resolution: {integrity: sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g==} dev: false @@ -7590,6 +7663,11 @@ packages: engines: {node: '>=0.10.0'} dev: false + /strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + dev: false + /strip-json-comments@2.0.1: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} diff --git a/apps/web/public/samba.png b/apps/web/public/samba.png new file mode 100644 index 0000000..572316a Binary files /dev/null and b/apps/web/public/samba.png differ diff --git a/apps/web/util/globals.ts b/apps/web/util/globals.ts index bc552c0..8170948 100644 --- a/apps/web/util/globals.ts +++ b/apps/web/util/globals.ts @@ -29,6 +29,11 @@ export const PROVIDERS = [ name: "Cloudflare R2", logo: "/cloudflare.png", }, + { + id: "samba", + name: "Samba", + logo: "/samba.png", + }, { id: "scaleway", name: "Scaleway", diff --git a/apps/web/util/helpers/s3-helpers.ts b/apps/web/util/helpers/s3-helpers.ts index 05c7221..4f67666 100644 --- a/apps/web/util/helpers/s3-helpers.ts +++ b/apps/web/util/helpers/s3-helpers.ts @@ -35,6 +35,7 @@ export const beforeCreatingDoc = async (req: NextApiRequest, res: NextApiRespons switch (type) { case "firebase": case "wasabi": + case "samba": return { success: true }; case "digitalocean": case "s3": diff --git a/apps/web/util/types.ts b/apps/web/util/types.ts index 35ebdd3..8362dba 100644 --- a/apps/web/util/types.ts +++ b/apps/web/util/types.ts @@ -26,6 +26,7 @@ export enum Provider { wasabi, digitalocean, cloudflare, + samba, scaleway, }