Allow normal user to upload.

This commit is contained in:
2025-07-10 15:10:56 +08:00
parent 747f76991d
commit dd5e5193da
14 changed files with 216 additions and 17 deletions

View File

@@ -36,6 +36,8 @@
window.serverName = "{{SiteName}}"; window.serverName = "{{SiteName}}";
window.cloudflareTurnstileSiteKey = "{{CFTurnstileSiteKey}}"; window.cloudflareTurnstileSiteKey = "{{CFTurnstileSiteKey}}";
window.siteInfo = `{{SiteInfo}}`; window.siteInfo = `{{SiteInfo}}`;
window.uploadPrompt = `{{UploadPrompt}}`;
window.allowNormalUserUpload = `{{AllowNormalUserUpload}}`;
</script> </script>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>

View File

@@ -4,6 +4,8 @@ interface MyWindow extends Window {
serverName?: string; serverName?: string;
cloudflareTurnstileSiteKey?: string; cloudflareTurnstileSiteKey?: string;
siteInfo?: string; siteInfo?: string;
uploadPrompt?: string;
allowNormalUserUpload?: string;
} }
class App { class App {
@@ -17,6 +19,10 @@ class App {
siteInfo = ""; siteInfo = "";
uploadPrompt = "";
allowNormalUserUpload = true;
constructor() { constructor() {
this.init(); this.init();
} }
@@ -37,6 +43,8 @@ class App {
this.cloudflareTurnstileSiteKey = null; // Placeholder value, set to null if not configured this.cloudflareTurnstileSiteKey = null; // Placeholder value, set to null if not configured
} }
this.siteInfo = (window as MyWindow).siteInfo || ""; this.siteInfo = (window as MyWindow).siteInfo || "";
this.uploadPrompt = (window as MyWindow).uploadPrompt || "";
// this.allowNormalUserUpload = (window as MyWindow).allowNormalUserUpload === "true";
} }
saveData() { saveData() {

View File

@@ -93,6 +93,7 @@ export interface Storage {
maxSize: number; maxSize: number;
currentSize: number; currentSize: number;
createdAt: string; createdAt: string;
isDefault: boolean;
} }
export interface RFile { export interface RFile {
@@ -157,6 +158,9 @@ export interface ServerConfig {
server_name: string; server_name: string;
server_description: string; server_description: string;
site_info: string; site_info: string;
allow_normal_user_upload: boolean;
max_normal_user_upload_size_in_mb: number;
upload_prompt: string;
} }
export enum RSort { export enum RSort {

View File

@@ -460,6 +460,12 @@ class Network {
); );
} }
async setDefaultStorage(id: number): Promise<Response<Storage>> {
return this._callApi(() =>
axios.put(`${this.apiBaseUrl}/storage/${id}/default`),
);
}
async initFileUpload( async initFileUpload(
filename: string, filename: string,
description: string, description: string,

View File

@@ -108,7 +108,7 @@ export default function ManageServerConfigPage() {
}} }}
></Input> ></Input>
<fieldset className="fieldset w-full"> <fieldset className="fieldset w-full">
<legend className="fieldset-legend">Allow register</legend> <legend className="fieldset-legend">Allow registration</legend>
<input <input
type="checkbox" type="checkbox"
checked={config.allow_register} checked={config.allow_register}
@@ -164,6 +164,36 @@ export default function ManageServerConfigPage() {
label="Site info (Markdown)" label="Site info (Markdown)"
height={180} height={180}
/> />
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">Allow normal user upload</legend>
<input
type="checkbox"
checked={config.allow_normal_user_upload}
className="toggle-primary toggle"
onChange={(e) => {
setConfig({ ...config, allow_normal_user_upload: e.target.checked });
}}
/>
</fieldset>
<Input
type="number"
value={config.max_normal_user_upload_size_in_mb.toString()}
label="Max normal user upload size (MB)"
onChange={(e) => {
setConfig({
...config,
max_normal_user_upload_size_in_mb: parseInt(e.target.value),
});
}}
></Input>
<Input
type="text"
value={config.upload_prompt}
label="Upload prompt"
onChange={(e) => {
setConfig({ ...config, upload_prompt: e.target.value });
}}
></Input>
<InfoAlert <InfoAlert
className="my-2" className="my-2"
message="If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download." message="If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download."

View File

@@ -3,10 +3,12 @@ import { Storage } from "../network/models.ts";
import { network } from "../network/network.ts"; import { network } from "../network/network.ts";
import showToast from "../components/toast.ts"; import showToast from "../components/toast.ts";
import Loading from "../components/loading.tsx"; import Loading from "../components/loading.tsx";
import { MdAdd, MdDelete } from "react-icons/md"; import { MdAdd, MdMoreHoriz } from "react-icons/md";
import { ErrorAlert } from "../components/alert.tsx"; import { ErrorAlert } from "../components/alert.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { app } from "../app.ts"; import { app } from "../app.ts";
import showPopup, { PopupMenuItem } from "../components/popup.tsx";
import Badge from "../components/badge.tsx";
export default function StorageView() { export default function StorageView() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -84,6 +86,26 @@ export default function StorageView() {
setLoadingId(null); setLoadingId(null);
}; };
const handleSetDefault = async (id: number) => {
if (loadingId != null) {
return;
}
setLoadingId(id);
const response = await network.setDefaultStorage(id);
if (response.success) {
showToast({
message: t("Storage set as default successfully"),
});
updateStorages();
} else {
showToast({
message: response.message,
type: "error",
});
}
setLoadingId(null);
};
return ( return (
<> <>
<div <div
@@ -121,7 +143,10 @@ export default function StorageView() {
{storages.map((s) => { {storages.map((s) => {
return ( return (
<tr key={s.id} className={"hover"}> <tr key={s.id} className={"hover"}>
<td>{s.name}</td> <td>
{s.name}
{s.isDefault && <Badge className={"ml-1"}>{t("Default")}</Badge>}
</td>
<td>{new Date(s.createdAt).toLocaleString()}</td> <td>{new Date(s.createdAt).toLocaleString()}</td>
<td> <td>
{(s.currentSize / 1024 / 1024).toFixed(2)} /{" "} {(s.currentSize / 1024 / 1024).toFixed(2)} /{" "}
@@ -129,21 +154,47 @@ export default function StorageView() {
</td> </td>
<td> <td>
<button <button
id={`set_default_button_${s.id}`}
className={"btn btn-square"} className={"btn btn-square"}
type={"button"} type={"button"}
onClick={() => {
showPopup(
<ul className="menu bg-base-100 rounded-box z-1 w-64 p-2 shadow-sm">
<h4 className="text-sm font-bold px-3 py-1 text-primary">
{t("Actions")}
</h4>
<PopupMenuItem
onClick={() => { onClick={() => {
const dialog = document.getElementById( const dialog = document.getElementById(
`confirm_delete_dialog_${s.id}`, `confirm_delete_dialog_${s.id}`,
) as HTMLDialogElement; ) as HTMLDialogElement;
dialog.showModal(); dialog.showModal();
}} }}
>
<a>{t("Delete")}</a>
</PopupMenuItem>
{!s.isDefault && <PopupMenuItem
onClick={() => {
handleSetDefault(s.id);
}}
>
<a>
t("Set as Default")
</a>
</PopupMenuItem>}
</ul>,
document.getElementById(
`set_default_button_${s.id}`,
)!,
);
}}
> >
{loadingId === s.id ? ( {loadingId === s.id ? (
<span <span
className={"loading loading-spinner loading-sm"} className={"loading loading-spinner loading-sm"}
></span> ></span>
) : ( ) : (
<MdDelete size={24} /> <MdMoreHoriz size={24} />
)} )}
</button> </button>
<dialog <dialog

View File

@@ -404,7 +404,6 @@ function Article({ resource }: { resource: ResourceDetails }) {
<Markdown <Markdown
components={{ components={{
p: ({ node, ...props }) => { p: ({ node, ...props }) => {
console.log(props.children);
if ( if (
typeof props.children === "object" && typeof props.children === "object" &&
(props.children as ReactElement).type === "strong" (props.children as ReactElement).type === "strong"
@@ -722,7 +721,7 @@ function Files({ files, resourceID }: { files: RFile[]; resourceID: number }) {
return <FileTile file={file} key={file.id}></FileTile>; return <FileTile file={file} key={file.id}></FileTile>;
})} })}
<div className={"h-2"}></div> <div className={"h-2"}></div>
{app.canUpload() && ( {app.canUpload() || app.allowNormalUserUpload && (
<div className={"flex flex-row-reverse"}> <div className={"flex flex-row-reverse"}>
<CreateFileDialog resourceId={resourceID}></CreateFileDialog> <CreateFileDialog resourceId={resourceID}></CreateFileDialog>
</div> </div>
@@ -876,6 +875,12 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
showToast({ message: res.message, type: "error" }); showToast({ message: res.message, type: "error" });
} else { } else {
storages.current = res.data!; storages.current = res.data!;
let defaultStorage = storages.current.find((s) => s.isDefault);
if (!defaultStorage && storages.current.length > 0) {
defaultStorage = storages.current[0];
}
console.log("defaultStorage", defaultStorage);
setStorage(defaultStorage || null);
setLoading(false); setLoading(false);
const dialog = document.getElementById( const dialog = document.getElementById(
"upload_dialog", "upload_dialog",
@@ -902,6 +907,10 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
<div className="modal-box"> <div className="modal-box">
<h3 className="font-bold text-lg mb-2">{t("Create File")}</h3> <h3 className="font-bold text-lg mb-2">{t("Create File")}</h3>
{app.uploadPrompt && (
<p className={"text-sm p-2"}>{app.uploadPrompt}</p>
)}
<p className={"text-sm font-bold p-2"}>{t("Type")}</p> <p className={"text-sm font-bold p-2"}>{t("Type")}</p>
<form className="filter mb-2"> <form className="filter mb-2">
<input <input
@@ -980,8 +989,9 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
)} )}
</p> </p>
<select <select
disabled={!app.canUpload()} // normal user cannot choose storage
className="select select-primary w-full my-2" className="select select-primary w-full my-2"
defaultValue={""} value={storage?.id || ""}
onChange={(e) => { onChange={(e) => {
const id = parseInt(e.target.value); const id = parseInt(e.target.value);
if (isNaN(id)) { if (isNaN(id)) {
@@ -1027,7 +1037,15 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
</> </>
)} )}
{fileType === FileType.serverTask && ( {fileType === FileType.serverTask && !app.canUpload() && (
<p className={"text-sm p-2"}>
{t(
"You do not have permission to upload files, please contact the administrator.",
)}
</p>
)}
{fileType === FileType.serverTask && app.canUpload() && (
<> <>
<p className={"text-sm p-2"}> <p className={"text-sm p-2"}>
{t( {t(
@@ -1035,8 +1053,9 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
)} )}
</p> </p>
<select <select
disabled={!app.canUpload()}
className="select select-primary w-full my-2" className="select select-primary w-full my-2"
defaultValue={""} value={storage?.id || ""}
onChange={(e) => { onChange={(e) => {
const id = parseInt(e.target.value); const id = parseInt(e.target.value);
if (isNaN(id)) { if (isNaN(id)) {

View File

@@ -109,10 +109,34 @@ func handleDeleteStorage(c fiber.Ctx) error {
}) })
} }
func handleSetDefaultStorage(c fiber.Ctx) error {
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
return model.NewRequestError("Invalid storage ID")
}
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("You are not authorized to perform this action")
}
err = service.SetDefaultStorage(uid, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[any]{
Success: true,
Message: "Default storage set successfully",
})
}
func AddStorageRoutes(r fiber.Router) { func AddStorageRoutes(r fiber.Router) {
s := r.Group("storage") s := r.Group("storage")
s.Post("/s3", handleCreateS3Storage) s.Post("/s3", handleCreateS3Storage)
s.Post("/local", handleCreateLocalStorage) s.Post("/local", handleCreateLocalStorage)
s.Get("/", handleListStorages) s.Get("/", handleListStorages)
s.Delete("/:id", handleDeleteStorage) s.Delete("/:id", handleDeleteStorage)
s.Put("/:id/default", handleSetDefaultStorage)
} }

View File

@@ -28,6 +28,12 @@ type ServerConfig struct {
ServerDescription string `json:"server_description"` ServerDescription string `json:"server_description"`
// SiteInfo is an article that describes the site. It will be displayed on the home page. Markdown format. // SiteInfo is an article that describes the site. It will be displayed on the home page. Markdown format.
SiteInfo string `json:"site_info"` SiteInfo string `json:"site_info"`
// AllowNormalUserUpload indicates whether normal users are allowed to upload files.
AllowNormalUserUpload bool `json:"allow_normal_user_upload"`
// MaxNormalUserUploadSizeInMB is the maximum size of files that normal users can upload.
MaxNormalUserUploadSizeInMB int `json:"max_normal_user_upload_size_in_mb"`
// Prompt for upload page
UploadPrompt string `json:"upload_prompt"`
} }
func init() { func init() {
@@ -42,6 +48,9 @@ func init() {
CloudflareTurnstileSecretKey: "", CloudflareTurnstileSecretKey: "",
ServerName: "Nysoure", ServerName: "Nysoure",
ServerDescription: "Nysoure is a file sharing service.", ServerDescription: "Nysoure is a file sharing service.",
AllowNormalUserUpload: true,
MaxNormalUserUploadSizeInMB: 16,
UploadPrompt: "You can upload your files here.",
} }
} else { } else {
data, err := os.ReadFile(p) data, err := os.ReadFile(p)
@@ -106,3 +115,15 @@ func CloudflareTurnstileSecretKey() string {
func SiteInfo() string { func SiteInfo() string {
return config.SiteInfo return config.SiteInfo
} }
func AllowNormalUserUpload() bool {
return config.AllowNormalUserUpload
}
func MaxNormalUserUploadSize() int64 {
return int64(config.MaxNormalUserUploadSizeInMB) * 1024 * 1024
}
func UploadPrompt() string {
return config.UploadPrompt
}

View File

@@ -1,9 +1,10 @@
package dao package dao
import ( import (
"nysoure/server/model"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
"nysoure/server/model"
) )
func CreateStorage(s model.Storage) (model.Storage, error) { func CreateStorage(s model.Storage) (model.Storage, error) {
@@ -37,3 +38,12 @@ func AddStorageUsage(id uint, offset int64) error {
return tx.Model(&model.Storage{}).Where("id = ?", id).Update("current_size", storage.CurrentSize+offset).Error return tx.Model(&model.Storage{}).Where("id = ?", id).Update("current_size", storage.CurrentSize+offset).Error
}) })
} }
func SetDefaultStorage(id uint) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Storage{}).Where("is_default = ?", true).Update("is_default", false).Error; err != nil {
return err
}
return tx.Model(&model.Storage{}).Where("id = ?", id).Update("is_default", true).Error
})
}

View File

@@ -128,6 +128,8 @@ func serveIndexHtml(c fiber.Ctx) error {
content = strings.ReplaceAll(content, "{{Url}}", url) content = strings.ReplaceAll(content, "{{Url}}", url)
content = strings.ReplaceAll(content, "{{CFTurnstileSiteKey}}", cfTurnstileSiteKey) content = strings.ReplaceAll(content, "{{CFTurnstileSiteKey}}", cfTurnstileSiteKey)
content = strings.ReplaceAll(content, "{{SiteInfo}}", siteInfo) content = strings.ReplaceAll(content, "{{SiteInfo}}", siteInfo)
content = strings.ReplaceAll(content, "{{UploadPrompt}}", config.UploadPrompt())
content = strings.ReplaceAll(content, "{{AllowNormalUserUpload}}", strconv.FormatBool(config.AllowNormalUserUpload()))
c.Set("Content-Type", "text/html; charset=utf-8") c.Set("Content-Type", "text/html; charset=utf-8")
return c.SendString(content) return c.SendString(content)

View File

@@ -1,8 +1,9 @@
package model package model
import ( import (
"gorm.io/gorm"
"time" "time"
"gorm.io/gorm"
) )
type Storage struct { type Storage struct {
@@ -12,6 +13,7 @@ type Storage struct {
Config string Config string
MaxSize int64 MaxSize int64
CurrentSize int64 CurrentSize int64
IsDefault bool
} }
type StorageView struct { type StorageView struct {
@@ -21,6 +23,7 @@ type StorageView struct {
MaxSize int64 `json:"maxSize"` MaxSize int64 `json:"maxSize"`
CurrentSize int64 `json:"currentSize"` CurrentSize int64 `json:"currentSize"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
IsDefault bool `json:"isDefault"`
} }
func (s *Storage) ToView() StorageView { func (s *Storage) ToView() StorageView {
@@ -31,5 +34,6 @@ func (s *Storage) ToView() StorageView {
MaxSize: s.MaxSize, MaxSize: s.MaxSize,
CurrentSize: s.CurrentSize, CurrentSize: s.CurrentSize,
CreatedAt: s.CreatedAt, CreatedAt: s.CreatedAt,
IsDefault: s.IsDefault,
} }
} }

View File

@@ -80,8 +80,10 @@ func CreateUploadingFile(uid uint, filename string, description string, fileSize
return nil, model.NewInternalServerError("failed to check user permission") return nil, model.NewInternalServerError("failed to check user permission")
} }
if !canUpload { if !canUpload {
if !config.AllowNormalUserUpload() || fileSize > config.MaxNormalUserUploadSize()*1024*1024 {
return nil, model.NewUnAuthorizedError("user cannot upload file") return nil, model.NewUnAuthorizedError("user cannot upload file")
} }
}
if fileSize > config.MaxFileSize() { if fileSize > config.MaxFileSize() {
return nil, model.NewRequestError("file size exceeds the limit") return nil, model.NewRequestError("file size exceeds the limit")
@@ -300,7 +302,7 @@ func CreateRedirectFile(uid uint, filename string, description string, resourceI
log.Error("failed to check user permission: ", err) log.Error("failed to check user permission: ", err)
return nil, model.NewInternalServerError("failed to check user permission") return nil, model.NewInternalServerError("failed to check user permission")
} }
if !canUpload { if !canUpload && !config.AllowNormalUserUpload() {
return nil, model.NewUnAuthorizedError("user cannot upload file") return nil, model.NewUnAuthorizedError("user cannot upload file")
} }

View File

@@ -105,3 +105,19 @@ func DeleteStorage(uid, id uint) error {
} }
return nil return nil
} }
func SetDefaultStorage(uid, id uint) error {
isAdmin, err := CheckUserIsAdmin(uid)
if err != nil {
log.Errorf("check user is admin failed: %s", err)
return model.NewInternalServerError("check user is admin failed")
}
if !isAdmin {
return model.NewUnAuthorizedError("only admin can set default storage")
}
err = dao.SetDefaultStorage(id)
if err != nil {
return err
}
return nil
}