237 lines
7.1 KiB
Go
237 lines
7.1 KiB
Go
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
|
|
}
|