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

@@ -1,8 +1,17 @@
export function ErrorAlert({message, className}: {message: string, className?: string}) {
export function ErrorAlert({ message, className }: { message: string, className?: string }) {
return <div role="alert" className={`alert alert-error ${className}`}>
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{message}</span>
</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;
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,
UserWithToken,
Comment,
CommentWithResource
CommentWithResource,
ServerConfig
} from "./models.ts";
class Network {
@@ -635,6 +636,32 @@ class Network {
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();

View File

@@ -4,6 +4,7 @@ import StorageView from "./manage_storage_page.tsx";
import UserView from "./manage_user_page.tsx";
import { useTranslation } from "react-i18next";
import { ManageMePage } from "./manage_me_page.tsx";
import ManageServerConfigPage from "./manage_server_config_page.tsx";
export default function ManagePage() {
const { t } = useTranslation();
@@ -43,13 +44,15 @@ export default function ManagePage() {
const pageNames = [
t("My Info"),
t("Storage"),
t("Users")
t("Users"),
t("Server"),
]
const pageComponents = [
<ManageMePage/>,
<StorageView/>,
<UserView/>
<UserView/>,
<ManageServerConfigPage/>,
]
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("Storage"), <MdOutlineStorage className={"text-xl"}/>, 1)}
{buildItem(t("Users"), <MdOutlinePerson className={"text-xl"}/>, 2)}
{buildItem(t("Server"), <MdOutlineStorage className={"text-xl"}/>, 3)}
</ul>
</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>
<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={() => {
setPage(0)
}}/>
@@ -109,7 +109,7 @@ export default function ResourcePage() {
<Article article={resource.article}/>
</div>
<label className="tab">
<label className="tab transition-all">
<input type="radio" name="my_tabs" checked={page === 1} onChange={() => {
setPage(1)
}}/>
@@ -122,7 +122,7 @@ export default function ResourcePage() {
<Files files={resource.files} resourceID={resource.id}/>
</div>
<label className="tab">
<label className="tab transition-all">
<input type="radio" name="my_tabs" checked={page === 2} onChange={() => {
setPage(2)
}}/>

View File

@@ -1,12 +1,13 @@
package main
import (
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/cors"
"github.com/gofiber/fiber/v3/middleware/logger"
"log"
"nysoure/server/api"
"nysoure/server/middleware"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/cors"
"github.com/gofiber/fiber/v3/middleware/logger"
)
func main() {
@@ -33,6 +34,7 @@ func main() {
api.AddStorageRoutes(apiG)
api.AddFileRoutes(apiG)
api.AddCommentRoutes(apiG)
api.AddConfigRoutes(apiG)
}
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 (
"fmt"
"github.com/gofiber/fiber/v3"
"nysoure/server/model"
"nysoure/server/service"
"strconv"
"strings"
"github.com/gofiber/fiber/v3"
)
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
import (
"github.com/gofiber/fiber/v3/log"
"github.com/google/uuid"
"nysoure/server/config"
"nysoure/server/dao"
"nysoure/server/model"
"nysoure/server/storage"
@@ -11,17 +10,15 @@ import (
"path/filepath"
"strconv"
"time"
"github.com/gofiber/fiber/v3/log"
"github.com/google/uuid"
)
const (
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 {
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")
}
if fileSize > maxFileSize {
if fileSize > config.MaxFileSize() {
return nil, model.NewRequestError("file size exceeds the limit")
}
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")
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")
}
isAdmin, err := checkUserIsAdmin(uid)
isAdmin, err := CheckUserIsAdmin(uid)
if err != nil {
log.Error("failed to check user permission: ", err)
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")
}
isAdmin, err := checkUserIsAdmin(uid)
isAdmin, err := CheckUserIsAdmin(uid)
if err != nil {
log.Error("failed to check user permission: ", err)
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 {
isAdmin, err := checkUserIsAdmin(uid)
isAdmin, err := CheckUserIsAdmin(uid)
if err != nil {
return err
}

View File

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

View File

@@ -3,6 +3,7 @@ package service
import (
"errors"
"fmt"
"nysoure/server/config"
"nysoure/server/dao"
"nysoure/server/model"
"nysoure/server/static"
@@ -18,6 +19,9 @@ const (
)
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 {
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
}
func checkUserIsAdmin(uid uint) (bool, error) {
func CheckUserIsAdmin(uid uint) (bool, error) {
user, err := dao.GetUserByID(uid)
if err != nil {
return false, err