Add server configuration management.

This commit is contained in:
2025-05-14 21:50:59 +08:00
parent 703812d3df
commit 5c08ab34ea
16 changed files with 337 additions and 29 deletions

View File

@@ -7,3 +7,12 @@ export function ErrorAlert({message, className}: {message: string, className?: s
<span>{message}</span> <span>{message}</span>
</div>; </div>;
} }
export function InfoAlert({ message, className }: { message: string, className?: string }) {
return <div role="alert" className={`alert alert-info ${className}`}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="h-6 w-6 shrink-0 stroke-current">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>{message}</span>
</div>;
}

View File

@@ -0,0 +1,22 @@
interface InputProps {
type?: string;
placeholder?: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
label: string;
inlineLabel?: boolean;
}
export default function Input(props: InputProps) {
if (props.inlineLabel) {
return <label className="input w-full">
{props.label}
<input type={props.type} className="grow" placeholder={props.placeholder} value={props.value} onChange={props.onChange} />
</label>
} else {
return <fieldset className="fieldset w-full">
<legend className="fieldset-legend">{props.label}</legend>
<input type={props.type} className="input w-full" placeholder={props.placeholder} value={props.value} onChange={props.onChange} />
</fieldset>
}
}

View File

@@ -108,3 +108,12 @@ export interface CommentWithResource {
user: User; user: User;
resource: Resource; resource: Resource;
} }
export interface ServerConfig {
max_uploading_size_in_mb: number;
max_file_size_in_mb: number;
max_downloads_per_day_for_single_ip: number;
allow_register: boolean;
cloudflare_turnstile_site_key: string;
cloudflare_turnstile_secret_key: string;
}

View File

