feat: add agent-friendly ufile CLI
This commit is contained in:
@@ -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
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
/ufile
|
/ufile
|
||||||
|
/ufile-cli
|
||||||
|
/dist/
|
||||||
/data/
|
/data/
|
||||||
/frontend/node_modules/
|
/frontend/node_modules/
|
||||||
/frontend/dist/
|
/frontend/dist/
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
.PHONY: build frontend test clean
|
.PHONY: build cli cli-dist frontend test clean
|
||||||
|
|
||||||
build: frontend
|
build: frontend
|
||||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ufile .
|
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:
|
frontend:
|
||||||
cd frontend && npm ci && npm run build
|
cd frontend && npm ci && npm run build
|
||||||
@@ -10,4 +17,4 @@ test: frontend
|
|||||||
go test ./...
|
go test ./...
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f ufile
|
rm -f ufile ufile-cli
|
||||||
|
|||||||
@@ -10,7 +10,36 @@
|
|||||||
./build.sh
|
./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。
|
||||||
|
|
||||||
## 运行
|
## 运行
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ npm ci
|
|||||||
npm run build
|
npm run build
|
||||||
cd ..
|
cd ..
|
||||||
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ufile .
|
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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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]))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ require (
|
|||||||
github.com/gin-gonic/gin v1.10.1
|
github.com/gin-gonic/gin v1.10.1
|
||||||
github.com/glebarez/sqlite v1.11.0
|
github.com/glebarez/sqlite v1.11.0
|
||||||
golang.org/x/crypto v0.36.0
|
golang.org/x/crypto v0.36.0
|
||||||
|
golang.org/x/term v0.30.0
|
||||||
gorm.io/gorm v1.25.12
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
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 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
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=
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||||
|
|||||||
Executable
+35
@@ -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
|
||||||
Reference in New Issue
Block a user