feat: add agent-friendly ufile CLI
This commit is contained in:
@@ -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