feat: add agent-friendly ufile CLI
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user