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 }