@@ -13,7 +13,8 @@ import {
User, User,
UserWithToken, UserWithToken,
Comment, Comment,
CommentWithResource CommentWithResource,
ServerConfig
} from "./models.ts"; } from "./models.ts";
class Network { class Network {
@@ -635,6 +636,32 @@ class Network {
return { success: false, message: e.toString() }; return { success: false, message: e.toString() };
} }
} }
async getServerConfig(): Promise<Response<ServerConfig>> {
try {
const response = await axios.get(`${this.apiBaseUrl}/config`);
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async setServerConfig(config: ServerConfig): Promise<Response<void>> {
try {
const response = await axios.post(`${this.apiBaseUrl}/config`, config);
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
} }
export const network = new Network(); export const network = new Network();

View File

@@ -4,6 +4,7 @@ import StorageView from "./manage_storage_page.tsx";
import UserView from "./manage_user_page.tsx"; import UserView from "./manage_user_page.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ManageMePage } from "./manage_me_page.tsx"; import { ManageMePage } from "./manage_me_page.tsx";
import ManageServerConfigPage from "./manage_server_config_page.tsx";
export default function ManagePage() { export default function ManagePage() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -43,13 +44,15 @@ export default function ManagePage() {
const pageNames = [ const pageNames = [
t("My Info"), t("My Info"),
t("Storage"), t("Storage"),
t("Users") t("Users"),
t("Server"),
] ]
const pageComponents = [ const pageComponents = [
<ManageMePage/>, <ManageMePage/>,
<StorageView/>, <StorageView/>,
<UserView/> <UserView/>,
<ManageServerConfigPage/>,
] ]
return <div className="drawer lg:drawer-open"> return <div className="drawer lg:drawer-open">
@@ -80,6 +83,7 @@ export default function ManagePage() {
{buildItem(t("My Info"), <MdOutlineBadge className={"text-xl"}/>, 0)} {buildItem(t("My Info"), <MdOutlineBadge className={"text-xl"}/>, 0)}
{buildItem(t("Storage"), <MdOutlineStorage className={"text-xl"}/>, 1)} {buildItem(t("Storage"), <MdOutlineStorage className={"text-xl"}/>, 1)}
{buildItem(t("Users"), <MdOutlinePerson className={"text-xl"}/>, 2)} {buildItem(t("Users"), <MdOutlinePerson className={"text-xl"}/>, 2)}
{buildItem(t("Server"), <MdOutlineStorage className={"text-xl"}/>, 3)}
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,92 @@
import { useTranslation } from "react-i18next";
import { app } from "../app"
import { ErrorAlert, InfoAlert } from "../components/alert"
import { useEffect, useState } from "react";
import { ServerConfig } from "../network/models";
import Loading from "../components/loading";
import Input from "../components/input";
import { network } from "../network/network";
import showToast from "../components/toast";
import Button from "../components/button";
export default function ManageServerConfigPage() {
const { t } = useTranslation();
const [config, setConfig] = useState<ServerConfig | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
network.getServerConfig().then((res) => {
if (res.success) {
setConfig(res.data!);
} else {
showToast({
message: res.message,
type: "error",
})
}
})
}, []);
if (!app.user) {
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")} />
}
if (!app.user?.is_admin) {
return <ErrorAlert className={"m-4"} message={t("You are not authorized to access this page.")} />
}
if (config == null) {
return <Loading />
}
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (isLoading) {
return;
}
setIsLoading(true);
const res = await network.setServerConfig(config);
if (res.success) {
showToast({
message: t("Update server config successfully"),
type: "success",
});
} else {
showToast({
message: res.message,
type: "error",
});
}
setIsLoading(false);
};
return <form className="px-4" onSubmit={handleSubmit}>
<Input type="number" value={config.max_uploading_size_in_mb.toString()} label="Max uploading size (MB)" onChange={(e) => {
setConfig({...config, max_uploading_size_in_mb: parseInt(e.target.value) })
}}></Input>
<Input type="number" value={config.max_file_size_in_mb.toString()} label="Max file size (MB)" onChange={(e) => {
setConfig({...config, max_file_size_in_mb: parseInt(e.target.value) })
}}></Input>
<Input type="number" value={config.max_downloads_per_day_for_single_ip.toString()} label="Max downloads per day for single IP" onChange={(e) => {
setConfig({...config, max_downloads_per_day_for_single_ip: parseInt(e.target.value) })
}}></Input>
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">Allow register</legend>
<input type="checkbox" checked={config.allow_register} className="toggle-primary toggle" onChange={(e) => {
setConfig({ ...config, allow_register: e.target.checked })
}} />
</fieldset>
<Input type="text" value={config.cloudflare_turnstile_site_key} label="Cloudflare Turnstile Site Key" onChange={(e) => {
setConfig({...config, cloudflare_turnstile_site_key: e.target.value })
}}></Input>
<Input type="text" value={config.cloudflare_turnstile_secret_key} label="Cloudflare Turnstile Secret Key" onChange={(e) => {
setConfig({...config, cloudflare_turnstile_secret_key: e.target.value })
}}></Input>
<InfoAlert className="my-2" message="If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download." />
<div className="flex justify-end">
<Button className="btn-accent shadow" isLoading={isLoading}>{t("Submit")}</Button>
</div>
</form>
}

View File

@@ -96,7 +96,7 @@ export default function ResourcePage() {
} }
</p> </p>
<div className="tabs tabs-box my-4 mx-2 p-4"> <div className="tabs tabs-box my-4 mx-2 p-4">
<label className="tab"> <label className="tab transition-all">
<input type="radio" name="my_tabs" checked={page === 0} onChange={() => { <input type="radio" name="my_tabs" checked={page === 0} onChange={() => {
setPage(0) setPage(0)
}}/> }}/>
@@ -109,7 +109,7 @@ export default function ResourcePage() {
<Article article={resource.article}/> <Article article={resource.article}/>
</div> </div>
<label className="tab"> <label className="tab transition-all">
<input type="radio" name="my_tabs" checked={page === 1} onChange={() => { <input type="radio" name="my_tabs" checked={page === 1} onChange={() => {
setPage(1) setPage(1)
}}/> }}/>
@@ -122,7 +122,7 @@ export default function ResourcePage() {
<Files files={resource.files} resourceID={resource.id}/> <Files files={resource.files} resourceID={resource.id}/>
</div> </div>
<label className="tab"> <label className="tab transition-all">
<input type="radio" name="my_tabs" checked={page === 2} onChange={() => { <input type="radio" name="my_tabs" checked={page === 2} onChange={() => {
setPage(2) setPage(2)
}}/> }}/>

View File

@@ -1,12 +1,13 @@
package main package main
import ( import (
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/cors"
"github.com/gofiber/fiber/v3/middleware/logger"
"log" "log"
"nysoure/server/api" "nysoure/server/api"
"nysoure/server/middleware" "nysoure/server/middleware"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/cors"
"github.com/gofiber/fiber/v3/middleware/logger"
) )
func main() { func main() {
@@ -33,6 +34,7 @@ func main() {
api.AddStorageRoutes(apiG) api.AddStorageRoutes(apiG)
api.AddFileRoutes(apiG) api.AddFileRoutes(apiG)
api.AddCommentRoutes(apiG) api.AddCommentRoutes(apiG)
api.AddConfigRoutes(apiG)
} }
log.Fatal(app.Listen(":3000")) log.Fatal(app.Listen(":3000"))

64
server/api/config.go Normal file
View File

@@ -0,0 +1,64 @@
package api
import (
"nysoure/server/config"
"nysoure/server/model"
"nysoure/server/service"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/log"
)
func getServerConfig(c fiber.Ctx) error {
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewRequestError("You are not logged in")
}
isAdmin, err := service.CheckUserIsAdmin(uid)
if err != nil {
log.Error("Error checking user admin status: ", err)
return model.NewInternalServerError("Error checking user admin status")
}
if !isAdmin {
return model.NewUnAuthorizedError("You do not have permission to access this resource")
}
sc := config.GetConfig()
return c.JSON(model.Response[config.ServerConfig]{
Success: true,
Data: sc,
})
}
func setServerConfig(c fiber.Ctx) error {
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewRequestError("You are not logged in")
}
isAdmin, err := service.CheckUserIsAdmin(uid)
if err != nil {
log.Error("Error checking user admin status: ", err)
return model.NewInternalServerError("Error checking user admin status")
}
if !isAdmin {
return model.NewUnAuthorizedError("You do not have permission to access this resource")
}
var sc config.ServerConfig
if err := c.Bind().Body(&sc); err != nil {
return model.NewRequestError("Invalid request parameters")
}
config.SetConfig(sc)
return c.JSON(model.Response[any]{
Success: true,
})
}
func AddConfigRoutes(r fiber.Router) {
configGroup := r.Group("/config")
{
configGroup.Get("/", getServerConfig)
configGroup.Post("/", setServerConfig)
}
}

View File

@@ -2,11 +2,12 @@ package api
import ( import (
"fmt" "fmt"
"github.com/gofiber/fiber/v3"
"nysoure/server/model" "nysoure/server/model"
"nysoure/server/service" "nysoure/server/service"
"strconv" "strconv"
"strings" "strings"
"github.com/gofiber/fiber/v3"
) )
func AddFileRoutes(router fiber.Router) { func AddFileRoutes(router fiber.Router) {

76
server/config/config.go Normal file
View File

@@ -0,0 +1,76 @@
package config
import (
"encoding/json"
"nysoure/server/utils"
"os"
"path/filepath"
)
var config *ServerConfig
type ServerConfig struct {
// MaxUploadingSizeInMB is the maximum size of files that are being uploaded at the same time.
MaxUploadingSizeInMB int `json:"max_uploading_size_in_mb"`
// MaxFileSizeInMB is the maximum size of a single file that can be uploaded.
MaxFileSizeInMB int `json:"max_file_size_in_mb"`
// MaxDownloadsForSingleIP is the maximum number of downloads allowed from a single IP address.
MaxDownloadsPerDayForSingleIP int `json:"max_downloads_per_day_for_single_ip"`
// AllowRegister indicates whether user registration is allowed.
AllowRegister bool `json:"allow_register"`
// CloudflareTurnstileSiteKey is the site key for Cloudflare Turnstile.
CloudflareTurnstileSiteKey string `json:"cloudflare_turnstile_site_key"`
// CloudflareTurnstileSecretKey is the secret key for Cloudflare Turnstile.
CloudflareTurnstileSecretKey string `json:"cloudflare_turnstile_secret_key"`
}
func init() {
filepath := filepath.Join(utils.GetStoragePath(), "config.json")
if _, err := os.Stat(filepath); os.IsNotExist(err) {
config = &ServerConfig{
MaxUploadingSizeInMB: 20 * 1024, // 20GB
MaxFileSizeInMB: 8 * 1024, // 8GB
MaxDownloadsPerDayForSingleIP: 20,
AllowRegister: true,
CloudflareTurnstileSiteKey: "",
CloudflareTurnstileSecretKey: "",
}
} else {
data, err := os.ReadFile(filepath)
if err != nil {
panic(err)
}
config = &ServerConfig{}
if err := json.Unmarshal(data, config); err != nil {
panic(err)
}
}
}
func GetConfig() ServerConfig {
return *config
}
func SetConfig(newConfig ServerConfig) {
config = &newConfig
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
panic(err)
}
filepath := filepath.Join(utils.GetStoragePath(), "config.json")
if err := os.WriteFile(filepath, data, 0644); err != nil {
panic(err)
}
}
func MaxUploadingSize() int64 {
return int64(config.MaxUploadingSizeInMB) * 1024 * 1024
}
func MaxFileSize() int64 {
return int64(config.MaxFileSizeInMB) * 1024 * 1024
}
func AllowRegister() bool {
return config.AllowRegister
}

View File

@@ -1,8 +1,7 @@
package service package service
import ( import (
"github.com/gofiber/fiber/v3/log" "nysoure/server/config"
"github.com/google/uuid"
"nysoure/server/dao" "nysoure/server/dao"
"nysoure/server/model" "nysoure/server/model"
"nysoure/server/storage" "nysoure/server/storage"
@@ -11,17 +10,15 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"time" "time"
"github.com/gofiber/fiber/v3/log"
"github.com/google/uuid"
) )
const ( const (
blockSize = 4 * 1024 * 1024 // 4MB blockSize = 4 * 1024 * 1024 // 4MB
) )
var (
maxUploadingSize = int64(1024 * 1024 * 1024 * 20) // TODO: make this configurable
maxFileSize = int64(1024 * 1024 * 1024 * 8) // TODO: make this configurable
)
func getUploadingSize(uid uint) int64 { func getUploadingSize(uid uint) int64 {
return dao.GetStatistic("uploading_size") return dao.GetStatistic("uploading_size")
} }
@@ -83,12 +80,12 @@ func CreateUploadingFile(uid uint, filename string, description string, fileSize
return nil, model.NewUnAuthorizedError("user cannot upload file") return nil, model.NewUnAuthorizedError("user cannot upload file")
} }
if fileSize > maxFileSize { if fileSize > config.MaxFileSize() {
return nil, model.NewRequestError("file size exceeds the limit") return nil, model.NewRequestError("file size exceeds the limit")
} }
currentUploadingSize := getUploadingSize(uid) currentUploadingSize := getUploadingSize(uid)
if currentUploadingSize+fileSize > maxUploadingSize { if currentUploadingSize+fileSize > config.MaxUploadingSize() {
log.Info("A new uploading file is rejected due to max uploading size limit") log.Info("A new uploading file is rejected due to max uploading size limit")
return nil, model.NewRequestError("server is busy, please try again later") return nil, model.NewRequestError("server is busy, please try again later")
} }
@@ -305,7 +302,7 @@ func DeleteFile(uid uint, fid string) error {
return model.NewNotFoundError("file not found") return model.NewNotFoundError("file not found")
} }
isAdmin, err := checkUserIsAdmin(uid) isAdmin, err := CheckUserIsAdmin(uid)
if err != nil { if err != nil {
log.Error("failed to check user permission: ", err) log.Error("failed to check user permission: ", err)
return model.NewInternalServerError("failed to check user permission") return model.NewInternalServerError("failed to check user permission")
@@ -342,7 +339,7 @@ func UpdateFile(uid uint, fid string, filename string, description string) (*mod
return nil, model.NewNotFoundError("file not found") return nil, model.NewNotFoundError("file not found")
} }
isAdmin, err := checkUserIsAdmin(uid) isAdmin, err := CheckUserIsAdmin(uid)
if err != nil { if err != nil {
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")

View File

@@ -89,7 +89,7 @@ func SearchResource(keyword string, page int) ([]model.ResourceView, int, error)
} }
func DeleteResource(uid, id uint) error { func DeleteResource(uid, id uint) error {
isAdmin, err := checkUserIsAdmin(uid) isAdmin, err := CheckUserIsAdmin(uid)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -1,11 +1,12 @@
package service package service
import ( import (
"github.com/gofiber/fiber/v3/log"
"nysoure/server/dao" "nysoure/server/dao"
"nysoure/server/model" "nysoure/server/model"
"nysoure/server/storage" "nysoure/server/storage"
"os" "os"
"github.com/gofiber/fiber/v3/log"
) )
type CreateS3StorageParams struct { type CreateS3StorageParams struct {
@@ -18,7 +19,7 @@ type CreateS3StorageParams struct {
} }
func CreateS3Storage(uid uint, params CreateS3StorageParams) error { func CreateS3Storage(uid uint, params CreateS3StorageParams) error {
isAdmin, err := checkUserIsAdmin(uid) isAdmin, err := CheckUserIsAdmin(uid)
if err != nil { if err != nil {
log.Errorf("check user is admin failed: %s", err) log.Errorf("check user is admin failed: %s", err)
return model.NewInternalServerError("check user is admin failed") return model.NewInternalServerError("check user is admin failed")
@@ -49,7 +50,7 @@ type CreateLocalStorageParams struct {
} }
func CreateLocalStorage(uid uint, params CreateLocalStorageParams) error { func CreateLocalStorage(uid uint, params CreateLocalStorageParams) error {
isAdmin, err := checkUserIsAdmin(uid) isAdmin, err := CheckUserIsAdmin(uid)
if err != nil { if err != nil {
log.Errorf("check user is admin failed: %s", err) log.Errorf("check user is admin failed: %s", err)
return model.NewInternalServerError("check user is admin failed") return model.NewInternalServerError("check user is admin failed")
@@ -88,7 +89,7 @@ func ListStorages() ([]model.StorageView, error) {
} }
func DeleteStorage(uid, id uint) error { func DeleteStorage(uid, id uint) error {
isAdmin, err := checkUserIsAdmin(uid) isAdmin, err := CheckUserIsAdmin(uid)
if err != nil { if err != nil {
log.Errorf("check user is admin failed: %s", err) log.Errorf("check user is admin failed: %s", err)
return model.NewInternalServerError("check user is admin failed") return model.NewInternalServerError("check user is admin failed")

View File

@@ -3,6 +3,7 @@ package service
import ( import (
"errors" "errors"
"fmt" "fmt"
"nysoure/server/config"
"nysoure/server/dao" "nysoure/server/dao"
"nysoure/server/model" "nysoure/server/model"
"nysoure/server/static" "nysoure/server/static"
@@ -18,6 +19,9 @@ const (
) )
func CreateUser(username, password string) (model.UserViewWithToken, error) { func CreateUser(username, password string) (model.UserViewWithToken, error) {
if !config.AllowRegister() {
return model.UserViewWithToken{}, model.NewRequestError("User registration is not allowed")
}
if len(username) < 3 || len(username) > 20 { if len(username) < 3 || len(username) > 20 {
return model.UserViewWithToken{}, model.NewRequestError("Username must be between 3 and 20 characters") return model.UserViewWithToken{}, model.NewRequestError("Username must be between 3 and 20 characters")
} }

View File

@@ -10,7 +10,7 @@ func checkUserCanUpload(uid uint) (bool, error) {
return user.IsAdmin || user.CanUpload, nil return user.IsAdmin || user.CanUpload, nil
} }
func checkUserIsAdmin(uid uint) (bool, error) { func CheckUserIsAdmin(uid uint) (bool, error) {
user, err := dao.GetUserByID(uid) user, err := dao.GetUserByID(uid)
if err != nil { if err != nil {
return false, err return false, err