diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f71d174..93e149d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import {FileSharing} from "./page/share.tsx"; export function App() { - return ; + return } \ No newline at end of file diff --git a/frontend/src/component/message/u-message.tsx b/frontend/src/component/message/u-message.tsx index 8a5de2a..0976f67 100644 --- a/frontend/src/component/message/u-message.tsx +++ b/frontend/src/component/message/u-message.tsx @@ -1,84 +1,126 @@ -// MessageContext.tsx -import React, { createContext, useContext, useState, useCallback, useRef } from 'react'; -import ReactDOM from 'react-dom'; -import './Message.css'; +import {createRoot} from "react-dom/client"; +import {useEffect, useState} from "react"; +import {createUseStyles} from "react-jss"; + +const useStyle = createUseStyles({ + container: { + position: 'fixed', + width: '100%', + display: 'flex', + zIndex: 10000, + top: '20px', + justifyContent: "center", + alignItems: "center", + flexDirection: "column", + }, + message: { + width: '100%', + maxWidth: '300px', + background: '#fafafa', // 浅灰背景与其他状态统一 + borderRadius: '8px', + padding: '10px 10px 10px 20px', // 统一左侧20px留白 + + borderLeft: '4px solid #e0e0e0', // 加粗为4px与其他状态一致 + color: '#757575', // 中性灰文字 + + boxShadow: '0 2px 6px rgba(224, 224, 224, 0.15)', // 灰色投影 + transition: 'all 0.3s ease-in-out', // 补全时间单位 + + marginBottom: '20px', + + '&.success': { + color: '#2e7d32', + backgroundColor: '#f0f9eb', + borderLeft: '4px solid #4CAF50', + paddingLeft: '20px', + boxShadow: '0 2px 6px rgba(76, 175, 80, 0.15)' + }, + '&.warning': { + color: '#faad14', // 警告文字色 + backgroundColor: '#fffbe6', // 浅黄色背景 + borderLeft: '4px solid #ffc53d', // 琥珀色左侧标识 + paddingLeft: '20px', + boxShadow: '0 2px 6px rgba(255, 197, 61, 0.15)' // 金色投影 + }, + '&.error': { + color: '#f5222d', // 错误文字色 + backgroundColor: '#fff1f0', // 浅红色背景 + borderLeft: '4px solid #ff4d4f', // 品红色左侧标识 + paddingLeft: '20px', + boxShadow: '0 2px 6px rgba(255, 77, 79, 0.15)' // 红色投影 + } + } +}) + +let el = document.querySelector("#u-message") +if (!el) { + el = document.createElement('div') + el.className = 'u-message' + el.id = 'u-message' + document.body.append(el) +} export interface Message { - id: number; - content: string; - type: 'info' | 'success' | 'warning' | 'error'; + id: number + content: string + duration: number + type: 'info' | 'success' | 'warning' | 'error' } -export type MessageType = Message['type']; - -const MessageContext = createContext<{ - addMessage: (content: string, type?: MessageType, duration?: number) => void; - removeMessage: (id: number) => void; -}>({ - addMessage: () => {}, - removeMessage: () => {}, -}); - -export const MessageProvider: React.FC = ({ children }) => { - const [messages, setMessages] = useState([]); - const timerRef = useRef>({}); - - const addMessage = useCallback((content: string, type: MessageType = 'info', duration: number = 3000) => { - const id = Date.now(); - setMessages(prev => [...prev, { id, content, type }]); - - timerRef.current[id] = setTimeout(() => { - removeMessage(id); - }, duration); - }, []); - - const removeMessage = useCallback((id: number) => { - setMessages(prev => prev.filter(msg => msg.id !== id)); - clearTimeout(timerRef.current[id]); - delete timerRef.current[id]; - }, []); - - return ( - - {children} - {ReactDOM.createPortal( -
- {messages.map(({ id, content, type }) => ( - removeMessage(id)} - /> - ))} -
, - document.body - )} -
- ); -}; - -interface MessageItemProps { - id: number; - content: string; - type: MessageType; - onClose: () => void; +export interface MessageApi { + info: (content: string, duration?: number) => void; + warning: (content: string, duration?: number) => void; + success: (content: string, duration?: number) => void; + error: (content: string, duration?: number) => void; } -const MessageItem: React.FC = ({ id, content, type, onClose }) => { - return ( -
- {content} - -
- ); -}; +const default_duration = 3000 -export const useMessage = (): ReturnType => { - const context = useContext(MessageContext); - if (!context) { - throw new Error('useMessage must be used within a MessageProvider'); + +let add: (msg: Message) => void; + +const MessageContainer: React.FC = () => { + const classes = useStyle() + const [msgs, setMsgs] = useState([]); + + const remove = (id: number) => { + setMsgs(prevMsgs => prevMsgs.filter(v => v.id !== id)); } - return context; -}; \ No newline at end of file + + add = (msg: Message) => { + const id = Date.now(); + setMsgs(prevMsgs => { + const newMsgs = [{...msg, id}, ...prevMsgs]; + // 直接限制数组长度为5,移除最旧的消息(最后一项) + if (newMsgs.length > 5) { + newMsgs.pop(); + } + return newMsgs; + }); + + setTimeout(() => { + remove(id); + }, msg.duration ?? default_duration); + } + + return
+ {msgs.map(m =>
{m.content}
)} +
+} + +createRoot(el).render() + +export const message: MessageApi = { + info: function (content: string, duration?: number): void { + add({content: content, duration: duration, type: "info"} as Message) + }, + warning: function (content: string, duration?: number): void { + add({content: content, duration: duration, type: "warning"} as Message) + }, + success: function (content: string, duration?: number): void { + add({content: content, duration: duration, type: "success"} as Message) + }, + error: function (content: string, duration?: number): void { + add({content: content, duration: duration, type: "error"} as Message) + } +} \ No newline at end of file diff --git a/frontend/src/page/component/panel-left.tsx b/frontend/src/page/component/panel-left.tsx index 74c89ed..8653ae9 100644 --- a/frontend/src/page/component/panel-left.tsx +++ b/frontend/src/page/component/panel-left.tsx @@ -2,6 +2,7 @@ import {createUseStyles} from "react-jss"; import {UButton} from "../../component/button/u-button.tsx"; import React from "react"; import {useStore} from "../../store/share.ts"; +import {message} from "../../component/message/u-message.tsx"; const useStyle = createUseStyles({ container: { @@ -39,6 +40,11 @@ const useStyle = createUseStyles({ '&:hover': {} } }) + +interface RespNew { + code: string +} + export const PanelLeft = () => { const style = useStyle() const {file, setFile} = useStore() @@ -53,8 +59,60 @@ export const PanelLeft = () => { setFile(e.currentTarget.files ? e.currentTarget.files[0] : null) } - function onFileUpload() { - console.log(`[D] onFileUpload: upload file = ${file?.name}, size = ${file?.size}`, file) + async function onFileUpload() { + if (!file) { + return + } + + console.log(`[D] onFileUpload: upload file = ${file.name}, size = ${file.size}`, file) + + let res1 = await fetch(`/api/share/${file.name}`, { + method: "PUT", + headers: {"X-File-Size": file.size.toString()} + }) + let j1 = await res1.json() as RespNew + console.log('[D] onFileUpload: json 1 =', j1) + if (!j1.code) { + return + } + + + // todo: for 循环上传文件分片直到上传完成 + + // 2. 分片上传配置 + const CHUNK_SIZE = 1024 * 1024 // 1MB分片 + const totalChunks = Math.ceil(file.size / CHUNK_SIZE) + const code = j1.code + + // 3. 循环上传所有分片 + for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { + const start = chunkIndex * CHUNK_SIZE + const end = Math.min(start + CHUNK_SIZE, file.size) + const chunk = file.slice(start, end) + + // 4. 构造Range头(bytes=start-end) + const rangeHeader = `bytes=${start}-${end - 1}` // end-1因为Range是闭区间 + + // 5. 上传分片 + const res = await fetch(`/api/share/${code}`, { + method: "POST", + headers: { + "Range": rangeHeader, + "Content-Type": "application/octet-stream" // 二进制流类型 + }, + body: chunk + }) + + if (!res.ok) { + const err = await res.text() + throw new Error(`分片 ${chunkIndex} 上传失败: ${err}`) + } + + console.log(`[D] 分片进度: ${chunkIndex + 1}/${totalChunks}`, + `(${Math.round((chunkIndex + 1)/totalChunks*100)}%)`) + } + + console.log('[D] 所有分片上传完成') } function onFileClean() { diff --git a/frontend/src/page/component/panel-right.tsx b/frontend/src/page/component/panel-right.tsx index b9d350e..47433aa 100644 --- a/frontend/src/page/component/panel-right.tsx +++ b/frontend/src/page/component/panel-right.tsx @@ -41,7 +41,7 @@ export const PanelRight = () => { } async function onFetchFile() { - const url = `/api/share/fetch?code=${code}` + const url = `/api/share/${code}` console.log('[D] onFetchFile: url =', url) const link = document.createElement('a'); link.href = url; diff --git a/go.mod b/go.mod index ac3ed9b..56935b0 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/matoous/go-nanoid/v2 v2.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect diff --git a/go.sum b/go.sum index 75c8590..679111f 100644 --- a/go.sum +++ b/go.sum @@ -59,6 +59,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/loveuer/nf v0.3.5 h1:DlgTa6Rx8D3/VtH9e0fLGAxmPwSYd7b3nfBltSMuypE= github.com/loveuer/nf v0.3.5/go.mod h1:IAq0K1c/mlNQzLBvUzAD1LCWiVlt2GqTMPdDjej3Ryo= +github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE= +github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= diff --git a/internal/api/api.go b/internal/api/api.go index b5b6b4b..5d6d585 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -18,7 +18,9 @@ func Start(ctx context.Context) <-chan struct{} { return c.SendStatus(http.StatusOK) }) - app.Get("/api/share/fetch", handler.Fetch()) + app.Get("/api/share/:code", handler.Fetch()) + app.Put("/api/share/:filename", handler.ShareNew()) // 获取上传 code, 分片大小 + app.Post("/api/share/:code", handler.ShareUpload()) // 分片上传接口 ready := make(chan struct{}) ln, err := net.Listen("tcp", opt.Cfg.Address) diff --git a/internal/controller/meta.go b/internal/controller/meta.go new file mode 100644 index 0000000..8f3a70b --- /dev/null +++ b/internal/controller/meta.go @@ -0,0 +1,122 @@ +package controller + +import ( + "context" + "fmt" + "github.com/loveuer/nf/nft/log" + "github.com/loveuer/ushare/internal/opt" + gonanoid "github.com/matoous/go-nanoid/v2" + "io" + "os" + "sync" + "time" +) + +type metaInfo struct { + f *os.File + name string + create time.Time + last time.Time + size int64 + cursor int64 + ip string +} + +func (m *metaInfo) generateMeta(code string) error { + content := fmt.Sprintf("filename=%s\ncreated_at=%d\nsize=%d\nuploader_ip=%s", + m.name, m.create.UnixMilli(), m.size, m.ip, + ) + + return os.WriteFile(opt.MetaPath(code), []byte(content), 0644) +} + +type meta struct { + sync.Mutex + ctx context.Context + m map[string]*metaInfo +} + +var ( + MetaManager = &meta{m: make(map[string]*metaInfo)} +) + +const letters = "1234567890abcdefghijklmnopqrstuvwxyz" + +func (m *meta) New(size int64, filename, ip string) (string, error) { + now := time.Now() + code, err := gonanoid.Generate(letters, 16) + if err != nil { + return "", err + } + + f, err := os.Create(opt.FilePath(code)) + if err != nil { + return "", err + } + + if err = f.Truncate(size); err != nil { + f.Close() + return "", err + } + + m.Lock() + defer m.Unlock() + + m.m[code] = &metaInfo{f: f, name: filename, last: now, size: size, cursor: 0, create: now, ip: ip} + + return code, nil +} + +func (m *meta) Write(code string, start, end int64, reader io.Reader) (total, cursor int64, err error) { + m.Lock() + defer m.Unlock() + + if _, ok := m.m[code]; !ok { + return 0, 0, fmt.Errorf("code not exist") + } + + w, err := io.CopyN(m.m[code].f, reader, end-start+1) + if err != nil { + return 0, 0, err + } + + m.m[code].cursor += w + m.m[code].last = time.Now() + + total = m.m[code].size + cursor = m.m[code].cursor + + if m.m[code].cursor == m.m[code].size { + defer delete(m.m, code) + if err = m.m[code].generateMeta(code); err != nil { + return 0, 0, err + } + } + + return total, cursor, nil +} + +func (m *meta) Start(ctx context.Context) { + ticker := time.NewTicker(time.Minute) + m.ctx = ctx + + go func() { + for { + select { + case <-ctx.Done(): + return + case now := <-ticker.C: + for code, info := range m.m { + if now.Sub(info.last) > 1*time.Minute { + m.Lock() + info.f.Close() + os.RemoveAll(opt.FilePath(code)) + delete(m.m, code) + m.Unlock() + log.Warn("MetaController: code timeout removed, code = %s", code) + } + } + } + } + }() +} diff --git a/internal/handler/share.go b/internal/handler/share.go index b814116..979297c 100644 --- a/internal/handler/share.go +++ b/internal/handler/share.go @@ -3,17 +3,23 @@ package handler import ( "fmt" "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" "net/http" "os" + "regexp" + "strings" ) func Fetch() nf.HandlerFunc { return func(c *nf.Ctx) error { - code := c.Query("code") + code := c.Param("code") + log.Debug("handler.Fetch: code = %s", code) info := new(model.Meta) _, err := os.Stat(opt.MetaPath(code)) if err != nil { @@ -40,3 +46,74 @@ func Fetch() nf.HandlerFunc { return nil } } + +func ShareNew() nf.HandlerFunc { + return func(c *nf.Ctx) error { + + filename := strings.TrimSpace(c.Param("filename")) + if filename == "" { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "filename required"}) + } + + size, err := cast.ToInt64E(c.Get(opt.HeaderSize)) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "miss header: " + opt.HeaderSize}) + } + + code, err := controller.MetaManager.New(size, filename, c.IP()) + if err != nil { + return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": ""}) + } + + return c.Status(http.StatusOK).JSON(map[string]string{"code": code}) + } +} + +func ShareUpload() nf.HandlerFunc { + rangeValidator := regexp.MustCompile(`^bytes=\d+-\d+$`) + return func(c *nf.Ctx) error { + code := strings.TrimSpace(c.Param("code")) + if len(code) != 16 { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "invalid file code"}) + } + + log.Debug("handler.ShareUpload: code = %s", code) + + ranger := strings.TrimSpace(c.Get("Range")) + if ranger == "" { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "miss header: Range"}) + } + + log.Debug("handler.ShareUpload: code = %s, ranger = %s", code, ranger) + + if !rangeValidator.MatchString(ranger) { + log.Warn("handler.ShareUpload: invalid range, ranger = %s", ranger) + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "Range invalid(1)"}) + } + + strs := strings.Split(strings.TrimPrefix(ranger, "bytes="), "-") + if len(strs) != 2 { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "Range invalid(2)"}) + } + + start, err := cast.ToInt64E(strs[0]) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "Range invalid(3)"}) + } + + end, err := cast.ToInt64E(strs[1]) + if err != nil { + return c.Status(http.StatusBadRequest).JSON(map[string]string{"msg": "Range invalid(4)"}) + } + + log.Debug("handler.ShareUpload: code = %s, start = %d, end = %d", code, start, end) + + total, cursor, err := controller.MetaManager.Write(code, start, end, c.Request.Body) + if err != nil { + log.Error("handler.ShareUpload: write error: %s", err) + return c.Status(http.StatusInternalServerError).JSON(map[string]string{"msg": ""}) + } + + return c.Status(http.StatusOK).JSON(map[string]any{"size": total, "cursor": cursor}) + } +} diff --git a/internal/opt/var.go b/internal/opt/var.go index 2181a7e..eb2706e 100644 --- a/internal/opt/var.go +++ b/internal/opt/var.go @@ -3,7 +3,8 @@ package opt import "path/filepath" const ( - Meta = ".meta." + Meta = ".meta." + HeaderSize = "X-File-Size" ) func FilePath(code string) string { diff --git a/main.go b/main.go index 1fd82a6..fa0c325 100644 --- a/main.go +++ b/main.go @@ -3,7 +3,9 @@ package main import ( "context" "flag" + "github.com/loveuer/nf/nft/log" "github.com/loveuer/ushare/internal/api" + "github.com/loveuer/ushare/internal/controller" "github.com/loveuer/ushare/internal/opt" "os/signal" "syscall" @@ -14,12 +16,17 @@ func init() { flag.StringVar(&opt.Cfg.Address, "address", "0.0.0.0:80", "") flag.StringVar(&opt.Cfg.DataPath, "data", "/data", "") flag.Parse() + + if opt.Cfg.Debug { + log.SetLogLevel(log.LogLevelDebug) + } } func main() { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() + controller.MetaManager.Start(ctx) api.Start(ctx) <-ctx.Done()