diff --git a/cmd/cli-uploader/Main.go b/cmd/cli-uploader/Main.go index 93448c9..c63ec86 100644 --- a/cmd/cli-uploader/Main.go +++ b/cmd/cli-uploader/Main.go @@ -1,6 +1,7 @@ package main import ( + "archive/zip" "encoding/json" "errors" "fmt" @@ -10,7 +11,10 @@ import ( "github.com/forceu/gokapi/cmd/cli-uploader/cliflags" "github.com/forceu/gokapi/internal/environment" "github.com/forceu/gokapi/internal/helper" + "github.com/schollz/progressbar/v3" + "io" "os" + "path/filepath" ) func main() { @@ -21,7 +25,9 @@ func main() { case cliflags.ModeLogout: doLogout() case cliflags.ModeUpload: - processUpload() + processUpload(false) + case cliflags.ModeArchive: + processUpload(true) case cliflags.ModeInvalid: os.Exit(3) } @@ -32,10 +38,21 @@ func doLogin() { cliconfig.CreateLogin() } -func processUpload() { +func processUpload(isArchive bool) { cliconfig.Load() - uploadParam := cliflags.GetUploadParameters() + uploadParam := cliflags.GetUploadParameters(isArchive) + if isArchive { + zipPath, err := zipFolder(uploadParam.Directory, uploadParam.TmpFolder, !uploadParam.JsonOutput) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + uploadParam.File = zipPath + defer deleteTempFolder(zipPath) // ensure cleanup + } + + // Perform the upload result, err := cliapi.UploadFile(uploadParam) if err != nil { fmt.Println() @@ -47,6 +64,8 @@ func processUpload() { } os.Exit(1) } + + // Output result if uploadParam.JsonOutput { jsonStr, _ := json.Marshal(result) fmt.Println(string(jsonStr)) @@ -84,3 +103,79 @@ func checkDockerFolders() { helper.CreateDir(cliconstants.DockerFolderUpload) } } + +func deleteTempFolder(path string) { + folder := filepath.Dir(path) + _ = os.RemoveAll(folder) +} + +// zipFolder compresses the contents of srcDir into a zip file at destZip +func zipFolder(srcDir, tmpFolder string, showOutput bool) (string, error) { + var progressBar *progressbar.ProgressBar + if showOutput { + progressBar = progressbar.NewOptions(-1, + progressbar.OptionSetDescription("Compressing files..."), + progressbar.OptionClearOnFinish(), + ) + defer progressBar.Finish() + } + folder, err := os.MkdirTemp(tmpFolder, "gokapi-cli-") + if err != nil { + return "", err + } + srcDir, err = filepath.Abs(srcDir) + if err != nil { + return "", err + } + srcDir = filepath.Clean(srcDir) + + zipPath := filepath.Join(folder, filepath.Base(srcDir)+".zip") + zipFile, err := os.Create(zipPath) + if err != nil { + return "", err + } + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + // Walk through every file and folder in the source directory + return zipPath, filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Compute the relative path (so zip doesn't store absolute paths) + relPath, err := filepath.Rel(srcDir, path) + if err != nil { + return err + } + + // Skip the root directory itself + if relPath == "." { + return nil + } + + // For folders, just add an entry with a trailing slash + if info.IsDir() { + _, err := zipWriter.Create(relPath + "/") + return err + } + + // For files, create a file entry + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + zipEntry, err := zipWriter.Create(relPath) + if err != nil { + return err + } + + progressBar.Describe("Compressing: " + filepath.Base(relPath)) + _, err = io.Copy(zipEntry, file) + return err + }) +} diff --git a/cmd/cli-uploader/cliapi/cliapi.go b/cmd/cli-uploader/cliapi/cliapi.go index 5ae5ed2..f12e56c 100644 --- a/cmd/cli-uploader/cliapi/cliapi.go +++ b/cmd/cli-uploader/cliapi/cliapi.go @@ -196,7 +196,7 @@ func UploadFile(uploadParams cliflags.UploadConfig) (models.FileApiOutput, error e2eFile := models.E2EFile{ Uuid: uuid, Id: metaData.Id, - Filename: getFileName(file), + Filename: getFileName(file, uploadParams), Cipher: cipher, } err = addE2EFileInfo(e2eFile) @@ -205,7 +205,7 @@ func UploadFile(uploadParams cliflags.UploadConfig) (models.FileApiOutput, error } hashContent, err := getHashContent(e2eFile) metaData.UrlDownload = metaData.UrlDownload + "#" + hashContent - metaData.Name = getFileName(file) + metaData.Name = getFileName(file, uploadParams) return metaData, err } @@ -215,18 +215,21 @@ func UploadFile(uploadParams cliflags.UploadConfig) (models.FileApiOutput, error return models.FileApiOutput{}, err } } - metaData, err := completeChunk(uuid, nameToBase64(file), sizeBytes, realSize, false, uploadParams, progressBar) + metaData, err := completeChunk(uuid, nameToBase64(file, uploadParams), sizeBytes, realSize, false, uploadParams, progressBar) if err != nil { return models.FileApiOutput{}, err } return metaData, nil } -func nameToBase64(f *os.File) string { - return "base64:" + base64.StdEncoding.EncodeToString([]byte(getFileName(f))) +func nameToBase64(f *os.File, uploadParams cliflags.UploadConfig) string { + return "base64:" + base64.StdEncoding.EncodeToString([]byte(getFileName(f, uploadParams))) } -func getFileName(f *os.File) string { +func getFileName(f *os.File, uploadParams cliflags.UploadConfig) string { + if uploadParams.FileName != "" { + return uploadParams.FileName + } return filepath.Base(f.Name()) } diff --git a/cmd/cli-uploader/cliflags/cliflags.go b/cmd/cli-uploader/cliflags/cliflags.go index db3d1b8..1bb9541 100644 --- a/cmd/cli-uploader/cliflags/cliflags.go +++ b/cmd/cli-uploader/cliflags/cliflags.go @@ -6,7 +6,9 @@ import ( "github.com/forceu/gokapi/internal/environment" "os" "path/filepath" + "regexp" "strconv" + "strings" ) const ( @@ -16,6 +18,8 @@ const ( ModeLogout // ModeUpload is the mode for the upload command ModeUpload + // ModeArchive is the mode for the archive command + ModeArchive // ModeInvalid is the mode for an invalid command ModeInvalid ) @@ -23,6 +27,9 @@ const ( // UploadConfig contains the parameters for the upload command. type UploadConfig struct { File string + Directory string + TmpFolder string + FileName string JsonOutput bool DisableE2e bool ExpiryDays int @@ -43,6 +50,8 @@ func Parse() int { return ModeLogout case "upload": return ModeUpload + case "upload-dir": + return ModeArchive case "help": printUsage(0) default: @@ -52,7 +61,7 @@ func Parse() int { } // GetUploadParameters parses the command line arguments and returns the parameters for the upload command. -func GetUploadParameters() UploadConfig { +func GetUploadParameters(isArchive bool) UploadConfig { result := UploadConfig{} for i := 2; i < len(os.Args); i++ { switch os.Args[i] { @@ -60,7 +69,7 @@ func GetUploadParameters() UploadConfig { fallthrough case "--json": result.JsonOutput = true - case "-n": + case "-x": fallthrough case "--disable-e2e": result.DisableE2e = true @@ -80,35 +89,87 @@ func GetUploadParameters() UploadConfig { fallthrough case "--password": result.Password = getParameter(&i) + case "-D": + fallthrough + case "--directory": + result.Directory = getParameter(&i) + case "-t": + fallthrough + case "--tempfolder": + result.TmpFolder = getParameter(&i) + case "-n": + fallthrough + case "--name": + result.FileName = getParameter(&i) case "-h": fallthrough case "--help": printUsage(0) } } - if result.File == "" { - if environment.IsDockerInstance() { - ok, dockerFile := getDockerUpload() - if !ok { - fmt.Println("ERROR: Missing parameter --file and no file or more than one file found in " + cliconstants.DockerFolderUpload) - os.Exit(2) - } - result.File = dockerFile - } else { - fmt.Println("ERROR: Missing parameter --file") - os.Exit(2) - } - } if result.ExpiryDownloads < 0 { result.ExpiryDownloads = 0 } if result.ExpiryDays < 0 { result.ExpiryDays = 0 } + sanitiseFilename(&result) + if !checkRequiredUploadParameter(&result, isArchive) { + os.Exit(2) + } + return result } -func getDockerUpload() (bool, string) { +func sanitiseFilename(config *UploadConfig) { + if config.FileName == "" { + return + } + config.FileName = filepath.Base(config.FileName) + config.FileName = strings.TrimSpace(config.FileName) + + // Replace illegal characters with underscore + // (Windows forbids <>:"/\|?* and control chars) + illegalChars := regexp.MustCompile(`[<>:"/\\|?*\x00-\x1F]`) + config.FileName = illegalChars.ReplaceAllString(config.FileName, "_") +} + +func checkRequiredUploadParameter(config *UploadConfig, isArchive bool) bool { + if isArchive && config.Directory != "" { + return true + } + if !isArchive && config.File != "" { + return true + } + + if !environment.IsDockerInstance() { + if isArchive { + fmt.Println("ERROR: Missing parameter --directory") + } else { + fmt.Println("ERROR: Missing parameter --file") + } + return false + } + + ok, uploadPath := getDockerUpload(isArchive) + if !ok { + if isArchive { + fmt.Println("ERROR: Missing parameter --file and no file found in " + cliconstants.DockerFolderUpload) + } else { + fmt.Println("ERROR: Missing parameter --file and no file or more than one file found in " + cliconstants.DockerFolderUpload) + } + return false + } + + if isArchive { + config.File = cliconstants.DockerFolderUpload + } else { + config.File = uploadPath + } + return true +} + +func getDockerUpload(isArchive bool) (bool, string) { if !environment.IsDockerInstance() { return false, "" } @@ -118,18 +179,21 @@ func getDockerUpload() (bool, string) { } var fileName string - var fileFound bool + var fileWasFound bool for _, entry := range entries { if entry.Type().IsRegular() { - if fileFound { + if isArchive { + return true, cliconstants.DockerFolderUpload + } + if fileWasFound { // More than one file exist return false, "" } fileName = entry.Name() - fileFound = true + fileWasFound = true } } - if !fileFound { + if !fileWasFound { return false, "" } return true, filepath.Join(cliconstants.DockerFolderUpload, fileName) @@ -177,19 +241,23 @@ func printUsage(exitCode int) { fmt.Println() fmt.Println("Commands:") - fmt.Println(" login Save login credentials") - fmt.Println(" upload Upload a file to Gokapi instance") - fmt.Println(" logout Delete login credentials") + fmt.Println(" login Save login credentials") + fmt.Println(" upload Upload a file to the Gokapi instance") + fmt.Println(" upload-dir Upload a folder as a zip file to the Gokapi instance") + fmt.Println(" logout Delete login credentials") fmt.Println() fmt.Println("Options:") - fmt.Println(" -f, --file File to upload") + fmt.Println(" -f, --file File to upload (required for \"upload\")") + fmt.Println(" -D, --directory Folder to upload (required for \"upload-dir\")") fmt.Println(" -c, --configuration Path to configuration file (default: gokapi-cli.json)") fmt.Println(" -j, --json Output the result in JSON only") - fmt.Println(" -n, --disable-e2e Disable end-to-end encryption") + fmt.Println(" -x, --disable-e2e Disable end-to-end encryption") fmt.Println(" -e, --expiry-days Set file expiry in days (default: unlimited)") fmt.Println(" -d, --expiry-downloads Set max allowed downloads (default: unlimited)") fmt.Println(" -p, --password Set a password for the file") + fmt.Println(" -n, --name Change final filename for uploaded file") + fmt.Println(" -t, --tmpfolder Folder for temporary Zip file when uploading a directory") fmt.Println(" -h, --help Show this help message") fmt.Println() @@ -197,6 +265,7 @@ func printUsage(exitCode int) { fmt.Println(" gokapi-cli login") fmt.Println(" gokapi-cli logout -c /path/to/config") fmt.Println(" gokapi-cli upload -f /file/to/upload --expiry-days 7 --json") + fmt.Println(" gokapi-cli upload-dir -D /path/to/upload -t /mnt/tmp") fmt.Println() os.Exit(exitCode) } diff --git a/docs/advanced.rst b/docs/advanced.rst index 3c990de..4f2a43d 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -234,24 +234,28 @@ To logout, either delete the configuration file or run ``gokapi-cli logout``. Docker --------------------------------- -If you are using Docker, your config will always be saved to ``/app/config/config.json`` and the location cannot be changed. To login, execute the following command: +If you are using Docker, your config will be saved to ``/app/config/config.json`` by default, but the location can be changed. To login, execute the following command: docker run -it --rm -v gokapi-cli-config:/app/config docker.io/f0rc3/gokapi-cli:latest login -The volume ``gokapi-cli-config:/app/config`` is not required if you re-use the container, but it is still highly recommended. If the volume is not mounted, you will need to log in again after every new container creation. +The volume ``gokapi-cli-config:/app/config`` is not required if you re-use the container, but it is still highly recommended. If a volume is not mounted, you will need to log in again after every new container creation. -Upload +.. _clitool-upload-file: + +Uploading a file ================================= To upload a file, simply run ``gokapi-cli upload -f /path/to/file``. By default the files are encrypted (if enabled) and stored without any expiration. These additional parameters are available: +---------------------------------+---------------------------------------------------+ +| Parameter | Effect | ++=================================+===================================================+ | --json, -j | Only outputs in JSON format, unless upload failed | +---------------------------------+---------------------------------------------------+ -| --disable-e2e, -n | Disables end-to-end encryption for this upload | +| --disable-e2e, -x | Disables end-to-end encryption for this upload | +---------------------------------+---------------------------------------------------+ | --expiry-days, -e [number] | Sets the expiry date of the file in days | +---------------------------------+---------------------------------------------------+ @@ -259,10 +263,12 @@ To upload a file, simply run ``gokapi-cli upload -f /path/to/file``. By default +---------------------------------+---------------------------------------------------+ | --password, -p [string] | Sets a password | +---------------------------------+---------------------------------------------------+ +| --name, -n [string] | Sets a different filename for uploaded file | ++---------------------------------+---------------------------------------------------+ | --configuration, -c [path] | Use the configuration file specified | +---------------------------------+---------------------------------------------------+ -Example: Uploading the file ``/tmp/example``. It will expire in 10 days, has unlimited downloads and requires the password ``abcd``: +**Example:** Uploading the file ``/tmp/example``. It will expire in 10 days, has unlimited downloads and requires the password ``abcd``: :: gokapi-cli upload -f /tmp/example --expiry-days 10 --password abcd @@ -279,22 +285,67 @@ Docker As a Docker container cannot access your host files without a volume, you will need to mount the folder that contains your file to upload and then specify the internal file path with ``-f``. If no ``-f`` parameter is supplied and only a single file exists in the container folder ``/upload/``, this file will be uploaded. -Example: Uploading the file ``/tmp/example``. It will expire after 5 downloads, has no time expiry and has no password. +**Example:** Uploading the file ``/tmp/example``. It will expire after 5 downloads, has no time expiry and has no password. :: docker run --rm -v gokapi-cli-config:/app/config -v /tmp/:/upload/ docker.io/f0rc3/gokapi-cli:latest upload -f /upload/example --expiry-downloads 5 -Example: Uploading the file ``/tmp/single/example``. There is no other file in the folder ``/tmp/single/``. +**Example:** Uploading the file ``/tmp/single/example``. There is no other file in the folder ``/tmp/single/``. :: docker run --rm -v gokapi-cli-config:/app/config -v /tmp/single/:/upload/ docker.io/f0rc3/gokapi-cli:latest upload -Example: Uploading the file ``/tmp/multiple/example``. There are other files in the folder ``/tmp/multiple/``. +**Example:** Uploading the file ``/tmp/multiple/example``. There are other files in the folder ``/tmp/multiple/``. :: docker run --rm -v gokapi-cli-config:/app/config -v /tmp/multiple/example:/upload/example docker.io/f0rc3/gokapi-cli:latest upload + + +Uploading a directory +================================= + + +By running ``gokapi-cli upload-dir -D /path/to/directory/``, gokapi-cli compresses the given folder as a zip file and then uploads it. By default the foldername is used for the name of the zip file. Also the file is encrypted (if enabled) and stored without any expiration. + +In addition to all the options seen in chapter :ref:`clitool-upload-file`, the following optional options are also available: + ++---------------------------------+---------------------------------------------------+ +| Parameter | Effect | ++=================================+===================================================+ +| --tmpfolder, -t | Sets the path for temporary files. | ++---------------------------------+---------------------------------------------------+ + + +**Example:** Uploading the folder ``/tmp/example/``. It will expire in 10 days, has unlimited downloads and requires the password ``abcd``: +:: + + gokapi-cli upload-dir -D /tmp/example --expiry-days 10 --password abcd + + +.. warning:: + + If you are using end-to-end encryption, do not upload other encrypted files simultaneously to avoid race conditions. + + + +Docker +--------------------------------- + +As a Docker container cannot access your host files without a volume, you will need to mount the folder that contains your file to upload and then specify the internal path with ``-D``. If no ``-D`` parameter is supplied, the folder ``/upload/`` will be uploaded (if it contains any files). + +**Example:** Uploading the folder ``/tmp/example/``. It will expire after 5 downloads, has no time expiry and has no password. +:: + + docker run --rm -v gokapi-cli-config:/app/config -v /tmp/example/:/upload/example docker.io/f0rc3/gokapi-cli:latest upload-dir -D /upload/example/ --expiry-downloads 5 + +**Example:** Uploading the folder ``/tmp/another/example`` and setting the filename to ``example.zip`` +:: + + docker run --rm -v gokapi-cli-config:/app/config -v /tmp/another/example:/upload/ docker.io/f0rc3/gokapi-cli:latest upload-dir -n "example.zip" + + .. _api: