From a2589ee4b3fea3fe233573b6593fe92109dc5d1f Mon Sep 17 00:00:00 2001 From: loveuer Date: Mon, 2 Mar 2026 01:49:37 -0800 Subject: [PATCH] feat: add download limit and expiry control per upload (v0.7.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - model/meta.go: add MaxDownloads, ExpiresAt, Downloads fields - opt/var.go: add X-Max-Downloads, X-Expires-In header constants; MinExpiresIn=30s, defaults - controller/meta.go: New() accepts maxDownloads+expiresIn; CheckAndIncrDownload() validates expiry/limit and increments counter atomically; periodic cleanup for expired files - handler/share.go: Fetch uses CheckAndIncrDownload (returns 410 on expired/limit exceeded); ShareNew and ShareAPIUpload read X-Max-Downloads/X-Expires-In headers Frontend: - upload.ts: UploadSettings interface; pass X-Max-Downloads and X-Expires-In headers on upload init - panel-left.tsx: collapsible "高级设置" panel with download count (0-999) and expiry (1-24h) controls; show settings summary on upload success card 🤖 Generated with [Qoder][https://qoder.com] --- frontend/src/api/upload.ts | 24 ++- .../src/page/share/component/panel-left.tsx | 195 ++++++++++++++---- internal/controller/meta.go | 163 +++++++++++---- internal/handler/share.go | 53 +++-- internal/model/meta.go | 11 +- internal/opt/var.go | 15 +- 6 files changed, 353 insertions(+), 108 deletions(-) diff --git a/frontend/src/api/upload.ts b/frontend/src/api/upload.ts index 3a4a120..317a209 100644 --- a/frontend/src/api/upload.ts +++ b/frontend/src/api/upload.ts @@ -1,5 +1,9 @@ import { useState } from 'react'; +export interface UploadSettings { + maxDownloads: number; // 0 = unlimited + expiresIn: number; // seconds +} interface UploadRes { code: string @@ -10,18 +14,25 @@ export const useFileUpload = () => { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const uploadFile = async (file: File): Promise => { + const uploadFile = async (file: File, settings?: UploadSettings): Promise => { setLoading(true); setError(null); setProgress(0); + const maxDownloads = settings?.maxDownloads ?? 3; + const expiresIn = settings?.expiresIn ?? 28800; + try { const url = `/api/ushare/${file.name}`; // 1. 初始化上传 const res1 = await fetch(url, { method: "PUT", - headers: {"X-File-Size": file.size.toString()} + headers: { + "X-File-Size": file.size.toString(), + "X-Max-Downloads": maxDownloads.toString(), + "X-Expires-In": expiresIn.toString(), + } }); if (!res1.ok) { @@ -30,7 +41,6 @@ export const useFileUpload = () => { window.location.href = "/login?next=/share" return "" } - throw new Error("上传失败<1>"); } @@ -64,19 +74,17 @@ export const useFileUpload = () => { throw new Error(`上传失败<3>: ${err}`); } - // 更新进度 - // const currentProgress = Number(((chunkIndex + 1) / totalChunks * 100).toFixed(2)); // 小数 - const currentProgress = Math.round(((chunkIndex + 1) / totalChunks) * 100); // 整数 0-100 + const currentProgress = Math.round(((chunkIndex + 1) / totalChunks) * 100); setProgress(currentProgress); } return code; } catch (err) { - throw err; // 将错误继续抛出以便组件处理 + throw err; } finally { setLoading(false); } }; return { uploadFile, progress, loading, error }; -}; \ No newline at end of file +}; diff --git a/frontend/src/page/share/component/panel-left.tsx b/frontend/src/page/share/component/panel-left.tsx index fd44e92..addc76c 100644 --- a/frontend/src/page/share/component/panel-left.tsx +++ b/frontend/src/page/share/component/panel-left.tsx @@ -3,7 +3,7 @@ import {UButton} from "../../../component/button/u-button.tsx"; import React, {useState} from "react"; import {useStore} from "../../../store/share.ts"; import {message} from "../../../hook/message/u-message.tsx"; -import {useFileUpload} from "../../../api/upload.ts"; +import {useFileUpload, UploadSettings} from "../../../api/upload.ts"; const useUploadStyle = createUseStyles({ container: { @@ -60,6 +60,66 @@ const useUploadStyle = createUseStyles({ cursor: 'pointer', '&:hover': {} }, + // Advanced settings + advToggle: { + marginTop: '16px', + display: 'flex', + alignItems: 'center', + gap: '6px', + cursor: 'pointer', + color: '#2c9678', + fontSize: '13px', + userSelect: 'none', + opacity: 0.75, + '&:hover': {opacity: 1}, + }, + advPanel: { + marginTop: '12px', + padding: '14px 16px', + backgroundColor: 'rgba(255,255,255,0.5)', + borderRadius: '10px', + display: 'flex', + flexDirection: 'column', + gap: '12px', + }, + advRow: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '12px', + }, + advLabel: { + color: '#2c9678', + fontSize: '13px', + fontWeight: 500, + flexShrink: 0, + }, + advInput: { + width: '80px', + padding: '5px 8px', + borderRadius: '5px', + border: '1px solid rgba(44,150,120,0.4)', + fontSize: '13px', + textAlign: 'center', + outline: 'none', + backgroundColor: 'rgba(255,255,255,0.8)', + '&:focus': {borderColor: '#2c9678'}, + }, + advSelect: { + padding: '5px 8px', + borderRadius: '5px', + border: '1px solid rgba(44,150,120,0.4)', + fontSize: '13px', + outline: 'none', + backgroundColor: 'rgba(255,255,255,0.8)', + color: '#333', + cursor: 'pointer', + '&:focus': {borderColor: '#2c9678'}, + }, + advHint: { + fontSize: '11px', + color: '#888', + }, }) const useShowStyle = createUseStyles({ @@ -115,7 +175,7 @@ const useShowStyle = createUseStyles({ height: "24px", cursor: "pointer", "&:hover": { - boxShadow: "20px 20px 60px #fff, -20px -20px 60px #fff", + boxShadow: "20px 20px 60px #fff, -20px -20px 60px #fff", }, }, codeWrapper: { @@ -163,22 +223,44 @@ const useShowStyle = createUseStyles({ fontSize: "12px", }, }, + metaInfo: { + fontSize: '12px', + color: '#555', + marginTop: '10px', + display: 'flex', + gap: '16px', + flexWrap: 'wrap', + }, + metaItem: { + display: 'flex', + alignItems: 'center', + gap: '4px', + }, }); +// Expiry options (hours) shown in the dropdown +const EXPIRY_OPTIONS = [1, 2, 4, 8, 12, 24]; + export const PanelLeft = () => { - const [code, set_code] = useState("") + const [code, setCode] = useState("") + const [settings, setSettings] = useState({maxDownloads: 3, expiresIn: 8 * 3600}) if (code) { - return + return } - return + return }; -const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_code}) => { +const PanelLeftUpload: React.FC<{ + set_code: (code: string) => void; + settings: UploadSettings; + setSettings: (s: UploadSettings) => void; +}> = ({set_code, settings, setSettings}) => { const style = useUploadStyle() const {file, setFile} = useStore() const {uploadFile, progress, loading} = useFileUpload(); + const [showAdv, setShowAdv] = useState(false); function onFileSelect() { // @ts-expect-error no types for direct DOM query @@ -190,11 +272,8 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod } async function onFileUpload() { - if (!file) { - return - } - - const code = await uploadFile(file) + if (!file) return; + const code = await uploadFile(file, settings) set_code(code) } @@ -202,34 +281,76 @@ const PanelLeftUpload: React.FC<{ set_code: (code:string) => void }> = ({set_cod setFile(null) } + function onMaxDownloadsChange(e: React.ChangeEvent) { + let v = parseInt(e.target.value, 10); + if (isNaN(v) || v < 0) v = 0; + if (v > 999) v = 999; + setSettings({...settings, maxDownloads: v}); + } + + function onExpiryChange(e: React.ChangeEvent) { + setSettings({...settings, expiresIn: parseInt(e.target.value, 10)}); + } + return

上传文件

- { - !file && !loading && - 选择文件 - } - { - file && !loading && - 上传文件 - } - { - loading && - 上传中 - } + {!file && !loading && 选择文件} + {file && !loading && 上传文件} + {loading && 上传中} - { - file && + {file && (
×
{file.name}
- } + )} + + {/* Advanced settings toggle */} +
setShowAdv(v => !v)}> + {showAdv ? '▾' : '▸'} + 高级设置 +
+ + {showAdv && ( +
+
+ 下载次数限制 +
+ + 0 = 不限制 +
+
+
+ 过期时间 + +
+
+ )}
} -const PanelLeftShow: React.FC<{ code: string; set_code: (code: string) => void }> = ({ code, set_code }) => { +const PanelLeftShow: React.FC<{ + code: string; + set_code: (code: string) => void; + settings: UploadSettings; +}> = ({code, set_code, settings}) => { const classes = useShowStyle(); const handleCopy = async () => { @@ -241,6 +362,9 @@ const PanelLeftShow: React.FC<{ code: string; set_code: (code: string) => void } } }; + const expiryHours = Math.round(settings.expiresIn / 3600); + const downloadLimit = settings.maxDownloads === 0 ? '不限' : `${settings.maxDownloads} 次`; + return (
@@ -248,21 +372,22 @@ const PanelLeftShow: React.FC<{ code: string; set_code: (code: string) => void } className={classes.closeButton} onClick={() => set_code('')} aria-label="关闭" - > - × - -

- 上传成功! -

+ >× +

上传成功!

                         {code}
                         
+                            一键复制
+                        
                     
+ +
+ 下载限制:{downloadLimit} + 有效期:{expiryHours} 小时 +
); diff --git a/internal/controller/meta.go b/internal/controller/meta.go index 321c697..3865d9d 100644 --- a/internal/controller/meta.go +++ b/internal/controller/meta.go @@ -3,34 +3,38 @@ package controller import ( "context" "fmt" - "github.com/loveuer/nf/nft/log" - "github.com/loveuer/ushare/internal/model" - "github.com/loveuer/ushare/internal/opt" - gonanoid "github.com/matoous/go-nanoid/v2" - "github.com/spf13/viper" "io" "os" "path/filepath" "strings" "sync" "time" + + gonanoid "github.com/matoous/go-nanoid/v2" + "github.com/loveuer/nf/nft/log" + "github.com/loveuer/ushare/internal/model" + "github.com/loveuer/ushare/internal/opt" + "github.com/pkg/errors" + "github.com/spf13/viper" ) type metaInfo struct { - f *os.File - name string - create time.Time - last time.Time - size int64 - cursor int64 - user string + f *os.File + name string + create time.Time + last time.Time + size int64 + cursor int64 + user string + maxDownloads int + expiresAt int64 } func (m *metaInfo) generateMeta(code string) error { - content := fmt.Sprintf("filename=%s\ncreated_at=%d\nsize=%d\nuploader=%s", - m.name, m.create.UnixMilli(), m.size, m.user, + content := fmt.Sprintf( + "filename=%s\ncreated_at=%d\nsize=%d\nuploader=%s\nmax_downloads=%d\nexpires_at=%d\ndownloads=0", + m.name, m.create.UnixMilli(), m.size, m.user, m.maxDownloads, m.expiresAt, ) - return os.WriteFile(opt.MetaPath(code), []byte(content), 0644) } @@ -46,8 +50,19 @@ var ( const letters = "1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ" -func (m *meta) New(size int64, filename, ip string) (string, error) { +// New creates a new upload session. +// maxDownloads: 0 = unlimited; expiresIn: seconds from now (minimum opt.MinExpiresIn). +func (m *meta) New(size int64, filename, ip string, maxDownloads int, expiresIn int64) (string, error) { now := time.Now() + + if expiresIn < opt.MinExpiresIn { + expiresIn = opt.DefaultExpiresIn + } + + if maxDownloads < 0 { + maxDownloads = 0 + } + code, err := gonanoid.Generate(letters, opt.CodeLength) if err != nil { return "", err @@ -66,7 +81,17 @@ func (m *meta) New(size int64, filename, ip string) (string, error) { m.Lock() defer m.Unlock() - m.m[code] = &metaInfo{f: f, name: filename, last: now, size: size, cursor: 0, create: now, user: ip} + m.m[code] = &metaInfo{ + f: f, + name: filename, + last: now, + size: size, + cursor: 0, + create: now, + user: ip, + maxDownloads: maxDownloads, + expiresAt: now.Unix() + expiresIn, + } return code, nil } @@ -100,6 +125,67 @@ func (m *meta) Write(code string, start, end int64, reader io.Reader) (total, cu return total, cursor, nil } +// CheckAndIncrDownload reads the meta file, validates expiry and download limit, +// increments the download counter, and writes the meta file back. +// Returns the meta on success, or an error if the file is unavailable. +func (m *meta) CheckAndIncrDownload(code string) (*model.Meta, error) { + m.Lock() + defer m.Unlock() + + metaPath := opt.MetaPath(code) + + v := viper.New() + v.SetConfigFile(metaPath) + v.SetConfigType("env") + if err := v.ReadInConfig(); err != nil { + return nil, errors.New("文件不存在或已过期") + } + + info := new(model.Meta) + if err := v.Unmarshal(info); err != nil { + return nil, errors.New("文件元数据损坏") + } + + now := time.Now().Unix() + + // Check expiry + if info.ExpiresAt > 0 && now > info.ExpiresAt { + // Clean up expired files + go func() { + _ = os.RemoveAll(opt.FilePath(code)) + _ = os.RemoveAll(metaPath) + }() + return nil, errors.New("文件已过期") + } + + // Check download limit + if info.MaxDownloads > 0 && info.Downloads >= info.MaxDownloads { + return nil, errors.New("文件下载次数已达上限") + } + + // Increment downloads and write back + info.Downloads++ + content := fmt.Sprintf( + "filename=%s\ncreated_at=%d\nsize=%d\nuploader=%s\nmax_downloads=%d\nexpires_at=%d\ndownloads=%d", + info.Filename, info.CreatedAt, info.Size, info.Uploader, + info.MaxDownloads, info.ExpiresAt, info.Downloads, + ) + if err := os.WriteFile(metaPath, []byte(content), 0644); err != nil { + log.Warn("meta.CheckAndIncrDownload: write back failed: %s", err.Error()) + } + + // If this was the last allowed download, clean up after serving + if info.MaxDownloads > 0 && info.Downloads >= info.MaxDownloads { + go func() { + time.Sleep(5 * time.Second) + _ = os.RemoveAll(opt.FilePath(code)) + _ = os.RemoveAll(metaPath) + }() + } + + return info, nil +} + func (m *meta) Start(ctx context.Context) { ticker := time.NewTicker(time.Minute) m.ctx = ctx @@ -108,7 +194,7 @@ func (m *meta) Start(ctx context.Context) { log.Fatal("controller.MetaManager.Start: mkdir datapath failed, path = %s, err = %s", opt.Cfg.DataPath, err.Error()) } - // 清理 2 分钟内没有继续上传的 part + // Clean uploads with no activity for 2 minutes go func() { for { select { @@ -133,7 +219,7 @@ func (m *meta) Start(ctx context.Context) { } }() - // 清理一天前的文件 + // Clean expired files by walking the data directory go func() { if opt.Cfg.CleanInterval <= 0 { log.Warn("meta.Clean: no clean interval set, plz clean manual!!!") @@ -148,12 +234,10 @@ func (m *meta) Start(ctx context.Context) { case <-ctx.Done(): return case now := <-ticker.C: - //log.Debug("meta.Clean: 开始清理过期文件 = %v", duration) _ = filepath.Walk(opt.Cfg.DataPath, func(path string, info os.FileInfo, err error) error { if info == nil { return nil } - if info.IsDir() { return nil } @@ -163,36 +247,33 @@ func (m *meta) Start(ctx context.Context) { return nil } - viper.SetConfigFile(path) - viper.SetConfigType("env") - if err = viper.ReadInConfig(); err != nil { - // todo log + v := viper.New() + v.SetConfigFile(path) + v.SetConfigType("env") + if err = v.ReadInConfig(); err != nil { return nil } mi := new(model.Meta) - - if err = viper.Unmarshal(mi); err != nil { - // todo log + if err = v.Unmarshal(mi); err != nil { return nil } code := strings.TrimPrefix(name, ".meta.") + // Remove if past explicit expiry + if mi.ExpiresAt > 0 && now.Unix() > mi.ExpiresAt { + log.Debug("controller.meta: file expired, code = %s", code) + _ = os.RemoveAll(opt.FilePath(code)) + _ = os.RemoveAll(path) + return nil + } + + // Remove if past global clean interval if now.Sub(time.UnixMilli(mi.CreatedAt)) > duration { - - log.Debug("controller.meta: file out of date, code = %s, user_key = %s", code, mi.Uploader) - - if err = os.RemoveAll(opt.FilePath(code)); err != nil { - log.Warn("meta.Clean: remove file failed, file = %s, err = %s", opt.FilePath(code), err.Error()) - } - if err = os.RemoveAll(path); err != nil { - log.Warn("meta.Clean: remove file failed, file = %s, err = %s", path, err.Error()) - } - - m.Lock() - delete(m.m, code) - m.Unlock() + log.Debug("controller.meta: file out of date, code = %s", code) + _ = os.RemoveAll(opt.FilePath(code)) + _ = os.RemoveAll(path) } return nil diff --git a/internal/handler/share.go b/internal/handler/share.go index 6ad226a..adddbcc 100644 --- a/internal/handler/share.go +++ b/internal/handler/share.go @@ -10,35 +10,26 @@ import ( "github.com/loveuer/nf" "github.com/loveuer/nf/nft/log" "github.com/loveuer/ushare/internal/controller" - "github.com/loveuer/ushare/internal/model" "github.com/loveuer/ushare/internal/opt" "github.com/pkg/errors" "github.com/spf13/cast" - "github.com/spf13/viper" ) func Fetch() nf.HandlerFunc { return func(c *nf.Ctx) error { code := c.Param("code") log.Debug("handler.Fetch: code = %s", code) - info := new(model.Meta) - _, err := os.Stat(opt.MetaPath(code)) - if err != nil { + + if _, err := os.Stat(opt.MetaPath(code)); err != nil { if errors.Is(err, os.ErrNotExist) { return c.Status(http.StatusNotFound).JSON(map[string]string{"msg": "文件不存在"}) } - return c.SendStatus(http.StatusInternalServerError) } - viper.SetConfigFile(opt.MetaPath(code)) - viper.SetConfigType("env") - if err = viper.ReadInConfig(); err != nil { - return c.SendStatus(http.StatusInternalServerError) - } - - if err = viper.Unmarshal(info); err != nil { - return c.SendStatus(http.StatusInternalServerError) + info, err := controller.MetaManager.CheckAndIncrDownload(code) + if err != nil { + return c.Status(http.StatusGone).JSON(map[string]string{"msg": err.Error()}) } c.SetHeader("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, info.Filename)) @@ -60,7 +51,21 @@ func ShareNew() nf.HandlerFunc { return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "miss header: " + opt.HeaderSize}) } - code, err := controller.MetaManager.New(size, filename, c.IP()) + maxDownloads := opt.DefaultMaxDownloads + if v := c.Get(opt.HeaderMaxDownload); v != "" { + if n, err := cast.ToIntE(v); err == nil && n >= 0 { + maxDownloads = n + } + } + + expiresIn := int64(opt.DefaultExpiresIn) + if v := c.Get(opt.HeaderExpiresIn); v != "" { + if n, err := cast.ToInt64E(v); err == nil && n >= opt.MinExpiresIn { + expiresIn = n + } + } + + code, err := controller.MetaManager.New(size, filename, c.IP(), maxDownloads, expiresIn) if err != nil { return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": ""}) } @@ -120,7 +125,7 @@ func ShareUpload() nf.HandlerFunc { // ShareAPIUpload handles one-step file upload via API token. // PUT /api/v1/upload/:filename -// Accepts the raw file body and Content-Length header, returns the download code. +// Optional headers: X-Max-Downloads, X-Expires-In (seconds). func ShareAPIUpload() nf.HandlerFunc { return func(c *nf.Ctx) error { filename := strings.TrimSpace(c.Param("filename")) @@ -133,7 +138,21 @@ func ShareAPIUpload() nf.HandlerFunc { return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "Content-Length header required"}) } - code, err := controller.MetaManager.New(size, filename, c.IP()) + maxDownloads := opt.DefaultMaxDownloads + if v := c.Get(opt.HeaderMaxDownload); v != "" { + if n, err := cast.ToIntE(v); err == nil && n >= 0 { + maxDownloads = n + } + } + + expiresIn := int64(opt.DefaultExpiresIn) + if v := c.Get(opt.HeaderExpiresIn); v != "" { + if n, err := cast.ToInt64E(v); err == nil && n >= opt.MinExpiresIn { + expiresIn = n + } + } + + code, err := controller.MetaManager.New(size, filename, c.IP(), maxDownloads, expiresIn) if err != nil { return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": "create upload failed"}) } diff --git a/internal/model/meta.go b/internal/model/meta.go index 6b7a4d4..958cd8f 100644 --- a/internal/model/meta.go +++ b/internal/model/meta.go @@ -1,8 +1,11 @@ package model type Meta struct { - Filename string `json:"filename" mapstructure:"filename"` - CreatedAt int64 `json:"created_at" mapstructure:"created_at"` - Size int64 `json:"size" mapstructure:"size"` - Uploader string `json:"uploader" mapstructure:"uploader"` + Filename string `json:"filename" mapstructure:"filename"` + CreatedAt int64 `json:"created_at" mapstructure:"created_at"` + Size int64 `json:"size" mapstructure:"size"` + Uploader string `json:"uploader" mapstructure:"uploader"` + MaxDownloads int `json:"max_downloads" mapstructure:"max_downloads"` + ExpiresAt int64 `json:"expires_at" mapstructure:"expires_at"` + Downloads int `json:"downloads" mapstructure:"downloads"` } diff --git a/internal/opt/var.go b/internal/opt/var.go index 78a0764..40419f7 100644 --- a/internal/opt/var.go +++ b/internal/opt/var.go @@ -3,9 +3,18 @@ package opt import "path/filepath" const ( - Meta = ".meta." - HeaderSize = "X-File-Size" - CodeLength = 8 + Meta = ".meta." + HeaderSize = "X-File-Size" + HeaderMaxDownload = "X-Max-Downloads" + HeaderExpiresIn = "X-Expires-In" + CodeLength = 8 + + // MinExpiresIn is the minimum allowed expiry in seconds (30s for testing). + MinExpiresIn = 30 + // DefaultExpiresIn is the default expiry in seconds (8 hours). + DefaultExpiresIn = 8 * 3600 + // DefaultMaxDownloads is the default max download count (0 = unlimited). + DefaultMaxDownloads = 3 ) func FilePath(code string) string {