feat: add agent-friendly ufile CLI

This commit is contained in:
loveuer
2026-09-21 00:45:35 -07:00
parent f0c29e4832
commit a58990ce66
17 changed files with 1684 additions and 3 deletions
+26
View File
@@ -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
+2
View File
@@ -1,4 +1,6 @@
/ufile
/ufile-cli
/dist/
/data/
/frontend/node_modules/
/frontend/dist/
+9 -2
View File
@@ -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
+30 -1
View File
@@ -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。
## 运行
+1
View File
@@ -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
+175
View File
@@ -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"
}
+119
View File
@@ -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}
}
+96
View File
@@ -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
}
+152
View File
@@ -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()
}
}
+123
View File
@@ -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
}
+177
View File
@@ -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 <login|whoami|logout> [选项]
登录:
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
}
+199
View File
@@ -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]))
}
+301
View File
@@ -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)
}
}
+236
View File
@@ -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
}
+1
View File
@@ -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
)
+2
View File
@@ -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=
+35
View File
@@ -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