1 Commits

Author SHA1 Message Date
loveuer
a2589ee4b3 feat: add download limit and expiry control per upload (v0.7.0)
Some checks failed
Release Binaries / Build and Release (.exe, amd64, windows, windows-amd64) (push) Has been cancelled
Release Binaries / Build and Release (amd64, darwin, darwin-amd64) (push) Has been cancelled
Release Binaries / Build and Release (amd64, linux, linux-amd64) (push) Has been cancelled
Release Binaries / Build and Release (arm64, darwin, darwin-arm64) (push) Has been cancelled
Release Binaries / Build and Release (arm64, linux, linux-arm64) (push) Has been cancelled
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]
2026-03-02 01:49:37 -08:00
6 changed files with 353 additions and 108 deletions

View File

@@ -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<string | null>(null);
const uploadFile = async (file: File): Promise<string> => {
const uploadFile = async (file: File, settings?: UploadSettings): Promise<string> => {
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,15 +74,13 @@ 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);
}

View File

@@ -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<UploadSettings>({maxDownloads: 3, expiresIn: 8 * 3600})
if (code) {
return <PanelLeftShow code={code} set_code={set_code} />
return <PanelLeftShow code={code} set_code={setCode} settings={settings}/>
}
return <PanelLeftUpload set_code={set_code}/>
return <PanelLeftUpload set_code={setCode} settings={settings} setSettings={setSettings}/>
};
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<HTMLInputElement>) {
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<HTMLSelectElement>) {
setSettings({...settings, expiresIn: parseInt(e.target.value, 10)});
}
return <div className={style.container}>
<div className={style.form}>
<h2 className={style.title}></h2>
{
!file && !loading &&
<UButton onClick={onFileSelect}></UButton>
}
{
file && !loading &&
<UButton onClick={onFileUpload}></UButton>
}
{
loading &&
<UButton process={progress} loading={loading}></UButton>
}
{!file && !loading && <UButton onClick={onFileSelect}></UButton>}
{file && !loading && <UButton onClick={onFileUpload}></UButton>}
{loading && <UButton process={progress} loading={loading}></UButton>}
<input type="file" className={style.file} id="real-file-input" onChange={onFileChange}/>
{
file &&
{file && (
<div className={style.preview}>
<div className={style.clean} onClick={onFileClean}>×</div>
<div className={style.name}>{file.name}</div>
</div>
}
)}
{/* Advanced settings toggle */}
<div className={style.advToggle} onClick={() => setShowAdv(v => !v)}>
<span>{showAdv ? '▾' : '▸'}</span>
<span></span>
</div>
{showAdv && (
<div className={style.advPanel}>
<div className={style.advRow}>
<span className={style.advLabel}></span>
<div style={{display: 'flex', alignItems: 'center', gap: '8px'}}>
<input
type="number"
min={0}
max={999}
className={style.advInput}
value={settings.maxDownloads}
onChange={onMaxDownloadsChange}
/>
<span className={style.advHint}>0 = </span>
</div>
</div>
<div className={style.advRow}>
<span className={style.advLabel}></span>
<select
className={style.advSelect}
value={settings.expiresIn}
onChange={onExpiryChange}
>
{EXPIRY_OPTIONS.map(h => (
<option key={h} value={h * 3600}>{h} </option>
))}
</select>
</div>
</div>
)}
</div>
</div>
}
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 (
<div className={classes.container}>
<div className={classes.form}>
@@ -248,21 +372,22 @@ const PanelLeftShow: React.FC<{ code: string; set_code: (code: string) => void }
className={classes.closeButton}
onClick={() => set_code('')}
aria-label="关闭"
>
×
</button>
<h2 className={classes.title}>
!
</h2>
>×</button>
<h2 className={classes.title}>!</h2>
<div className={classes.codeWrapper}>
<pre className={classes.pre}>
<code>{code}</code>
<button className={classes.copyButton} onClick={handleCopy}>
</button>
</button>
</pre>
</div>
<div className={classes.metaInfo}>
<span className={classes.metaItem}>{downloadLimit}</span>
<span className={classes.metaItem}>{expiryHours} </span>
</div>
</div>
</div>
);

View File

@@ -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

View File

@@ -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"})
}

View File

@@ -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"`
}

View File

@@ -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 {