From a58990ce66ac83964237ffac9dc64cafe3f42197 Mon Sep 17 00:00:00 2001 From: loveuer Date: Mon, 21 Sep 2026 00:45:35 -0700 Subject: [PATCH] feat: add agent-friendly ufile CLI --- .gitea/workflows/cli-release.yaml | 26 +++ .gitignore | 2 + Makefile | 11 +- README.md | 31 ++- build.sh | 1 + cmd/ufile-cli/auth.go | 175 +++++++++++++++++ cmd/ufile-cli/client.go | 119 ++++++++++++ cmd/ufile-cli/config.go | 96 ++++++++++ cmd/ufile-cli/download.go | 152 +++++++++++++++ cmd/ufile-cli/errors.go | 123 ++++++++++++ cmd/ufile-cli/help.go | 177 ++++++++++++++++++ cmd/ufile-cli/main.go | 199 ++++++++++++++++++++ cmd/ufile-cli/main_test.go | 301 ++++++++++++++++++++++++++++++ cmd/ufile-cli/upload.go | 236 +++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + scripts/build-cli.sh | 35 ++++ 17 files changed, 1684 insertions(+), 3 deletions(-) create mode 100644 .gitea/workflows/cli-release.yaml create mode 100644 cmd/ufile-cli/auth.go create mode 100644 cmd/ufile-cli/client.go create mode 100644 cmd/ufile-cli/config.go create mode 100644 cmd/ufile-cli/download.go create mode 100644 cmd/ufile-cli/errors.go create mode 100644 cmd/ufile-cli/help.go create mode 100644 cmd/ufile-cli/main.go create mode 100644 cmd/ufile-cli/main_test.go create mode 100644 cmd/ufile-cli/upload.go create mode 100755 scripts/build-cli.sh diff --git a/.gitea/workflows/cli-release.yaml b/.gitea/workflows/cli-release.yaml new file mode 100644 index 0000000..f664d09 --- /dev/null +++ b/.gitea/workflows/cli-release.yaml @@ -0,0 +1,26 @@ +name: CLI Release + +on: + push: + tags: + - "v*" + +jobs: + release: + runs-on: tencent-local + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Build CLI binaries + run: VERSION=${{ github.ref_name }} ./scripts/build-cli.sh + + - name: Publish Gitea release + uses: https://gitea.com/actions/gitea-release-action@v1.3.7 + with: + token: ${{ secrets.RELEASE_TOKEN }} + files: | + dist/ufile-cli_* + dist/checksums.txt diff --git a/.gitignore b/.gitignore index 031ae03..77f83a5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ /ufile +/ufile-cli +/dist/ /data/ /frontend/node_modules/ /frontend/dist/ diff --git a/Makefile b/Makefile index 8203d67..ea8f099 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,14 @@ -.PHONY: build frontend test clean +.PHONY: build cli cli-dist frontend test clean build: frontend CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ufile . + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ufile-cli ./cmd/ufile-cli + +cli: + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ufile-cli ./cmd/ufile-cli + +cli-dist: + ./scripts/build-cli.sh frontend: cd frontend && npm ci && npm run build @@ -10,4 +17,4 @@ test: frontend go test ./... clean: - rm -f ufile + rm -f ufile ufile-cli diff --git a/README.md b/README.md index 2b0f0ab..098dbb5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,36 @@ ./build.sh ``` -也可以运行 `make build`。构建脚本会先安装并编译前端,再通过 `go:embed` 将产物写入最终的 `ufile` 二进制;SQLite 使用纯 Go 驱动,不依赖 CGO。 +也可以运行 `make build`。构建脚本会先安装并编译前端,再通过 `go:embed` 将产物写入最终的 `ufile` 二进制,同时构建 `ufile-cli`;SQLite 使用纯 Go 驱动,不依赖 CGO。 + +## ufile-cli + +`ufile-cli` 面向自动化和 Agent,成功结果统一输出 JSON,错误 JSON 输出到 stderr,并使用稳定退出码。常用选项提供短参数,危险或低频选项保留语义明确的长参数;服务地址支持 `-H`、`--host` 和 `--server`。 + +```bash +# 密码不会进入命令参数或配置文件 +printf '%s\n' "$UFILE_PASSWORD" | \ + ./ufile-cli -H https://ufile.example.com auth login -U agent --password-stdin + +# 默认自动生成并回显 6 位取件码 +./ufile-cli upload ./result.zip -R 1d -D 5 + +# 只有显式指定 --no-pickup-code 才创建公开分享 +./ufile-cli upload ./public.txt --no-pickup-code + +# 下载不会额外调用取件校验接口 +./ufile-cli download Ab3dE8xP -C 123456 -O ./result.zip +``` + +运行 `ufile-cli help` 查看输出契约、环境变量和退出码,运行 `ufile-cli help upload` 或 `ufile-cli help download` 查看权限要求及操作副作用。 + +多平台构建: + +```bash +make cli-dist +``` + +产物位于 `dist/`,覆盖 Linux、macOS 和 Windows 的 amd64/arm64,并附带 `checksums.txt`。推送 `v*` 标签会通过 Gitea Actions 创建 Release;需要预先配置具有仓库发布权限的 `RELEASE_TOKEN` secret。 ## 运行 diff --git a/build.sh b/build.sh index cbca7ee..0ff9496 100755 --- a/build.sh +++ b/build.sh @@ -6,3 +6,4 @@ npm ci npm run build cd .. CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ufile . +CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ufile-cli ./cmd/ufile-cli diff --git a/cmd/ufile-cli/auth.go b/cmd/ufile-cli/auth.go new file mode 100644 index 0000000..80fa636 --- /dev/null +++ b/cmd/ufile-cli/auth.go @@ -0,0 +1,175 @@ +package main + +import ( + "bufio" + "context" + "errors" + "flag" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" + + "golang.org/x/term" +) + +func runAuth(ctx context.Context, args []string, configPath string, config configFile, client *apiClient, ioStreams streams) *commandError { + if len(args) == 0 { + return usageError("auth 需要子命令 login、whoami 或 logout;运行 ufile-cli help auth") + } + switch args[0] { + case "login": + return authLogin(ctx, args[1:], configPath, config, client, ioStreams) + case "whoami": + return authWhoami(ctx, args[1:], client, ioStreams) + case "logout": + return authLogout(ctx, args[1:], configPath, config, client, ioStreams) + case "help", "--help": + _, _ = io.WriteString(ioStreams.out, authHelp) + return nil + default: + return usageError(fmt.Sprintf("未知 auth 子命令 %q;运行 ufile-cli help auth", args[0])) + } +} + +func authLogin(ctx context.Context, args []string, configPath string, config configFile, client *apiClient, ioStreams streams) *commandError { + var username string + var passwordStdin, help bool + fs := flag.NewFlagSet("auth login", flag.ContinueOnError) + fs.SetOutput(io.Discard) + fs.StringVar(&username, "U", "", "用户名") + fs.StringVar(&username, "username", "", "用户名") + fs.BoolVar(&passwordStdin, "password-stdin", false, "从标准输入读取密码") + fs.BoolVar(&help, "help", false, "显示帮助") + if err := fs.Parse(args); err != nil { + return usageError(err.Error()) + } + if help { + _, _ = io.WriteString(ioStreams.out, authHelp) + return nil + } + if fs.NArg() != 0 { + return usageError("auth login 不接受位置参数;运行 ufile-cli help auth") + } + username = firstNonEmpty(username, os.Getenv("UFILE_USERNAME")) + if username == "" { + return usageError("缺少用户名;请使用 -U/--username 或设置 UFILE_USERNAME") + } + password, err := readPassword(passwordStdin, ioStreams) + if err != nil { + return usageError(err.Error()) + } + + var user userResponse + response, err := client.doJSON(ctx, http.MethodPost, "/api/auth/login", map[string]string{"username": username, "password": password}, &user) + if err != nil { + return classifyError(err) + } + var session string + var expiresAt time.Time + for _, cookie := range response.Cookies() { + if cookie.Name == sessionCookieName { + session = cookie.Value + expiresAt = cookie.Expires + break + } + } + if session == "" { + return networkError("登录成功,但服务端没有返回会话 Cookie", nil) + } + config.Host = client.host + config.Session = session + config.ExpiresAt = expiresAt + if err := saveConfig(configPath, config); err != nil { + return localError("保存会话配置失败", err) + } + result := struct { + Host string `json:"host"` + ExpiresAt time.Time `json:"expiresAt"` + User userResponse `json:"user"` + }{Host: client.host, ExpiresAt: expiresAt, User: user} + writeJSON(ioStreams.out, result) + return nil +} + +func authWhoami(ctx context.Context, args []string, client *apiClient, ioStreams streams) *commandError { + if isHelpOnly(args) { + _, _ = io.WriteString(ioStreams.out, authHelp) + return nil + } + if len(args) != 0 { + return usageError("auth whoami 不接受参数") + } + if client.session == "" { + return &commandError{Code: "not_logged_in", Message: "本地没有会话;请先运行 auth login", ExitCode: exitAuth} + } + var user userResponse + if _, err := client.doJSON(ctx, http.MethodGet, "/api/auth/me", nil, &user); err != nil { + return classifyError(err) + } + writeJSON(ioStreams.out, user) + return nil +} + +func authLogout(ctx context.Context, args []string, configPath string, config configFile, client *apiClient, ioStreams streams) *commandError { + if isHelpOnly(args) { + _, _ = io.WriteString(ioStreams.out, authHelp) + return nil + } + if len(args) != 0 { + return usageError("auth logout 不接受参数") + } + if client.session == "" { + return &commandError{Code: "not_logged_in", Message: "本地没有会话", ExitCode: exitAuth} + } + if _, err := client.doJSON(ctx, http.MethodPost, "/api/auth/logout", nil, nil); err != nil { + var remoteErr *apiError + if !errors.As(err, &remoteErr) || remoteErr.Status != http.StatusUnauthorized { + return classifyError(err) + } + } + config.Host = client.host + config.Session = "" + config.ExpiresAt = time.Time{} + if err := saveConfig(configPath, config); err != nil { + return localError("清除本地会话失败", err) + } + writeJSON(ioStreams.out, map[string]bool{"loggedOut": true}) + return nil +} + +func readPassword(fromStdin bool, ioStreams streams) (string, error) { + if fromStdin { + line, err := bufio.NewReader(ioStreams.in).ReadString('\n') + if err != nil && len(line) == 0 { + return "", fmt.Errorf("从标准输入读取密码失败: %w", err) + } + password := strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r") + if password == "" { + return "", fmt.Errorf("密码不能为空") + } + return password, nil + } + if password := os.Getenv("UFILE_PASSWORD"); password != "" { + return password, nil + } + if ioStreams.stdin == nil || !term.IsTerminal(int(ioStreams.stdin.Fd())) { + return "", fmt.Errorf("非交互环境需要使用 --password-stdin 或 UFILE_PASSWORD") + } + fmt.Fprint(ioStreams.errOut, "密码: ") + password, err := term.ReadPassword(int(ioStreams.stdin.Fd())) + fmt.Fprintln(ioStreams.errOut) + if err != nil { + return "", fmt.Errorf("读取密码失败: %w", err) + } + if len(password) == 0 { + return "", fmt.Errorf("密码不能为空") + } + return string(password), nil +} + +func isHelpOnly(args []string) bool { + return len(args) == 1 && args[0] == "--help" +} diff --git a/cmd/ufile-cli/client.go b/cmd/ufile-cli/client.go new file mode 100644 index 0000000..b09a692 --- /dev/null +++ b/cmd/ufile-cli/client.go @@ -0,0 +1,119 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const sessionCookieName = "ufile_session" + +type apiClient struct { + host string + session string + http *http.Client +} + +type apiError struct { + Status int + Code string + Message string +} + +func (e *apiError) Error() string { return e.Message } + +type userResponse struct { + ID uint `json:"id"` + Username string `json:"username"` + CanManageUsers bool `json:"canManageUsers"` + CanManageFiles bool `json:"canManageFiles"` + CanUpload bool `json:"canUpload"` + CanAdvancedUpload bool `json:"canAdvancedUpload"` + MustChangePassword bool `json:"mustChangePassword"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type fileResponse struct { + ID uint `json:"id"` + Hash string `json:"hash"` + Name string `json:"name"` + MimeType string `json:"mimeType"` + Size int64 `json:"size"` + ExpiresAt *time.Time `json:"expiresAt"` + MaxDownloads *int `json:"maxDownloads"` + DownloadCount int `json:"downloadCount"` +} + +func newAPIClient(host, session string, timeout time.Duration) *apiClient { + return &apiClient{host: host, session: session, http: &http.Client{Timeout: timeout}} +} + +func (c *apiClient) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { + request, err := http.NewRequestWithContext(ctx, method, c.host+path, body) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "application/json") + request.Header.Set("User-Agent", "ufile-cli/"+version) + if c.session != "" { + request.AddCookie(&http.Cookie{Name: sessionCookieName, Value: c.session}) + } + return request, nil +} + +func (c *apiClient) doJSON(ctx context.Context, method, path string, requestBody, responseBody any) (*http.Response, error) { + var body io.Reader + if requestBody != nil { + data, err := json.Marshal(requestBody) + if err != nil { + return nil, err + } + body = bytes.NewReader(data) + } + request, err := c.newRequest(ctx, method, path, body) + if err != nil { + return nil, err + } + if requestBody != nil { + request.Header.Set("Content-Type", "application/json") + } + response, err := c.http.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return response, decodeAPIError(response) + } + if responseBody == nil || response.StatusCode == http.StatusNoContent { + _, _ = io.Copy(io.Discard, response.Body) + return response, nil + } + if err := json.NewDecoder(io.LimitReader(response.Body, 2<<20)).Decode(responseBody); err != nil { + return response, fmt.Errorf("服务端返回了无效的 JSON: %w", err) + } + return response, nil +} + +func decodeAPIError(response *http.Response) error { + payload := struct { + Error string `json:"error"` + Code string `json:"code"` + }{} + data, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + _ = json.Unmarshal(data, &payload) + message := strings.TrimSpace(payload.Error) + if message == "" { + message = strings.TrimSpace(string(data)) + } + if message == "" { + message = response.Status + } + return &apiError{Status: response.StatusCode, Code: payload.Code, Message: message} +} diff --git a/cmd/ufile-cli/config.go b/cmd/ufile-cli/config.go new file mode 100644 index 0000000..942842e --- /dev/null +++ b/cmd/ufile-cli/config.go @@ -0,0 +1,96 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + "time" +) + +type configFile struct { + Host string `json:"host,omitempty"` + Session string `json:"session,omitempty"` + ExpiresAt time.Time `json:"expiresAt,omitempty"` +} + +func defaultConfigPath() string { + if value := os.Getenv("UFILE_CONFIG"); value != "" { + return value + } + dir, err := os.UserConfigDir() + if err != nil { + return "ufile-cli.json" + } + return filepath.Join(dir, "ufile-cli", "config.json") +} + +func loadConfig(path string) (configFile, error) { + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return configFile{}, nil + } + if err != nil { + return configFile{}, err + } + var config configFile + if err := json.Unmarshal(data, &config); err != nil { + return configFile{}, fmt.Errorf("配置文件格式错误: %w", err) + } + return config, nil +} + +func saveConfig(path string, config configFile) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + temp, err := os.CreateTemp(dir, ".config-*") + if err != nil { + return err + } + tempName := temp.Name() + defer os.Remove(tempName) + if err := temp.Chmod(0o600); err != nil { + temp.Close() + return err + } + if _, err := temp.Write(data); err != nil { + temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + if err := os.Rename(tempName, path); err == nil { + return nil + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + return os.Rename(tempName, path) +} + +func normalizeHost(value string) (string, error) { + value = strings.TrimSpace(value) + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("无效的服务地址 %q,需要包含 http:// 或 https://", value) + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return "", fmt.Errorf("服务地址只支持 http 或 https") + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("服务地址不能包含查询参数或片段") + } + parsed.Path = strings.TrimRight(parsed.Path, "/") + return strings.TrimRight(parsed.String(), "/"), nil +} diff --git a/cmd/ufile-cli/download.go b/cmd/ufile-cli/download.go new file mode 100644 index 0000000..e1fae23 --- /dev/null +++ b/cmd/ufile-cli/download.go @@ -0,0 +1,152 @@ +package main + +import ( + "context" + "errors" + "flag" + "io" + "mime" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" +) + +type downloadResult struct { + Path string `json:"path"` + Name string `json:"name"` + Size int64 `json:"size"` + ContentType string `json:"contentType"` +} + +func runDownload(ctx context.Context, args []string, client *apiClient, ioStreams streams) *commandError { + var code, output string + var force, help bool + fs := flag.NewFlagSet("download", flag.ContinueOnError) + fs.SetOutput(io.Discard) + fs.StringVar(&code, "C", "", "取件码") + fs.StringVar(&code, "code", "", "取件码") + fs.StringVar(&output, "O", "", "输出路径") + fs.StringVar(&output, "output", "", "输出路径") + fs.BoolVar(&force, "force", false, "覆盖已有文件") + fs.BoolVar(&help, "help", false, "显示帮助") + args = interspersedArgs(args, map[string]bool{"C": true, "code": true, "O": true, "output": true}) + if err := fs.Parse(args); err != nil { + return usageError(err.Error()) + } + if help { + _, _ = io.WriteString(ioStreams.out, downloadHelp) + return nil + } + if fs.NArg() != 1 { + return usageError("download 需要且只接受一个 8 位文件码;运行 ufile-cli help download") + } + hash := strings.TrimSpace(fs.Arg(0)) + if len(hash) != 8 { + return usageError("文件码必须是 8 位") + } + if code != "" && !pickupCodePattern.MatchString(code) { + return usageError("--code 必须是 6 位数字") + } + if output == "" { + return usageError("必须使用 -O/--output 指定输出路径;使用 - 可写入 stdout") + } + if output == "-" && force { + return usageError("输出到 stdout 时不能使用 --force") + } + + var destination io.Writer = ioStreams.out + var temp *os.File + var tempName string + if output != "-" { + if info, err := os.Stat(output); err == nil && info.IsDir() { + return localError("目标路径是目录,请使用完整文件路径", nil) + } else if err == nil && !force { + return localError("目标文件已存在;如需覆盖请使用 --force", nil) + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return localError("检查目标文件失败", err) + } + var err error + temp, err = os.CreateTemp(filepath.Dir(output), ".ufile-download-*") + if err != nil { + return localError("创建临时下载文件失败", err) + } + tempName = temp.Name() + defer os.Remove(tempName) + destination = temp + } + + query := url.Values{} + if code != "" { + query.Set("code", code) + } + path := "/uf/" + url.PathEscape(hash) + if encoded := query.Encode(); encoded != "" { + path += "?" + encoded + } + request, err := client.newRequest(ctx, http.MethodGet, path, nil) + if err != nil { + closeTemp(temp) + return classifyError(err) + } + request.Header.Set("Accept", "application/octet-stream") + response, err := client.http.Do(request) + if err != nil { + closeTemp(temp) + return classifyError(err) + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + closeTemp(temp) + return classifyError(decodeAPIError(response)) + } + + size, copyErr := io.Copy(destination, response.Body) + if temp != nil { + closeErr := temp.Close() + temp = nil + if copyErr == nil { + copyErr = closeErr + } + } + if copyErr != nil { + return localError("写入下载内容失败;本次访问次数可能已经被消耗", copyErr) + } + if output == "-" { + return nil + } + if force { + if err := os.Remove(output); err != nil && !errors.Is(err, os.ErrNotExist) { + return localError("覆盖已有文件失败;本次访问次数已经被消耗", err) + } + } else if _, err := os.Stat(output); err == nil { + return localError("下载期间目标文件已被创建,未覆盖该文件;本次访问次数已经被消耗", nil) + } else if err != nil && !errors.Is(err, os.ErrNotExist) { + return localError("检查目标文件失败;本次访问次数已经被消耗", err) + } + if err := os.Rename(tempName, output); err != nil { + return localError("保存下载文件失败;本次访问次数已经被消耗", err) + } + + result := downloadResult{ + Path: output, Name: responseFilename(response), Size: size, + ContentType: response.Header.Get("Content-Type"), + } + writeJSON(ioStreams.out, result) + return nil +} + +func responseFilename(response *http.Response) string { + _, params, err := mime.ParseMediaType(response.Header.Get("Content-Disposition")) + if err != nil { + return "" + } + return filepath.Base(params["filename"]) +} + +func closeTemp(file *os.File) { + if file != nil { + _ = file.Close() + } +} diff --git a/cmd/ufile-cli/errors.go b/cmd/ufile-cli/errors.go new file mode 100644 index 0000000..62aeab3 --- /dev/null +++ b/cmd/ufile-cli/errors.go @@ -0,0 +1,123 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" +) + +type commandError struct { + Code string + Message string + HTTPStatus int + ExitCode int + Cause error +} + +func (e *commandError) Error() string { + if e.Cause != nil { + return fmt.Sprintf("%s: %v", e.Message, e.Cause) + } + return e.Message +} + +func usageError(message string) *commandError { + return &commandError{Code: "usage_error", Message: message, ExitCode: exitUsage} +} + +func localError(message string, cause error) *commandError { + return &commandError{Code: "local_io_error", Message: message, ExitCode: exitLocalIO, Cause: cause} +} + +func networkError(message string, cause error) *commandError { + return &commandError{Code: "network_error", Message: message, ExitCode: exitNetwork, Cause: cause} +} + +func fromHTTPError(err *apiError) *commandError { + code := err.Code + exitCode := exitGeneral + switch err.Status { + case http.StatusBadRequest, http.StatusConflict, http.StatusUnprocessableEntity: + exitCode = exitValidation + if code == "" { + code = "validation_failed" + } + case http.StatusUnauthorized: + exitCode = exitAuth + if code == "" { + code = "authentication_required" + } + case http.StatusForbidden: + exitCode = exitPermission + if code == "" { + code = "permission_denied" + } + case http.StatusNotFound, http.StatusGone: + exitCode = exitUnavailable + if code == "" { + code = "file_unavailable" + } + default: + if err.Status >= 500 { + exitCode = exitNetwork + } + } + if code == "" { + code = "request_failed" + } + return &commandError{Code: code, Message: err.Message, HTTPStatus: err.Status, ExitCode: exitCode} +} + +func classifyError(err error) *commandError { + if err == nil { + return nil + } + var commandErr *commandError + if errors.As(err, &commandErr) { + return commandErr + } + var remoteErr *apiError + if errors.As(err, &remoteErr) { + return fromHTTPError(remoteErr) + } + var netErr net.Error + if errors.As(err, &netErr) { + return networkError("请求 ufile 服务失败", err) + } + return &commandError{Code: "unexpected_error", Message: "执行失败", ExitCode: exitGeneral, Cause: err} +} + +func writeCommandError(out io.Writer, err *commandError) int { + payload := struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + HTTPStatus int `json:"httpStatus,omitempty"` + } `json:"error"` + }{} + payload.Error.Code = err.Code + payload.Error.Message = err.Message + if err.Cause != nil { + payload.Error.Message += ": " + err.Cause.Error() + } + payload.Error.HTTPStatus = err.HTTPStatus + encoder := json.NewEncoder(out) + encoder.SetEscapeHTML(false) + _ = encoder.Encode(payload) + if err.ExitCode == 0 { + return exitGeneral + } + return err.ExitCode +} + +func writeJSON(out io.Writer, value any) int { + encoder := json.NewEncoder(out) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(value); err != nil { + return exitLocalIO + } + return exitOK +} diff --git a/cmd/ufile-cli/help.go b/cmd/ufile-cli/help.go new file mode 100644 index 0000000..dab035a --- /dev/null +++ b/cmd/ufile-cli/help.go @@ -0,0 +1,177 @@ +package main + +import ( + "fmt" + "io" + "strings" +) + +const rootHelp = `ufile-cli — 面向自动化与 Agent 的 ufile 命令行客户端 + +用法: + ufile-cli [全局选项] <命令> [命令选项] + +Agent 快速开始: + printf '%s\n' "$UFILE_PASSWORD" | ufile-cli -H https://ufile.example.com auth login -U agent --password-stdin + ufile-cli upload ./result.zip -R 1d -D 5 + ufile-cli download Ab3dE8xP -C 123456 -O ./result.zip + +命令: + auth login 登录并安全保存 30 天 Cookie 会话 + auth whoami 输出当前登录用户和权限 + auth logout 注销当前会话并清除本地凭据 + upload 上传文件;默认自动生成 6 位取件码 + download 下载文件;直接下载,不执行额外的取件校验 + version 输出版本、提交和构建时间 + help [命令] 显示根帮助或指定命令的详细帮助 + +全局选项(必须写在命令之前): + -H, --host URL ufile 服务地址;--server 是等价别名 + --config PATH 配置文件路径 + --timeout TIME 单次请求超时,默认 10m;0 表示不限制 + --help 显示帮助 + -V, --version 输出版本信息 + +环境变量: + UFILE_HOST 服务地址;UFILE_SERVER 是兼容别名 + UFILE_SESSION 临时覆盖配置文件中的会话 Cookie + UFILE_USERNAME auth login 的默认用户名 + UFILE_PASSWORD auth login 的非交互密码;优先使用密钥管理器注入 + UFILE_CONFIG 默认配置文件路径 + +输出契约: + 成功结果以单个 JSON 对象写入 stdout。 + 错误以单个 JSON 对象写入 stderr,并返回非零退出码。 + download -O - 是唯一例外:stdout 只包含原始文件字节,不输出 JSON。 + Agent 应以退出码判断成功,不要解析本地化的 message 文案。 + +错误 JSON: + {"error":{"code":"permission_denied","message":"没有上传权限","httpStatus":403}} + +退出码: + 0 成功 + 1 未分类错误 + 2 参数或用法错误 + 3 未登录、会话过期或认证失败 + 4 权限不足、缺少取件码或业务限制 + 5 文件不存在、已失效或取件码错误 + 6 服务端输入校验失败 + 7 网络错误或服务端内部错误 + 8 本地配置或文件读写错误 + +安全约定: + upload 默认生成取件码;只有显式传入 --no-pickup-code 才公开文件。 + 错误或缺失的取件码累计 5 次会使服务端删除整个文件。 + download 不会预先调用校验接口,避免一次操作产生多次校验请求。 + 密码不能作为命令行参数传入,避免出现在进程列表和 Agent 日志中。 + pickupCode 和 downloadUrl 可能包含访问凭据,不应写入公开日志。 + +详细帮助: + ufile-cli help auth + ufile-cli help upload + ufile-cli help download +` + +const authHelp = `ufile-cli auth — 管理 Cookie 会话 + +用法: + ufile-cli [全局选项] auth [选项] + +登录: + ufile-cli -H https://ufile.example.com auth login -U USER + printf '%s\n' "$UFILE_PASSWORD" | ufile-cli -H https://ufile.example.com auth login -U USER --password-stdin + +login 选项: + -U, --username USER 用户名;也可使用 UFILE_USERNAME + --password-stdin 从标准输入读取一行密码,适合 Agent 和 CI + --help 显示本帮助 + +密码来源优先级: + 1. --password-stdin + 2. UFILE_PASSWORD + 3. 交互式终端中的隐藏输入 + +说明: + 会话 Cookie 有效期由服务端决定,目前为 30 天。 + 密码永远不会写入配置文件。 + 首次登录后必须改密的账号,需要先在 Web 界面完成密码修改。 +` + +const uploadHelp = `ufile-cli upload — 上传文件并输出分享信息 + +用法: + ufile-cli [全局选项] upload <文件路径> [选项] + command | ufile-cli [全局选项] upload - -N <文件名> [选项] + +示例: + ufile-cli upload ./report.zip -R 1d -D 5 + ufile-cli upload ./public.txt -R 1h -D 1 --no-pickup-code + ufile-cli upload ./secret.pdf -P 123456 + command | ufile-cli upload - -N result.txt -R 8h + +选项: + -R, --retention VALUE 保留时间:1h、8h、1d、3d、forever;默认 1d + -D, --downloads VALUE 下载次数:1-99 或 unlimited;默认 5 + -P, --pickup-code CODE 使用指定的 6 位数字取件码 + --no-pickup-code 明确创建无需取件码的公开分享 + -N, --name NAME 从标准输入上传时必需的文件名 + --content-type TYPE 覆盖根据扩展名推断的 MIME 类型 + --help 显示本帮助 + +安全与权限: + 未指定 -P 或 --no-pickup-code 时,会使用密码学安全随机数自动生成取件码。 + -P 与 --no-pickup-code 不能同时使用。 + 普通上传单文件最大 10 MiB,且最多保留 10 个有效文件。 + forever、unlimited 和超过 10 MiB 的文件需要高级上传权限。 + +成功 JSON 字段: + hash、name、size、expiresAt、maxDownloads、pickupCode、downloadUrl、previewUrl + +示例输出: + {"hash":"Ab3dE8xP","name":"report.zip","size":1024,"expiresAt":"2026-09-21T12:00:00Z","maxDownloads":5,"pickupCode":"123456","downloadUrl":"https://ufile.example.com/uf/Ab3dE8xP?code=123456","previewUrl":"https://ufile.example.com/uf/Ab3dE8xP?code=123456&preview=true"} +` + +const downloadHelp = `ufile-cli download — 下载一个公开分享文件 + +用法: + ufile-cli [全局选项] download <8位文件码> -O <输出路径> [选项] + +示例: + ufile-cli download Ab3dE8xP -C 123456 -O ./report.zip + ufile-cli download Ab3dE8xP -O ./public.txt + ufile-cli download Ab3dE8xP -C 123456 -O - > report.zip + +选项: + -C, --code CODE 6 位数字取件码 + -O, --output PATH 输出路径;使用 - 将原始文件字节写入 stdout + --force 覆盖已存在的目标文件 + --help 显示本帮助 + +重要副作用: + 每次成功下载都会消耗一次访问次数;次数耗尽后服务端删除文件。 + 缺失或错误的取件码也会累计失败;达到 5 次时服务端删除文件。 + 为减少风险,本命令直接下载,不会先请求 /api/pickup 校验。 + 本地文件先写入同目录临时文件,成功后再重命名。 + +输出: + 普通路径成功时,stdout 输出包含 path、name、size、contentType 的 JSON。 + -O - 成功时,stdout 仅包含文件字节;错误 JSON 仍写入 stderr。 +` + +func printHelp(out, errOut io.Writer, topic string) int { + switch strings.TrimSpace(topic) { + case "", "root": + _, _ = io.WriteString(out, rootHelp) + case "auth", "auth login", "auth whoami", "auth logout": + _, _ = io.WriteString(out, authHelp) + case "upload": + _, _ = io.WriteString(out, uploadHelp) + case "download": + _, _ = io.WriteString(out, downloadHelp) + case "version": + fmt.Fprintln(out, "用法:ufile-cli version\n\n以 JSON 输出 version、commit 和 date。") + default: + return writeCommandError(errOut, usageError(fmt.Sprintf("未知帮助主题 %q", topic))) + } + return exitOK +} diff --git a/cmd/ufile-cli/main.go b/cmd/ufile-cli/main.go new file mode 100644 index 0000000..afd4758 --- /dev/null +++ b/cmd/ufile-cli/main.go @@ -0,0 +1,199 @@ +package main + +import ( + "context" + "flag" + "fmt" + "io" + "os" + "os/signal" + "strings" + "syscall" + "time" +) + +var ( + version = "dev" + commit = "unknown" + date = "unknown" +) + +const ( + exitOK = 0 + exitGeneral = 1 + exitUsage = 2 + exitAuth = 3 + exitPermission = 4 + exitUnavailable = 5 + exitValidation = 6 + exitNetwork = 7 + exitLocalIO = 8 +) + +type streams struct { + in io.Reader + out io.Writer + errOut io.Writer + stdin *os.File +} + +type globalOptions struct { + host string + configPath string + timeout time.Duration +} + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + os.Exit(run(ctx, os.Args[1:], streams{in: os.Stdin, out: os.Stdout, errOut: os.Stderr, stdin: os.Stdin})) +} + +func run(ctx context.Context, args []string, ioStreams streams) int { + options, rest, help, err := parseGlobalOptions(args) + if err != nil { + return writeCommandError(ioStreams.errOut, usageError(err.Error())) + } + if help || len(rest) == 0 { + _, _ = io.WriteString(ioStreams.out, rootHelp) + return exitOK + } + if rest[0] == "help" { + topic := "" + if len(rest) > 1 { + topic = strings.Join(rest[1:], " ") + } + return printHelp(ioStreams.out, ioStreams.errOut, topic) + } + if rest[0] == "version" { + return writeJSON(ioStreams.out, map[string]string{"version": version, "commit": commit, "date": date}) + } + if helpTopic, ok := requestedCommandHelp(rest); ok { + return printHelp(ioStreams.out, ioStreams.errOut, helpTopic) + } + if err := validateCommand(rest); err != nil { + return writeCommandError(ioStreams.errOut, err) + } + + config, err := loadConfig(options.configPath) + if err != nil { + return writeCommandError(ioStreams.errOut, localError("读取配置失败", err)) + } + host := firstNonEmpty(options.host, os.Getenv("UFILE_HOST"), os.Getenv("UFILE_SERVER"), config.Host) + if host == "" { + return writeCommandError(ioStreams.errOut, usageError("缺少服务地址;请使用 -H/--host,或设置 UFILE_HOST")) + } + host, err = normalizeHost(host) + if err != nil { + return writeCommandError(ioStreams.errOut, usageError(err.Error())) + } + session := firstNonEmpty(os.Getenv("UFILE_SESSION"), config.Session) + client := newAPIClient(host, session, options.timeout) + + var commandErr *commandError + switch rest[0] { + case "auth": + commandErr = runAuth(ctx, rest[1:], options.configPath, config, client, ioStreams) + case "upload": + commandErr = runUpload(ctx, rest[1:], client, ioStreams) + case "download": + commandErr = runDownload(ctx, rest[1:], client, ioStreams) + default: + commandErr = usageError(fmt.Sprintf("未知命令 %q;运行 ufile-cli help 查看可用命令", rest[0])) + } + if commandErr != nil { + return writeCommandError(ioStreams.errOut, commandErr) + } + return exitOK +} + +func parseGlobalOptions(args []string) (globalOptions, []string, bool, error) { + var options globalOptions + var help, showVersion bool + fs := flag.NewFlagSet("ufile-cli", flag.ContinueOnError) + fs.SetOutput(io.Discard) + fs.StringVar(&options.host, "H", "", "ufile 服务地址") + fs.StringVar(&options.host, "host", "", "ufile 服务地址") + fs.StringVar(&options.host, "server", "", "ufile 服务地址(--host 的别名)") + fs.StringVar(&options.configPath, "config", defaultConfigPath(), "配置文件路径") + fs.DurationVar(&options.timeout, "timeout", 10*time.Minute, "单次请求超时") + fs.BoolVar(&help, "help", false, "显示帮助") + fs.BoolVar(&showVersion, "V", false, "显示版本") + fs.BoolVar(&showVersion, "version", false, "显示版本") + if err := fs.Parse(args); err != nil { + return options, nil, false, err + } + if options.timeout < 0 { + return options, nil, false, fmt.Errorf("--timeout 不能为负数") + } + if showVersion { + return options, []string{"version"}, false, nil + } + return options, fs.Args(), help, nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} + +func interspersedArgs(args []string, valueFlags map[string]bool) []string { + options := make([]string, 0, len(args)) + positionals := make([]string, 0, len(args)) + for index := 0; index < len(args); index++ { + argument := args[index] + if argument == "--" { + positionals = append(positionals, args[index+1:]...) + break + } + if argument == "-" || !strings.HasPrefix(argument, "-") { + positionals = append(positionals, argument) + continue + } + options = append(options, argument) + name := strings.TrimLeft(strings.SplitN(argument, "=", 2)[0], "-") + if valueFlags[name] && !strings.Contains(argument, "=") && index+1 < len(args) { + index++ + options = append(options, args[index]) + } + } + return append(options, positionals...) +} + +func requestedCommandHelp(args []string) (string, bool) { + if args[0] == "auth" && len(args) > 1 && args[1] == "help" { + return "auth", true + } + for _, argument := range args[1:] { + if argument == "--help" { + topic := args[0] + if topic == "auth" && len(args) > 1 && !strings.HasPrefix(args[1], "-") { + topic += " " + args[1] + } + return topic, true + } + } + return "", false +} + +func validateCommand(args []string) *commandError { + switch args[0] { + case "upload", "download": + return nil + case "auth": + if len(args) < 2 { + return usageError("auth 需要子命令 login、whoami 或 logout;运行 ufile-cli help auth") + } + switch args[1] { + case "login", "whoami", "logout", "help": + return nil + default: + return usageError(fmt.Sprintf("未知 auth 子命令 %q;运行 ufile-cli help auth", args[1])) + } + } + return usageError(fmt.Sprintf("未知命令 %q;运行 ufile-cli help 查看可用命令", args[0])) +} diff --git a/cmd/ufile-cli/main_test.go b/cmd/ufile-cli/main_test.go new file mode 100644 index 0000000..025d3b3 --- /dev/null +++ b/cmd/ufile-cli/main_test.go @@ -0,0 +1,301 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "sync/atomic" + "testing" + "time" +) + +func runTestCLI(t *testing.T, args []string, input string) (int, string, string) { + t.Helper() + var stdout, stderr bytes.Buffer + code := run(context.Background(), args, streams{in: strings.NewReader(input), out: &stdout, errOut: &stderr}) + return code, stdout.String(), stderr.String() +} + +func TestHelpIsAvailableWithoutConfiguration(t *testing.T) { + t.Setenv("UFILE_HOST", "") + t.Setenv("UFILE_SERVER", "") + code, stdout, stderr := runTestCLI(t, []string{"download", "--help"}, "") + if code != exitOK || !strings.Contains(stdout, "-O, --output") || !strings.Contains(stdout, "达到 5 次") || stderr != "" { + t.Fatalf("code=%d, stdout=%q, stderr=%q", code, stdout, stderr) + } +} + +func TestInvalidFlagWritesOnlyJSONError(t *testing.T) { + code, stdout, stderr := runTestCLI(t, []string{"--not-a-real-option"}, "") + if code != exitUsage || stdout != "" || strings.Count(strings.TrimSpace(stderr), "\n") != 0 { + t.Fatalf("code=%d, stdout=%q, stderr=%q", code, stdout, stderr) + } + var payload map[string]any + if err := json.Unmarshal([]byte(stderr), &payload); err != nil { + t.Fatalf("stderr is not JSON: %v; %q", err, stderr) + } +} + +func TestUnsupportedShortAliasesAreRejected(t *testing.T) { + tests := [][]string{ + {"-h"}, + {"-c", "config.json", "version"}, + {"-t", "1m", "version"}, + {"-H", "https://example.com", "auth", "login", "-P"}, + {"-H", "https://example.com", "auth", "login", "-u", "agent"}, + {"-H", "https://example.com", "upload", "file.txt", "-N"}, + {"-H", "https://example.com", "upload", "file.txt", "-r", "1h"}, + {"-H", "https://example.com", "upload", "file.txt", "-T", "text/plain"}, + {"-H", "https://example.com", "download", "Ab3dE8xP", "-c", "123456", "-O", "out"}, + {"-H", "https://example.com", "download", "Ab3dE8xP", "-O", "out", "-f"}, + } + for _, args := range tests { + code, stdout, stderr := runTestCLI(t, args, "") + if code != exitUsage || stdout != "" || !strings.Contains(stderr, `"code":"usage_error"`) { + t.Fatalf("args=%q, code=%d, stdout=%q, stderr=%q", args, code, stdout, stderr) + } + } +} + +func TestAuthLoginStoresCookieWithoutPassword(t *testing.T) { + expires := time.Now().Add(30 * 24 * time.Hour).UTC().Truncate(time.Second) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/api/auth/login" { + t.Errorf("path = %s", request.URL.Path) + } + var body map[string]string + if err := json.NewDecoder(request.Body).Decode(&body); err != nil { + t.Error(err) + } + if body["username"] != "agent" || body["password"] != "secret-value" { + t.Errorf("body = %#v", body) + } + http.SetCookie(writer, &http.Cookie{Name: sessionCookieName, Value: "session-secret", Expires: expires, Path: "/"}) + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, `{"id":2,"username":"agent","canUpload":true}`) + })) + defer server.Close() + + configPath := filepath.Join(t.TempDir(), "nested", "config.json") + code, stdout, stderr := runTestCLI(t, []string{"-H", server.URL, "--config", configPath, "auth", "login", "-U", "agent", "--password-stdin"}, "secret-value\n") + if code != exitOK || stderr != "" { + t.Fatalf("code=%d, stdout=%s, stderr=%s", code, stdout, stderr) + } + config, err := loadConfig(configPath) + if err != nil { + t.Fatal(err) + } + if config.Host != server.URL || config.Session != "session-secret" || config.ExpiresAt.IsZero() { + t.Fatalf("config = %#v", config) + } + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "secret-value") { + t.Fatal("password was written to config") + } + if runtime.GOOS != "windows" { + info, _ := os.Stat(configPath) + if info.Mode().Perm() != 0o600 { + t.Fatalf("config mode = %o", info.Mode().Perm()) + } + } +} + +func TestAuthWhoamiAndLogoutUseStoredSession(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + cookie, err := request.Cookie(sessionCookieName) + if err != nil || cookie.Value != "stored-session" { + t.Errorf("cookie=%#v, err=%v", cookie, err) + } + switch { + case request.Method == http.MethodGet && request.URL.Path == "/api/auth/me": + writer.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(writer, `{"id":2,"username":"agent","canUpload":true}`) + case request.Method == http.MethodPost && request.URL.Path == "/api/auth/logout": + writer.WriteHeader(http.StatusNoContent) + default: + http.NotFound(writer, request) + } + })) + defer server.Close() + configPath := filepath.Join(t.TempDir(), "config.json") + if err := saveConfig(configPath, configFile{Host: server.URL, Session: "stored-session"}); err != nil { + t.Fatal(err) + } + + code, stdout, stderr := runTestCLI(t, []string{"--config", configPath, "auth", "whoami"}, "") + if code != exitOK || stderr != "" || !strings.Contains(stdout, `"username":"agent"`) { + t.Fatalf("whoami code=%d, stdout=%s, stderr=%s", code, stdout, stderr) + } + code, stdout, stderr = runTestCLI(t, []string{"--config", configPath, "auth", "logout"}, "") + if code != exitOK || stderr != "" || !strings.Contains(stdout, `"loggedOut":true`) { + t.Fatalf("logout code=%d, stdout=%s, stderr=%s", code, stdout, stderr) + } + config, err := loadConfig(configPath) + if err != nil || config.Session != "" { + t.Fatalf("config=%#v, err=%v", config, err) + } +} + +func TestUploadSupportsShortOptionsAfterPathAndGeneratesCode(t *testing.T) { + t.Setenv("UFILE_SESSION", "test-session") + var receivedCode string + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if cookie, err := request.Cookie(sessionCookieName); err != nil || cookie.Value != "test-session" { + t.Errorf("cookie = %#v, err = %v", cookie, err) + } + if err := request.ParseMultipartForm(2 << 20); err != nil { + t.Error(err) + } + if request.FormValue("retention") != "8h" || request.FormValue("downloads") != "3" { + t.Errorf("form = %#v", request.Form) + } + receivedCode = request.FormValue("pickupCode") + if !pickupCodePattern.MatchString(receivedCode) { + t.Errorf("pickup code = %q", receivedCode) + } + file, header, err := request.FormFile("file") + if err != nil { + t.Error(err) + } else { + if header.Header.Get("Content-Type") != "text/plain; charset=utf-8" { + t.Errorf("content type = %q", header.Header.Get("Content-Type")) + } + content, _ := io.ReadAll(file) + file.Close() + if string(content) != "agent artifact" { + t.Errorf("content = %q", content) + } + } + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(writer, `{"id":1,"hash":"Ab3dE8xP","name":"artifact.txt","size":14,"expiresAt":null,"maxDownloads":3}`) + })) + defer server.Close() + + path := filepath.Join(t.TempDir(), "artifact.txt") + if err := os.WriteFile(path, []byte("agent artifact"), 0o600); err != nil { + t.Fatal(err) + } + code, stdout, stderr := runTestCLI(t, []string{"-H", server.URL, "upload", path, "-R", "8h", "-D", "3"}, "") + if code != exitOK || stderr != "" { + t.Fatalf("code=%d, stdout=%s, stderr=%s", code, stdout, stderr) + } + var result uploadResult + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatal(err) + } + if result.PickupCode != receivedCode || !strings.Contains(result.DownloadURL, "code="+receivedCode) || !strings.Contains(result.PreviewURL, "preview=true") { + t.Fatalf("result = %#v", result) + } +} + +func TestUploadFromStdinCanBeExplicitlyPublic(t *testing.T) { + t.Setenv("UFILE_SESSION", "test-session") + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if err := request.ParseMultipartForm(2 << 20); err != nil { + t.Error(err) + } + if code := request.FormValue("pickupCode"); code != "" { + t.Errorf("pickup code = %q", code) + } + file, header, err := request.FormFile("file") + if err != nil { + t.Error(err) + } else { + content, _ := io.ReadAll(file) + file.Close() + if header.Filename != "agent.txt" || string(content) != "stdin artifact" { + t.Errorf("filename=%q, content=%q", header.Filename, content) + } + } + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusCreated) + _, _ = io.WriteString(writer, `{"hash":"Cd4fG6hJ","name":"agent.txt","size":14,"maxDownloads":5}`) + })) + defer server.Close() + + code, stdout, stderr := runTestCLI(t, []string{"-H", server.URL, "upload", "-", "-N", "agent.txt", "--no-pickup-code"}, "stdin artifact") + if code != exitOK || stderr != "" || strings.Contains(stdout, "pickupCode") || !strings.Contains(stdout, `"downloadUrl"`) { + t.Fatalf("code=%d, stdout=%s, stderr=%s", code, stdout, stderr) + } +} + +func TestDownloadUsesOneRequestAndWritesAtomically(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requests.Add(1) + if request.URL.Path != "/uf/Ab3dE8xP" || request.URL.Query().Get("code") != "123456" { + t.Errorf("url = %s", request.URL.String()) + } + writer.Header().Set("Content-Type", "text/plain") + writer.Header().Set("Content-Disposition", `attachment; filename="result.txt"`) + _, _ = io.WriteString(writer, "downloaded") + })) + defer server.Close() + + output := filepath.Join(t.TempDir(), "result.txt") + code, stdout, stderr := runTestCLI(t, []string{"--server", server.URL, "download", "Ab3dE8xP", "-C", "123456", "-O", output}, "") + if code != exitOK || stderr != "" || requests.Load() != 1 { + t.Fatalf("code=%d, requests=%d, stdout=%s, stderr=%s", code, requests.Load(), stdout, stderr) + } + content, err := os.ReadFile(output) + if err != nil || string(content) != "downloaded" { + t.Fatalf("content=%q, err=%v", content, err) + } + var result downloadResult + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatal(err) + } + if result.Name != "result.txt" || result.Size != int64(len(content)) { + t.Fatalf("result = %#v", result) + } +} + +func TestDownloadDoesNotConsumeAccessWhenTargetExists(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests.Add(1) })) + defer server.Close() + output := filepath.Join(t.TempDir(), "exists.txt") + if err := os.WriteFile(output, []byte("keep"), 0o600); err != nil { + t.Fatal(err) + } + code, _, stderr := runTestCLI(t, []string{"-H", server.URL, "download", "Ab3dE8xP", "-O", output}, "") + if code != exitLocalIO || requests.Load() != 0 || !strings.Contains(stderr, "local_io_error") { + t.Fatalf("code=%d, requests=%d, stderr=%s", code, requests.Load(), stderr) + } +} + +func TestDownloadToStdoutContainsOnlyFileBytes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + _, _ = io.WriteString(writer, "raw bytes") + })) + defer server.Close() + code, stdout, stderr := runTestCLI(t, []string{"-H", server.URL, "download", "Ab3dE8xP", "-O", "-"}, "") + if code != exitOK || stdout != "raw bytes" || stderr != "" { + t.Fatalf("code=%d, stdout=%q, stderr=%q", code, stdout, stderr) + } +} + +func TestRemoteErrorsAreMachineReadable(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "application/json") + writer.WriteHeader(http.StatusNotFound) + _, _ = io.WriteString(writer, `{"error":"文件不存在或取件码错误","code":"file_unavailable"}`) + })) + defer server.Close() + output := filepath.Join(t.TempDir(), "missing.txt") + code, stdout, stderr := runTestCLI(t, []string{"-H", server.URL, "download", "Ab3dE8xP", "-O", output}, "") + if code != exitUnavailable || stdout != "" || !strings.Contains(stderr, `"code":"file_unavailable"`) || !strings.Contains(stderr, `"httpStatus":404`) { + t.Fatalf("code=%d, stdout=%s, stderr=%s", code, stdout, stderr) + } +} diff --git a/cmd/ufile-cli/upload.go b/cmd/ufile-cli/upload.go new file mode 100644 index 0000000..b248a89 --- /dev/null +++ b/cmd/ufile-cli/upload.go @@ -0,0 +1,236 @@ +package main + +import ( + "context" + "crypto/rand" + "encoding/json" + "flag" + "fmt" + "io" + "math/big" + "mime" + "mime/multipart" + "net/http" + "net/textproto" + "net/url" + "os" + "path/filepath" + "regexp" + "strconv" + "time" +) + +var pickupCodePattern = regexp.MustCompile(`^[0-9]{6}$`) + +type uploadResult struct { + Hash string `json:"hash"` + Name string `json:"name"` + Size int64 `json:"size"` + ExpiresAt *time.Time `json:"expiresAt"` + MaxDownloads *int `json:"maxDownloads"` + PickupCode string `json:"pickupCode,omitempty"` + DownloadURL string `json:"downloadUrl"` + PreviewURL string `json:"previewUrl"` +} + +func runUpload(ctx context.Context, args []string, client *apiClient, ioStreams streams) *commandError { + var retention, downloads, pickupCode, name, contentType string + var noPickupCode, help bool + fs := flag.NewFlagSet("upload", flag.ContinueOnError) + fs.SetOutput(io.Discard) + fs.StringVar(&retention, "R", "1d", "保留时间") + fs.StringVar(&retention, "retention", "1d", "保留时间") + fs.StringVar(&downloads, "D", "5", "下载次数") + fs.StringVar(&downloads, "downloads", "5", "下载次数") + fs.StringVar(&pickupCode, "P", "", "6 位取件码") + fs.StringVar(&pickupCode, "pickup-code", "", "6 位取件码") + fs.BoolVar(&noPickupCode, "no-pickup-code", false, "不设置取件码") + fs.StringVar(&name, "N", "", "标准输入的文件名") + fs.StringVar(&name, "name", "", "标准输入的文件名") + fs.StringVar(&contentType, "content-type", "", "MIME 类型") + fs.BoolVar(&help, "help", false, "显示帮助") + args = interspersedArgs(args, map[string]bool{"R": true, "retention": true, "D": true, "downloads": true, "P": true, "pickup-code": true, "N": true, "name": true, "content-type": true}) + if err := fs.Parse(args); err != nil { + return usageError(err.Error()) + } + if help { + _, _ = io.WriteString(ioStreams.out, uploadHelp) + return nil + } + if fs.NArg() != 1 { + return usageError("upload 需要且只接受一个文件路径;运行 ufile-cli help upload") + } + if client.session == "" { + return &commandError{Code: "not_logged_in", Message: "上传需要登录;请先运行 auth login", ExitCode: exitAuth} + } + if !validRetention(retention) { + return usageError("--retention 只支持 1h、8h、1d、3d 或 forever") + } + if !validDownloads(downloads) { + return usageError("--downloads 需要是 1-99 或 unlimited") + } + if pickupCode != "" && noPickupCode { + return usageError("--pickup-code 与 --no-pickup-code 不能同时使用") + } + if pickupCode != "" && !pickupCodePattern.MatchString(pickupCode) { + return usageError("--pickup-code 必须是 6 位数字") + } + if pickupCode == "" && !noPickupCode { + var err error + pickupCode, err = randomPickupCode() + if err != nil { + return &commandError{Code: "random_failed", Message: "生成取件码失败", ExitCode: exitGeneral, Cause: err} + } + } + + path := fs.Arg(0) + var source io.Reader + var closeSource func() error + if path == "-" { + if name == "" { + return usageError("从标准输入上传时必须使用 -N/--name 指定文件名") + } + source = ioStreams.in + } else { + if name != "" { + return usageError("-N/--name 只用于标准输入上传") + } + file, err := os.Open(path) + if err != nil { + return localError("打开待上传文件失败", err) + } + info, err := file.Stat() + if err != nil { + file.Close() + return localError("读取待上传文件信息失败", err) + } + if !info.Mode().IsRegular() { + file.Close() + return usageError("upload 只支持普通文件或标准输入") + } + source = file + closeSource = file.Close + name = filepath.Base(path) + } + if closeSource != nil { + defer closeSource() + } + name = filepath.Base(name) + if name == "" || name == "." { + return usageError("文件名不能为空") + } + if contentType == "" { + contentType = mime.TypeByExtension(filepath.Ext(name)) + } + if contentType == "" { + contentType = "application/octet-stream" + } + if _, _, err := mime.ParseMediaType(contentType); err != nil { + return usageError("--content-type 不是有效的 MIME 类型") + } + + bodyReader, bodyWriter := io.Pipe() + multipartWriter := multipart.NewWriter(bodyWriter) + writeResult := make(chan error, 1) + go func() { + err := writeMultipartUpload(multipartWriter, source, name, contentType, retention, downloads, pickupCode) + if err == nil { + err = multipartWriter.Close() + } + _ = bodyWriter.CloseWithError(err) + writeResult <- err + }() + + request, err := client.newRequest(ctx, http.MethodPost, "/api/files", bodyReader) + if err != nil { + bodyReader.CloseWithError(err) + <-writeResult + return classifyError(err) + } + request.Header.Set("Content-Type", multipartWriter.FormDataContentType()) + response, requestErr := client.http.Do(request) + if requestErr != nil { + bodyReader.CloseWithError(requestErr) + <-writeResult + return classifyError(requestErr) + } + _ = bodyReader.Close() + writeErr := <-writeResult + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return classifyError(decodeAPIError(response)) + } + if writeErr != nil { + return localError("读取待上传文件失败", writeErr) + } + var uploaded fileResponse + if err := json.NewDecoder(io.LimitReader(response.Body, 2<<20)).Decode(&uploaded); err != nil { + return networkError("服务端返回了无效的上传结果", err) + } + downloadURL := shareURL(client.host, uploaded.Hash, pickupCode, false) + result := uploadResult{ + Hash: uploaded.Hash, Name: uploaded.Name, Size: uploaded.Size, ExpiresAt: uploaded.ExpiresAt, + MaxDownloads: uploaded.MaxDownloads, PickupCode: pickupCode, DownloadURL: downloadURL, + PreviewURL: shareURL(client.host, uploaded.Hash, pickupCode, true), + } + writeJSON(ioStreams.out, result) + return nil +} + +func writeMultipartUpload(writer *multipart.Writer, source io.Reader, name, contentType, retention, downloads, pickupCode string) error { + fields := [][2]string{{"retention", retention}, {"downloads", downloads}, {"pickupCode", pickupCode}} + for _, field := range fields { + if err := writer.WriteField(field[0], field[1]); err != nil { + return err + } + } + header := make(textproto.MIMEHeader) + header.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{"name": "file", "filename": name})) + header.Set("Content-Type", contentType) + part, err := writer.CreatePart(header) + if err != nil { + return err + } + _, err = io.Copy(part, source) + return err +} + +func randomPickupCode() (string, error) { + value, err := rand.Int(rand.Reader, big.NewInt(1_000_000)) + if err != nil { + return "", err + } + return fmt.Sprintf("%06d", value.Int64()), nil +} + +func validRetention(value string) bool { + switch value { + case "1h", "8h", "1d", "3d", "forever": + return true + default: + return false + } +} + +func validDownloads(value string) bool { + if value == "unlimited" { + return true + } + number, err := strconv.Atoi(value) + return err == nil && number >= 1 && number <= 99 +} + +func shareURL(host, hash, pickupCode string, preview bool) string { + result := host + "/uf/" + url.PathEscape(hash) + query := url.Values{} + if pickupCode != "" { + query.Set("code", pickupCode) + } + if preview { + query.Set("preview", "true") + } + if encoded := query.Encode(); encoded != "" { + result += "?" + encoded + } + return result +} diff --git a/go.mod b/go.mod index 2bef864..3bad576 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/gin-gonic/gin v1.10.1 github.com/glebarez/sqlite v1.11.0 golang.org/x/crypto v0.36.0 + golang.org/x/term v0.30.0 gorm.io/gorm v1.25.12 ) diff --git a/go.sum b/go.sum index 8e0ab07..fab641e 100644 --- a/go.sum +++ b/go.sum @@ -91,6 +91,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= +golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= diff --git a/scripts/build-cli.sh b/scripts/build-cli.sh new file mode 100755 index 0000000..45eaf62 --- /dev/null +++ b/scripts/build-cli.sh @@ -0,0 +1,35 @@ +#!/bin/sh +set -eu + +cd "$(dirname "$0")/.." + +version=${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)} +commit=${COMMIT:-$(git rev-parse --short HEAD 2>/dev/null || echo unknown)} +build_date=${BUILD_DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)} +ldflags="-s -w -X main.version=$version -X main.commit=$commit -X main.date=$build_date" + +mkdir -p dist + +for target in \ + linux/amd64 \ + linux/arm64 \ + darwin/amd64 \ + darwin/arm64 \ + windows/amd64 \ + windows/arm64 +do + goos=${target%/*} + goarch=${target#*/} + output="dist/ufile-cli_${goos}_${goarch}" + if [ "$goos" = windows ]; then + output="${output}.exe" + fi + echo "building $output" + CGO_ENABLED=0 GOOS=$goos GOARCH=$goarch go build -trimpath -ldflags="$ldflags" -o "$output" ./cmd/ufile-cli +done + +if command -v sha256sum >/dev/null 2>&1; then + (cd dist && sha256sum ufile-cli_*) > dist/checksums.txt +else + (cd dist && shasum -a 256 ufile-cli_*) > dist/checksums.txt +fi