Compare commits

...

6 Commits

Author SHA1 Message Date
b8acd97c11 improve tag creating 2025-09-05 16:22:43 +08:00
8f240823ef enhance GetResourceByID to preload specific fields for Tags 2025-09-05 16:16:38 +08:00
a33171fb20 improve layout 2025-09-05 14:55:27 +08:00
993e7f488d fix 2025-09-05 14:36:01 +08:00
ebfe25e6d8 Show file hash 2025-09-05 14:23:08 +08:00
f0079003f2 add hash field to File model and update file creation functions to support hash 2025-09-05 14:06:51 +08:00
10 changed files with 75 additions and 26 deletions

View File

@@ -4,14 +4,16 @@ export default function Badge({
children, children,
className, className,
onClick, onClick,
selectable = false,
}: { }: {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
onClick?: () => void; onClick?: () => void;
selectable?: boolean;
}) { }) {
return ( return (
<span <span
className={`badge ${!className?.includes("badge-") && "badge-primary"} select-none ${className}`} className={`badge ${!className?.includes("badge-") && "badge-primary"} ${className} ${!selectable && "select-none"}`}
onClick={onClick} onClick={onClick}
> >
{children} {children}

View File

@@ -203,7 +203,11 @@ export function QuickAddTagDialog({
return; return;
} }
setError(null); setError(null);
const names = text.split(separator).filter((n) => n.length > 0); let sep: string | RegExp = separator;
if (sep === " ") {
sep = /\s+/;
}
const names = text.split(sep).filter((n) => n.length > 0);
setLoading(true); setLoading(true);
const res = await network.getOrCreateTags(names, type); const res = await network.getOrCreateTags(names, type);
setLoading(false); setLoading(false);

View File

@@ -1,6 +1,10 @@
@import "tailwindcss"; @import "tailwindcss";
@plugin "daisyui"; @plugin "daisyui";
@theme {
--breakpoint-xs: 30rem;
}
/* Pink Theme */ /* Pink Theme */
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
name: "pink"; name: "pink";

View File

@@ -104,6 +104,7 @@ export interface RFile {
is_redirect: boolean; is_redirect: boolean;
user: User; user: User;
resource?: Resource; resource?: Resource;
hash?: string;
} }
export interface UploadingFile { export interface UploadingFile {

View File

@@ -37,6 +37,7 @@ import {
MdOutlineFolderSpecial, MdOutlineFolderSpecial,
MdOutlineLink, MdOutlineLink,
MdOutlineOpenInNew, MdOutlineOpenInNew,
MdOutlineVerifiedUser,
} from "react-icons/md"; } from "react-icons/md";
import { app } from "../app.ts"; import { app } from "../app.ts";
import { uploadingManager } from "../network/uploading.ts"; import { uploadingManager } from "../network/uploading.ts";
@@ -693,8 +694,8 @@ function FileTile({ file }: { file: RFile }) {
const navigate = useNavigate(); const navigate = useNavigate();
return ( return (
<div className={"card shadow bg-base-100 mb-4"}> <div className={"card shadow bg-base-100 mb-4 p-4"}>
<div className={"p-4 flex flex-row items-center"}> <div className={"flex flex-row items-center"}>
<div className={"grow"}> <div className={"grow"}>
<h4 className={"font-bold break-all"}>{file.filename}</h4> <h4 className={"font-bold break-all"}>{file.filename}</h4>
<div className={"text-sm my-1 comment_tile"}> <div className={"text-sm my-1 comment_tile"}>
@@ -725,11 +726,17 @@ function FileTile({ file }: { file: RFile }) {
<MdOutlineArchive size={16} className={"inline-block"} /> <MdOutlineArchive size={16} className={"inline-block"} />
{file.is_redirect ? t("Redirect") : fileSizeToString(file.size)} {file.is_redirect ? t("Redirect") : fileSizeToString(file.size)}
</Badge> </Badge>
{
file.hash && <Badge className={"badge-soft badge-accent text-xs mr-2 break-all hidden sm:inline-flex"} selectable={true}>
<MdOutlineVerifiedUser size={16} className={"inline-block"} />
Md5: {file.hash}
</Badge>
}
<DeleteFileDialog fileId={file.id} uploaderId={file.user.id} /> <DeleteFileDialog fileId={file.id} uploaderId={file.user.id} />
<UpdateFileInfoDialog file={file} /> <UpdateFileInfoDialog file={file} />
</p> </p>
</div> </div>
<div className={"flex flex-row items-center"}> <div className={`flex-row items-center hidden xs:flex`}>
{file.size > 10 * 1024 * 1024 ? ( {file.size > 10 * 1024 * 1024 ? (
<button <button
ref={buttonRef} ref={buttonRef}
@@ -759,6 +766,11 @@ function FileTile({ file }: { file: RFile }) {
)} )}
</div> </div>
</div> </div>
<div className="flex flex-row-reverse xs:hidden p-2">
<button className={"btn btn-primary btn-soft btn-sm"}>
<MdOutlineDownload size={20} />
</button>
</div>
</div> </div>
); );
} }

View File

@@ -1,12 +1,17 @@
package api package api
import ( import (
"github.com/gofiber/fiber/v3"
"net/url" "net/url"
"nysoure/server/model" "nysoure/server/model"
"nysoure/server/service" "nysoure/server/service"
"strconv" "strconv"
"strings" "strings"
"github.com/gofiber/fiber/v3"
)
const (
maxTagNameLength = 20
) )
func handleCreateTag(c fiber.Ctx) error { func handleCreateTag(c fiber.Ctx) error {
@@ -15,6 +20,9 @@ func handleCreateTag(c fiber.Ctx) error {
return model.NewRequestError("name is required") return model.NewRequestError("name is required")
} }
tag = strings.TrimSpace(tag) tag = strings.TrimSpace(tag)
if len([]rune(tag)) > maxTagNameLength {
return model.NewRequestError("Tag name too long")
}
uid, ok := c.Locals("uid").(uint) uid, ok := c.Locals("uid").(uint)
if !ok { if !ok {
return model.NewUnAuthorizedError("You must be logged in to create a tag") return model.NewUnAuthorizedError("You must be logged in to create a tag")
@@ -159,6 +167,9 @@ func getOrCreateTags(c fiber.Ctx) error {
if name == "" { if name == "" {
continue continue
} }
if len([]rune(name)) > maxTagNameLength {
return model.NewRequestError("Tag name too long: " + name)
}
names = append(names, name) names = append(names, name)
} }

View File

@@ -73,7 +73,7 @@ func GetUploadingFilesOlderThan(time time.Time) ([]model.UploadingFile, error) {
return files, nil return files, nil
} }
func CreateFile(filename string, description string, resourceID uint, storageID *uint, storageKey string, redirectUrl string, size int64, userID uint) (*model.File, error) { func CreateFile(filename string, description string, resourceID uint, storageID *uint, storageKey string, redirectUrl string, size int64, userID uint, hash string) (*model.File, error) {
if storageID == nil && redirectUrl == "" { if storageID == nil && redirectUrl == "" {
return nil, errors.New("storageID and redirectUrl cannot be both empty") return nil, errors.New("storageID and redirectUrl cannot be both empty")
} }
@@ -88,6 +88,7 @@ func CreateFile(filename string, description string, resourceID uint, storageID
StorageKey: storageKey, StorageKey: storageKey,
Size: size, Size: size,
UserID: userID, UserID: userID,
Hash: hash,
} }
err := db.Transaction(func(tx *gorm.DB) error { err := db.Transaction(func(tx *gorm.DB) error {
@@ -200,13 +201,14 @@ func SetFileStorageKey(id string, storageKey string) error {
return nil return nil
} }
func SetFileStorageKeyAndSize(id string, storageKey string, size int64) error { func SetFileStorageKeyAndSize(id string, storageKey string, size int64, hash string) error {
f := &model.File{} f := &model.File{}
if err := db.Where("uuid = ?", id).First(f).Error; err != nil { if err := db.Where("uuid = ?", id).First(f).Error; err != nil {
return err return err
} }
f.StorageKey = storageKey f.StorageKey = storageKey
f.Size = size f.Size = size
f.Hash = hash
if err := db.Save(f).Error; err != nil { if err := db.Save(f).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return model.NewNotFoundError("file not found") return model.NewNotFoundError("file not found")

View File

@@ -36,7 +36,9 @@ func GetResourceByID(id uint) (model.Resource, error) {
var r model.Resource var r model.Resource
if err := db.Preload("User"). if err := db.Preload("User").
Preload("Images"). Preload("Images").
Preload("Tags"). Preload("Tags", func(db *gorm.DB) *gorm.DB {
return db.Select("id", "name", "type", "alias_of")
}).
Preload("Files"). Preload("Files").
Preload("Files.User"). Preload("Files.User").
First(&r, id).Error; err != nil { First(&r, id).Error; err != nil {

View File

@@ -18,6 +18,7 @@ type File struct {
UserID uint UserID uint
User User `gorm:"foreignKey:UserID"` User User `gorm:"foreignKey:UserID"`
Size int64 Size int64
Hash string `gorm:"default:null"`
} }
type FileView struct { type FileView struct {
@@ -28,6 +29,7 @@ type FileView struct {
IsRedirect bool `json:"is_redirect"` IsRedirect bool `json:"is_redirect"`
User UserView `json:"user"` User UserView `json:"user"`
Resource *ResourceView `json:"resource,omitempty"` Resource *ResourceView `json:"resource,omitempty"`
Hash string `json:"hash,omitempty"`
} }
func (f *File) ToView() *FileView { func (f *File) ToView() *FileView {
@@ -38,6 +40,7 @@ func (f *File) ToView() *FileView {
Size: f.Size, Size: f.Size,
IsRedirect: f.RedirectUrl != "", IsRedirect: f.RedirectUrl != "",
User: f.User.ToView(), User: f.User.ToView(),
Hash: f.Hash,
} }
} }
@@ -56,5 +59,6 @@ func (f *File) ToViewWithResource() *FileView {
IsRedirect: f.RedirectUrl != "", IsRedirect: f.RedirectUrl != "",
User: f.User.ToView(), User: f.User.ToView(),
Resource: resource, Resource: resource,
Hash: f.Hash,
} }
} }

View File

@@ -236,7 +236,7 @@ func FinishUploadingFile(uid uint, fid uint, md5Str string) (*model.FileView, er
return nil, model.NewInternalServerError("failed to finish uploading file. please re-upload") return nil, model.NewInternalServerError("failed to finish uploading file. please re-upload")
} }
dbFile, err := dao.CreateFile(uploadingFile.Filename, uploadingFile.Description, uploadingFile.TargetResourceID, &uploadingFile.TargetStorageID, storageKeyUnavailable, "", uploadingFile.TotalSize, uid) dbFile, err := dao.CreateFile(uploadingFile.Filename, uploadingFile.Description, uploadingFile.TargetResourceID, &uploadingFile.TargetStorageID, storageKeyUnavailable, "", uploadingFile.TotalSize, uid, sumStr)
if err != nil { if err != nil {
log.Error("failed to create file in db: ", err) log.Error("failed to create file in db: ", err)
_ = os.Remove(resultFilePath) _ = os.Remove(resultFilePath)
@@ -310,7 +310,7 @@ func CreateRedirectFile(uid uint, filename string, description string, resourceI
return nil, model.NewUnAuthorizedError("user cannot upload file") return nil, model.NewUnAuthorizedError("user cannot upload file")
} }
file, err := dao.CreateFile(filename, description, resourceID, nil, "", redirectUrl, 0, uid) file, err := dao.CreateFile(filename, description, resourceID, nil, "", redirectUrl, 0, uid, "")
if err != nil { if err != nil {
log.Error("failed to create file in db: ", err) log.Error("failed to create file in db: ", err)
return nil, model.NewInternalServerError("failed to create file in db") return nil, model.NewInternalServerError("failed to create file in db")
@@ -496,57 +496,61 @@ func testFileUrl(url string) (int64, error) {
} }
// downloadFile return nil if the download is successful or the context is cancelled // downloadFile return nil if the download is successful or the context is cancelled
func downloadFile(ctx context.Context, url string, path string) error { func downloadFile(ctx context.Context, url string, path string) (string, error) {
if _, err := os.Stat(path); err == nil { if _, err := os.Stat(path); err == nil {
_ = os.Remove(path) // Remove the file if it already exists _ = os.Remove(path) // Remove the file if it already exists
} }
req, err := http.NewRequestWithContext(ctx, "GET", url, nil) req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { if err != nil {
return model.NewRequestError("failed to create HTTP request") return "", model.NewRequestError("failed to create HTTP request")
} }
client := http.Client{} client := http.Client{}
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
// Check if the error is due to context cancellation // Check if the error is due to context cancellation
if ctx.Err() != nil { if ctx.Err() != nil {
return nil return "", nil
} }
return model.NewRequestError("failed to send HTTP request") return "", model.NewRequestError("failed to send HTTP request")
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return model.NewRequestError("URL is not accessible, status code: " + resp.Status) return "", model.NewRequestError("URL is not accessible, status code: " + resp.Status)
} }
file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, os.ModePerm) file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, os.ModePerm)
if err != nil { if err != nil {
return model.NewInternalServerError("failed to open file for writing") return "", model.NewInternalServerError("failed to open file for writing")
} }
defer file.Close() defer file.Close()
writer := bufio.NewWriter(file) writer := bufio.NewWriter(file)
h := md5.New()
buf := make([]byte, 64*1024) buf := make([]byte, 64*1024)
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return nil return "", nil
default: default:
n, readErr := resp.Body.Read(buf) n, readErr := resp.Body.Read(buf)
if n > 0 { if n > 0 {
if _, writeErr := writer.Write(buf[:n]); writeErr != nil { if _, writeErr := writer.Write(buf[:n]); writeErr != nil {
return model.NewInternalServerError("failed to write to file") return "", model.NewInternalServerError("failed to write to file")
} }
h.Write(buf[:n])
} }
if readErr != nil { if readErr != nil {
if readErr == io.EOF { if readErr == io.EOF {
if err := writer.Flush(); err != nil { if err := writer.Flush(); err != nil {
return model.NewInternalServerError("failed to flush writer") return "", model.NewInternalServerError("failed to flush writer")
} }
return nil // Download completed successfully md5Sum := hex.EncodeToString(h.Sum(nil))
return md5Sum, nil // Download completed successfully
} }
if ctx.Err() != nil { if ctx.Err() != nil {
return nil // Context cancelled, return nil return "", nil // Context cancelled, return nil
} }
return model.NewInternalServerError("failed to read response body") return "", model.NewInternalServerError("failed to read response body")
} }
} }
} }
@@ -573,7 +577,7 @@ func CreateServerDownloadTask(uid uint, url, filename, description string, resou
return nil, model.NewRequestError("server is busy, please try again later") return nil, model.NewRequestError("server is busy, please try again later")
} }
file, err := dao.CreateFile(filename, description, resourceID, &storageID, storageKeyUnavailable, "", 0, uid) file, err := dao.CreateFile(filename, description, resourceID, &storageID, storageKeyUnavailable, "", 0, uid, "")
if err != nil { if err != nil {
log.Error("failed to create file in db: ", err) log.Error("failed to create file in db: ", err)
return nil, model.NewInternalServerError("failed to create file in db") return nil, model.NewInternalServerError("failed to create file in db")
@@ -624,11 +628,14 @@ func CreateServerDownloadTask(uid uint, url, filename, description string, resou
} }
}() }()
hash := ""
for i := range 3 { for i := range 3 {
if done.Load() { if done.Load() {
return return
} }
if err := downloadFile(ctx, url, tempPath); err != nil { hash, err = downloadFile(ctx, url, tempPath)
if err != nil {
log.Error("failed to download file: ", err) log.Error("failed to download file: ", err)
if i == 2 { if i == 2 {
_ = dao.DeleteFile(file.UUID) _ = dao.DeleteFile(file.UUID)
@@ -689,7 +696,7 @@ func CreateServerDownloadTask(uid uint, url, filename, description string, resou
_ = os.Remove(tempPath) _ = os.Remove(tempPath)
return return
} }
if err := dao.SetFileStorageKeyAndSize(file.UUID, storageKey, size); err != nil { if err := dao.SetFileStorageKeyAndSize(file.UUID, storageKey, size, hash); err != nil {
log.Error("failed to set file storage key: ", err) log.Error("failed to set file storage key: ", err)
_ = dao.DeleteFile(file.UUID) _ = dao.DeleteFile(file.UUID)
_ = iStorage.Delete(storageKey) _ = iStorage.Delete(storageKey)