Initial commit

This commit is contained in:
2025-05-11 20:32:14 +08:00
commit d97247159f
80 changed files with 13013 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.idea
*.iml
test.db
.idea/

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

54
frontend/README.md Normal file
View File

@@ -0,0 +1,54 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config({
extends: [
// Remove ...tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
],
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config({
plugins: {
// Add the react-x and react-dom plugins
'react-x': reactX,
'react-dom': reactDom,
},
rules: {
// other rules...
// Enable its recommended typescript rules
...reactX.configs['recommended-typescript'].rules,
...reactDom.configs.recommended.rules,
},
})
```

42
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,42 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-unused-vars': [
'off',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
ignoreRestSiblings: true,
},
],
'@typescript-eslint/no-explicit-any': [
'off',
{
ignoreRestArgs: true,
},
]
},
},
)

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6334
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
frontend/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.5",
"axios": "^1.9.0",
"i18next": "^25.1.1",
"i18next-browser-languagedetector": "^8.1.0",
"masonic": "^4.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-i18next": "^15.5.1",
"react-icons": "^5.5.0",
"react-markdown": "^10.1.0",
"react-router": "^7.5.3",
"tailwindcss": "^4.1.5"
},
"devDependencies": {
"@eslint/js": "^9.22.0",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"daisyui": "^5.0.35",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.26.1",
"vite": "^6.3.1"
}
}

39
frontend/src/app.ts Normal file
View File

@@ -0,0 +1,39 @@
import {User} from "./network/models.ts";
class App {
appName = "资源库"
user: User | null = null;
token: string | null = null;
constructor() {
this.init();
}
init() {
const userJson = localStorage.getItem("user");
const tokenJson = localStorage.getItem("token");
if (userJson) {
this.user = JSON.parse(userJson);
}
if (tokenJson) {
this.token = JSON.parse(tokenJson);
}
}
saveData() {
localStorage.setItem("user", JSON.stringify(this.user));
localStorage.setItem("token", JSON.stringify(this.token));
}
isAdmin() {
return this.user != null && this.user.is_admin;
}
isLoggedIn() {
return this.user != null && this.token != null;
}
}
export const app = new App();

28
frontend/src/app.tsx Normal file
View File

@@ -0,0 +1,28 @@
import {BrowserRouter, Route, Routes} from "react-router";
import LoginPage from "./pages/login_page.tsx";
import RegisterPage from "./pages/register_page.tsx";
import Navigator from "./components/navigator.tsx";
import HomePage from "./pages/home_page.tsx";
import PublishPage from "./pages/publish_page.tsx";
import SearchPage from "./pages/search_page.tsx";
import ResourcePage from "./pages/resource_details_page.tsx";
import "./i18n.ts"
import ManagePage from "./pages/manage_page.tsx";
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path={"/login"} element={<LoginPage/>}/>
<Route path={"/register"} element={<RegisterPage/>}/>
<Route element={<Navigator/>}>
<Route path={"/"} element={<HomePage/>}/>
<Route path={"/publish"} element={<PublishPage/>} />
<Route path={"/search"} element={<SearchPage/>} />
<Route path={"/resources/:id"} element={<ResourcePage/>}/>
<Route path={"/manage"} element={<ManagePage/>}/>
</Route>
</Routes>
</BrowserRouter>
)
}

View File

@@ -0,0 +1,9 @@
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"/>
</svg>
<span>{message}</span>
</div>;
}

View File

@@ -0,0 +1,10 @@
import { useTranslation } from "react-i18next";
export default function Loading() {
const {t} = useTranslation();
return <div className={"flex justify-center py-4"}>
<span className="loading loading-spinner loading-lg mr-2"></span>
<span>{t("Loading")}</span>
</div>;
}

View File

@@ -0,0 +1,191 @@
import {app} from "../app.ts";
import {network} from "../network/network.ts";
import {useNavigate, useOutlet} from "react-router";
import {useEffect, useState} from "react";
import {MdOutlinePerson, MdSearch, MdSettings} from "react-icons/md";
import { useTranslation } from "react-i18next";
export default function Navigator() {
const outlet = useOutlet()
const navigate = useNavigate()
return <>
<div className="navbar bg-base-100 shadow-sm fixed top-0 z-1 lg:z-10">
<div className={"flex-1 max-w-7xl mx-auto flex"}>
<div className="flex-1">
<button className="btn btn-ghost text-xl" onClick={() => {
navigate(`/`);
}}>{app.appName}</button>
</div>
<div className="flex gap-2">
<SearchBar/>
{
app.isAdmin() && <button className={"btn btn-circle btn-ghost"} onClick={() => {
navigate("/manage");
}}>
<MdSettings size={24}/>
</button>
}
{
app.isLoggedIn() ? <UserButton/> : <button className={"btn btn-primary btn-square btn-soft"} onClick={() => {
navigate("/login");
}}>
<MdOutlinePerson size={24}></MdOutlinePerson>
</button>
}
</div>
</div>
</div>
<div className={"max-w-7xl mx-auto pt-16"}>
{outlet}
</div>
</>
}
function UserButton() {
let avatar = "./avatar.png";
if (app.user) {
avatar = network.getUserAvatar(app.user)
}
const navigate = useNavigate()
const {t} = useTranslation()
return <>
<div className="dropdown dropdown-end">
<div tabIndex={0} role="button" className="btn btn-ghost btn-circle avatar">
<div className="w-10 rounded-full">
<img
alt="Avatar"
src={avatar}/>
</div>
</div>
<ul
id={"navi_dropdown_menu"}
tabIndex={0}
className="menu dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow">
<li><a onClick={() => navigate(`/user/${app.user?.id}`)}>{t("My Profile")}</a></li>
<li><a onClick={() => {
navigate(`/publish`);
const menu = document.getElementById("navi_dropdown_menu") as HTMLUListElement;
menu.blur();
}}>{t("Publish")}</a></li>
<li><a onClick={() => {
const dialog = document.getElementById("confirm_logout") as HTMLDialogElement;
dialog.showModal();
}}>{t("Log out")}</a></li>
</ul>
</div>
<dialog id="confirm_logout" className="modal">
<div className="modal-box">
<h3 className="text-lg font-bold">{t("Log out")}</h3>
<p className="py-4">{t("Are you sure you want to log out?")}</p>
<div className="modal-action">
<form method="dialog">
<button className="btn">{t('Cancel')}</button>
<button className="btn btn-error mx-2" type={"button"} onClick={() => {
app.user = null;
app.token = null;
app.saveData();
navigate(`/login`, {replace: true});
}}>{t('Confirm')}
</button>
</form>
</div>
</div>
</dialog>
</>
}
function SearchBar() {
const [small, setSmall] = useState(window.innerWidth < 640);
const navigate = useNavigate();
const [search, setSearch] = useState("");
const {t} = useTranslation();
useEffect(() => {
const handleResize = () => {
if (window.innerWidth < 640) {
setSmall(true);
} else {
setSmall(false);
}
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
const doSearch = () => {
if (search.length === 0) {
return;
}
const replace = window.location.pathname === "/search";
navigate(`/search?keyword=${search}`, {replace: replace});
}
const searchField = <label className={`input input-primary ${small ? "w-full": "w-64"}`}>
<svg className="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<form className={"w-full"} onSubmit={(e) => {
e.preventDefault();
doSearch();
}}>
<input type="search" className={"w-full"} required placeholder={t("Search")} value={search} onChange={(e) => setSearch(e.target.value)}/>
</form>
</label>
if (small) {
return <>
<button className={"btn btn-circle btn-ghost"} onClick={() => {
const dialog = document.getElementById("search_dialog") as HTMLDialogElement;
dialog.showModal();
}}>
<MdSearch size={24}/>
</button>
<dialog id="search_dialog" className="modal">
<div className="modal-box">
<form method="dialog">
<button className="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"></button>
</form>
<h3 className="text-lg font-bold">{t("Search")}</h3>
<div className={"h-4"}/>
{searchField}
<div className={"h-4"}/>
<div className={"flex flex-row-reverse"}>
<button className={"btn btn-primary"} onClick={() => {
if (search.length === 0) {
return;
}
const dialog = document.getElementById("search_dialog") as HTMLDialogElement;
dialog.close();
doSearch();
}}>
{t("Search")}
</button>
</div>
</div>
</dialog>
</>
}
return searchField
}

View File

@@ -0,0 +1,36 @@
import { ReactNode } from "react";
import { MdChevronLeft, MdChevronRight } from "react-icons/md";
export default function Pagination({ page, setPage, totalPages }: { page: number, setPage: (page: number) => void, totalPages: number }) {
const items: ReactNode[] = [];
if (page > 1) {
items.push(<button className="join-item btn" onClick={() => setPage(1)}>1</button>);
}
if (page - 2 > 1) {
items.push(<button className="join-item btn">...</button>);
}
if (page-1 > 1) {
items.push(<button className="join-item btn" onClick={() => setPage(page-1)}>{page-1}</button>);
}
items.push(<button className="join-item btn btn-active">{page}</button>);
if (page+1 < totalPages) {
items.push(<button className="join-item btn" onClick={() => setPage(page+1)}>{page+1}</button>);
}
if (page+2 < totalPages) {
items.push(<button className="join-item btn">...</button>);
}
if (page < totalPages) {
items.push(<button className="join-item btn" onClick={() => setPage(totalPages)}>{totalPages}</button>);
}
return <div className="join">
<button className={`join-item btn ${page === 1 && "btn-disabled"}`} onClick={() => setPage(page-1)}>
<MdChevronLeft size={20} className="opacity-50"/>
</button>
{items}
<button className={`join-item btn ${page === totalPages && "btn-disabled"}`} onClick={() => setPage(page+1)}>
<MdChevronRight size={20} className="opacity-50"/>
</button>
</div>
}

View File

@@ -0,0 +1,61 @@
import React from "react";
import { createRoot } from "react-dom/client";
export default function showPopup(content: React.ReactNode, element: HTMLElement) {
const eRect = element.getBoundingClientRect();
const div = document.createElement("div");
div.style.position = "fixed";
if (eRect.x > window.innerWidth / 2) {
div.style.right = `${window.innerWidth - eRect.x}px`;
} else {
div.style.left = `${eRect.x}px`;
}
if (eRect.y > window.innerHeight / 2) {
div.style.bottom = `${window.innerHeight - eRect.y}px`;
} else {
div.style.top = `${eRect.y}px`;
}
div.style.zIndex = "9999";
div.className = "animate-appearance-in";
document.body.appendChild(div);
const mask = document.createElement("div");
const close = () => {
console.log("close popup");
document.body.removeChild(div);
document.body.removeChild(mask);
};
mask.style.position = "fixed";
mask.style.top = "0";
mask.style.left = "0";
mask.style.width = "100%";
mask.style.height = "100%";
mask.style.zIndex = "9998";
mask.onclick = close;
document.body.appendChild(mask);
createRoot(div).render(<context.Provider value={close}>
{content}
</context.Provider>)
}
const context = React.createContext<() => void>(() => {});
export function useClosePopup() {
return React.useContext(context);
}
export function PopupMenuItem({ children, onClick }: { children: React.ReactNode, onClick: () => void }) {
const close = useClosePopup();
return <li onClick={() => {
close();
onClick();
}}>
{children}
</li>
}

View File

@@ -0,0 +1,45 @@
import { Resource } from "../network/models.ts";
import { network } from "../network/network.ts";
import { useNavigate } from "react-router";
export default function ResourceCard({ resource }: { resource: Resource }) {
const navigate = useNavigate()
return <div className={"p-2 cursor-pointer"} onClick={() => {
navigate(`/resources/${resource.id}`)
}}>
<div className={"card shadow hover:shadow-md transition-shadow"}>
{
resource.image != null && <figure>
<img
src={network.getImageUrl(resource.image.id)}
alt="cover" style={{
width: "100%",
aspectRatio: resource.image.width / resource.image.height,
}}/>
</figure>
}
<div className="flex flex-col p-4">
<h2 className="card-title">{resource.title}</h2>
<div className="h-2"></div>
<p>
{
resource.tags.map((tag) => {
return <span key={tag.id} className={"badge badge-primary mr-2"}>{tag.name}</span>
})
}
</p>
<div className="h-2"></div>
<div className="flex items-center">
<div className="avatar">
<div className="w-6 rounded-full">
<img src={network.getUserAvatar(resource.author)} />
</div>
</div>
<div className="w-2"></div>
<div className="text-sm">{resource.author.username}</div>
</div>
</div>
</div>
</div>
}

View File

@@ -0,0 +1,44 @@
import {PageResponse, Resource} from "../network/models.ts";
import {useCallback, useEffect, useRef, useState} from "react";
import showToast from "./toast.ts";
import ResourceCard from "./resource_card.tsx";
import {Masonry} from "masonic";
import Loading from "./loading.tsx";
export default function ResourcesView({loader}: {loader: (page: number) => Promise<PageResponse<Resource>>}) {
const [data, setData] = useState<Resource[]>([])
const pageRef = useRef(1)
const totalPagesRef = useRef(1)
const isLoadingRef = useRef(false)
const loadPage = useCallback(async () => {
if (pageRef.current > totalPagesRef.current) return
if (isLoadingRef.current) return
isLoadingRef.current = true
const res = await loader(pageRef.current)
if (!res.success) {
showToast({message: "Error loading resources", type: "error"})
} else {
isLoadingRef.current = false
pageRef.current = pageRef.current + 1
totalPagesRef.current = res.totalPages ?? 1
setData((prev) => [...prev, ...res.data!])
}
}, [loader])
useEffect(() => {
loadPage()
}, [loadPage]);
return <div className={"px-2 pt-2"}>
<Masonry columnWidth={300} items={data} render={(e) => {
if (e.index === data.length - 1) {
loadPage()
}
return <ResourceCard resource={e.data} key={e.data.id}/>
} }></Masonry>
{
pageRef.current <= totalPagesRef.current && <Loading/>
}
</div>
}

View File

@@ -0,0 +1,14 @@
export default function showToast({message, type}: {message: string, type?: "success" | "error" | "warning" | "info"}) {
type = type || "info"
const div = document.createElement("div")
div.innerHTML = `
<div class="toast toast-center">
<div class="alert shadow ${type === "success" && "alert-success"} ${type === "error" && "alert-error"} ${type === 'warning' && "alert-warning"} ${type === "info" && "alert-info"}">
<span>${message}</span>
</div>
</div>`
document.body.appendChild(div)
setTimeout(() => {
div.remove()
}, 3000)
}

275
frontend/src/i18n.ts Normal file
View File

@@ -0,0 +1,275 @@
export const i18nData = {
"en": {
translation: {
"My Profile": "My Profile",
"Publish": "Publish",
"Log out": "Log out",
"Are you sure you want to log out?": "Are you sure you want to log out?",
"Cancel": "Cancel",
"Confirm": "Confirm",
"Search": "Search",
"Login": "Login",
"Register": "Register",
"Username": "Username",
"Password": "Password",
"Confirm Password": "Confirm Password",
"Username and password cannot be empty": "Username and password cannot be empty",
"Passwords do not match": "Passwords do not match",
"Continue": "Continue",
"Don't have an account? Register": "Don't have an account? Register",
"Already have an account? Login": "Already have an account? Login",
"Publish Resource": "Publish Resource",
"All information, images, and files can be modified after publishing": "All information, images, and files can be modified after publishing",
"Title": "Title",
"Alternative Titles": "Alternative Titles",
"Add Alternative Title": "Add Alternative Title",
"Tags": "Tags",
"Description": "Description",
"Use Markdown format": "Use Markdown format",
"Images": "Images",
"Images will not be displayed automatically, you need to reference them in the description": "Images will not be displayed automatically, you need to reference them in the description",
"Preview": "Preview",
"Link": "Link",
"Action": "Action",
"Upload Image": "Upload Image",
"Error": "Error",
"Title cannot be empty": "Title cannot be empty",
"Alternative title cannot be empty": "Alternative title cannot be empty",
"At least one tag required": "At least one tag required",
"Description cannot be empty": "Description cannot be empty",
"Loading": "Loading",
"Enter a search keyword to continue": "Enter a search keyword to continue",
// Management page translations
"Manage": "Manage",
"Storage": "Storage",
"Users": "Users",
"You are not logged in. Please log in to access this page.": "You are not logged in. Please log in to access this page.",
"You are not authorized to access this page.": "You are not authorized to access this page.",
// Storage management
"No storage found. Please create a new storage.": "No storage found. Please create a new storage.",
"Name": "Name",
"Created At": "Created At",
"Actions": "Actions",
"Delete Storage": "Delete Storage",
"Are you sure you want to delete this storage? This action cannot be undone.": "Are you sure you want to delete this storage? This action cannot be undone.",
"Delete": "Delete",
"Storage deleted successfully": "Storage deleted successfully",
"New Storage": "New Storage",
"Type": "Type",
"Local": "Local",
"S3": "S3",
"Path": "Path",
"Max Size (MB)": "Max Size (MB)",
"Endpoint": "Endpoint",
"Access Key ID": "Access Key ID",
"Secret Access Key": "Secret Access Key",
"Bucket Name": "Bucket Name",
"All fields are required": "All fields are required",
"Storage created successfully": "Storage created successfully",
"Close": "Close",
"Submit": "Submit",
// User management
"Admin": "Admin",
"Can Upload": "Can Upload",
"Yes": "Yes",
"No": "No",
"Delete User": "Delete User",
"Are you sure you want to delete user": "Are you sure you want to delete user",
"This action cannot be undone.": "This action cannot be undone.",
"User deleted successfully": "User deleted successfully",
"Set as user": "Set as user",
"Set as admin": "Set as admin",
"Remove upload permission": "Remove upload permission",
"Grant upload permission": "Grant upload permission",
"User set as admin successfully": "User set as admin successfully",
"User set as user successfully": "User set as user successfully",
"User set as upload permission successfully": "User set as upload permission successfully",
"User removed upload permission successfully": "User removed upload permission successfully",
}
},
"zh-CN": {
translation: {
"My Profile": "我的资料",
"Publish": "发布",
"Log out": "退出登录",
"Are you sure you want to log out?": "您确定要退出登录吗?",
"Cancel": "取消",
"Confirm": "确认",
"Search": "搜索",
"Login": "登录",
"Register": "注册",
"Username": "用户名",
"Password": "密码",
"Confirm Password": "确认密码",
"Username and password cannot be empty": "用户名和密码不能为空",
"Passwords do not match": "两次输入的密码不匹配",
"Continue": "继续",
"Don't have an account? Register": "没有账号?注册",
"Already have an account? Login": "已有账号?登录",
"Publish Resource": "发布资源",
"All information, images, and files can be modified after publishing": "所有的信息, 图片, 文件均可在发布后修改",
"Title": "标题",
"Alternative Titles": "其他标题",
"Add Alternative Title": "新增标题",
"Tags": "标签",
"Description": "介绍",
"Use Markdown format": "使用Markdown格式",
"Images": "图片",
"Images will not be displayed automatically, you need to reference them in the description": "图片不会被自动显示, 你需要在介绍中引用它们",
"Preview": "预览",
"Link": "链接",
"Action": "操作",
"Upload Image": "上传图片",
"Error": "错误",
"Title cannot be empty": "标题不能为空",
"Alternative title cannot be empty": "不能存在空标题",
"At least one tag required": "至少选择一个标签",
"Description cannot be empty": "介绍不能为空",
"Loading": "加载中",
"Enter a search keyword to continue": "输入搜索关键词以继续",
// Management page translations
"Manage": "管理",
"Storage": "存储",
"Users": "用户",
"You are not logged in. Please log in to access this page.": "您尚未登录。请登录以访问此页面。",
"You are not authorized to access this page.": "您无权访问此页面。",
// Storage management
"No storage found. Please create a new storage.": "未找到存储。请创建新的存储。",
"Name": "名称",
"Created At": "创建于",
"Actions": "操作",
"Delete Storage": "删除存储",
"Are you sure you want to delete this storage? This action cannot be undone.": "您确定要删除此存储吗?此操作不可撤销。",
"Delete": "删除",
"Storage deleted successfully": "存储已成功删除",
"New Storage": "新建存储",
"Type": "类型",
"Local": "本地",
"S3": "S3",
"Path": "路径",
"Max Size (MB)": "最大大小 (MB)",
"Endpoint": "终端节点",
"Access Key ID": "访问密钥 ID",
"Secret Access Key": "私有访问密钥",
"Bucket Name": "桶名称",
"All fields are required": "所有字段都是必填的",
"Storage created successfully": "存储创建成功",
"Close": "关闭",
"Submit": "提交",
// User management
"Admin": "管理员",
"Can Upload": "可上传",
"Yes": "是",
"No": "否",
"Delete User": "删除用户",
"Are you sure you want to delete user": "您确定要删除用户",
"This action cannot be undone.": "此操作不可撤销。",
"User deleted successfully": "用户已成功删除",
"Set as user": "设为普通用户",
"Set as admin": "设为管理员",
"Remove upload permission": "移除上传权限",
"Grant upload permission": "授予上传权限",
"User set as admin successfully": "用户已成功设为管理员",
"User set as user successfully": "用户已成功设为普通用户",
"User set as upload permission successfully": "用户已成功授予上传权限",
"User removed upload permission successfully": "用户已成功移除上传权限",
}
},
"zh-TW": {
translation: {
"My Profile": "我的資料",
"Publish": "發布",
"Log out": "登出",
"Are you sure you want to log out?": "您確定要登出嗎?",
"Cancel": "取消",
"Confirm": "確認",
"Search": "搜尋",
"Login": "登入",
"Register": "註冊",
"Username": "用戶名",
"Password": "密碼",
"Confirm Password": "確認密碼",
"Username and password cannot be empty": "用戶名和密碼不能為空",
"Passwords do not match": "兩次輸入的密碼不匹配",
"Continue": "繼續",
"Don't have an account? Register": "沒有賬號?註冊",
"Already have an account? Login": "已有賬號?登入",
"Publish Resource": "發布資源",
"All information, images, and files can be modified after publishing": "所有資訊、圖片、檔案均可於發布後修改",
"Title": "標題",
"Alternative Titles": "其他標題",
"Add Alternative Title": "新增標題",
"Tags": "標籤",
"Description": "介紹",
"Use Markdown format": "使用Markdown格式",
"Images": "圖片",
"Images will not be displayed automatically, you need to reference them in the description": "圖片不會自動顯示,需在介紹中引用",
"Preview": "預覽",
"Link": "連結",
"Action": "操作",
"Upload Image": "上傳圖片",
"Error": "錯誤",
"Title cannot be empty": "標題不能為空",
"Alternative title cannot be empty": "不能有空的標題",
"At least one tag required": "至少選擇一個標籤",
"Description cannot be empty": "介紹不能為空",
"Loading": "載入中",
"Enter a search keyword to continue": "輸入搜尋關鍵字以繼續",
// Management page translations
"Manage": "管理",
"Storage": "儲存",
"Users": "用戶",
"You are not logged in. Please log in to access this page.": "您尚未登入。請登入以訪問此頁面。",
"You are not authorized to access this page.": "您無權訪問此頁面。",
// Storage management
"No storage found. Please create a new storage.": "未找到儲存。請創建新的儲存。",
"Name": "名稱",
"Created At": "建立於",
"Actions": "操作",
"Delete Storage": "刪除儲存",
"Are you sure you want to delete this storage? This action cannot be undone.": "您確定要刪除此儲存嗎?此操作不可撤銷。",
"Delete": "刪除",
"Storage deleted successfully": "儲存已成功刪除",
"New Storage": "新建儲存",
"Type": "類型",
"Local": "本地",
"S3": "S3",
"Path": "路徑",
"Max Size (MB)": "最大大小 (MB)",
"Endpoint": "端點",
"Access Key ID": "訪問密鑰 ID",
"Secret Access Key": "私有訪問密鑰",
"Bucket Name": "儲存桶名稱",
"All fields are required": "所有欄位都是必填的",
"Storage created successfully": "儲存創建成功",
"Close": "關閉",
"Submit": "提交",
// User management
"Admin": "管理員",
"Can Upload": "可上傳",
"Yes": "是",
"No": "否",
"Delete User": "刪除用戶",
"Are you sure you want to delete user": "您確定要刪除用戶",
"This action cannot be undone.": "此操作不可撤銷。",
"User deleted successfully": "用戶已成功刪除",
"Set as user": "設為普通用戶",
"Set as admin": "設為管理員",
"Remove upload permission": "移除上傳權限",
"Grant upload permission": "授予上傳權限",
"User set as admin successfully": "用戶已成功設為管理員",
"User set as user successfully": "用戶已成功設為普通用戶",
"User set as upload permission successfully": "用戶已成功授予上傳權限",
"User removed upload permission successfully": "用戶已成功移除上傳權限",
}
}
}

113
frontend/src/index.css Normal file
View File

@@ -0,0 +1,113 @@
@import "tailwindcss";
@plugin "daisyui";
@plugin "daisyui/theme" {
name: "light";
default: false;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(98% 0 0);
--color-base-200: oklch(97% 0 0);
--color-base-300: oklch(92% 0 0);
--color-base-content: oklch(20% 0 0);
--color-primary: oklch(80% 0.114 19.571);
--color-primary-content: oklch(25% 0.092 26.042);
--color-secondary: oklch(81% 0.111 293.571);
--color-secondary-content: oklch(28% 0.141 291.089);
--color-accent: oklch(90% 0.182 98.111);
--color-accent-content: oklch(28% 0.066 53.813);
--color-neutral: oklch(14% 0 0);
--color-neutral-content: oklch(98% 0 0);
--color-info: oklch(70% 0.165 254.624);
--color-info-content: oklch(28% 0.091 267.935);
--color-success: oklch(84% 0.238 128.85);
--color-success-content: oklch(27% 0.072 132.109);
--color-warning: oklch(82% 0.189 84.429);
--color-warning-content: oklch(27% 0.077 45.635);
--color-error: oklch(71% 0.194 13.428);
--color-error-content: oklch(27% 0.105 12.094);
--radius-selector: 0.25rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 1;
}
@plugin "daisyui/theme" {
name: "valentine";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(98% 0.014 343.198);
--color-base-200: oklch(96% 0.028 342.258);
--color-base-300: oklch(84% 0.061 343.231);
--color-base-content: oklch(0% 0 0);
--color-primary: oklch(65% 0.241 354.308);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(62% 0.265 303.9);
--color-secondary-content: oklch(97% 0.014 308.299);
--color-accent: oklch(78% 0.115 274.713);
--color-accent-content: oklch(39% 0.09 240.876);
--color-neutral: oklch(40% 0.153 2.432);
--color-neutral-content: oklch(89% 0.061 343.231);
--color-info: oklch(80% 0.105 251.813);
--color-info-content: oklch(44% 0.11 240.79);
--color-success: oklch(70% 0.14 182.503);
--color-success-content: oklch(43% 0.095 166.913);
--color-warning: oklch(75% 0.183 55.934);
--color-warning-content: oklch(26% 0.079 36.259);
--color-error: oklch(63% 0.237 25.331);
--color-error-content: oklch(97% 0.013 17.38);
--radius-selector: 1rem;
--radius-field: 2rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}
html {
width: 100%;
height: 100%;
overflow-y: scroll;
}
body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
#root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
@keyframes appearance-in {
0% {
opacity: 0;
transform: translateZ(0) scale(0.95);
}
60% {
opacity: 0.75;
backface-visibility: hidden;
webkit-font-smoothing: antialiased;
transform: translateZ(0) scale(1.05);
}
100% {
opacity: 1;
transform: translateZ(0) scale(1);
}
}
.animate-appearance-in {
animation: appearance-in 250ms ease-out normal both;
}

26
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,26 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from "./app.tsx";
import i18n from "i18next";
import {initReactI18next} from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import {i18nData} from "./i18n.ts";
i18n
.use(initReactI18next)
.use(LanguageDetector)
.init({
resources: i18nData,
debug: true,
fallbackLng: "en",
interpolation: {
escapeValue: false
}
}).then(() => {
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App/>
</StrictMode>,
)
})

81
frontend/src/markdown.css Normal file
View File

@@ -0,0 +1,81 @@
article {
h1 {
font-size: 20px;
font-weight: bold;
padding: 12px 0;
}
h2 {
font-size: 18px;
font-weight: bold;
padding: 10px 0;
}
h3 {
font-size: 16px;
font-weight: bold;
padding: 8px 0;
}
h4 {
font-size: 14px;
font-weight: bold;
padding: 6px 0;
}
h5 {
font-size: 12px;
font-weight: bold;
padding: 4px 0;
}
h6 {
font-size: 10px;
font-weight: bold;
padding: 2px 0;
}
p {
font-size: 14px;
line-height: 1.5;
margin: 8px 0;
}
ul {
list-style-type: disc;
margin: 0 0 16px 20px;
padding: 0;
li {
font-size: 14px;
line-height: 1.5;
margin: 0 0 8px;
}
}
ol {
list-style-type: decimal;
margin: 0 0 16px 20px;
padding: 0;
li {
font-size: 14px;
line-height: 1.5;
margin: 0 0 8px;
}
}
blockquote {
font-size: 14px;
line-height: 1.5;
margin: 0 0 16px;
padding: 8px;
border-left: 4px solid var(--color-base-300);
background-color: var(--color-base-200);
}
hr {
border: 0;
border-top: 1px solid var(--color-base-300);
margin: 16px 0;
}
a {
color: var(--color-primary);
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
img {
border-radius: 8px;
max-height: 400px;
}
}

View File

@@ -0,0 +1,91 @@
export interface User {
id: number;
username: string;
created_at: string;
avatar_path: string;
is_admin: boolean;
can_upload: boolean;
}
export interface UserWithToken extends User {
token: string;
}
export interface Response<T> {
success: boolean;
message: string;
data?: T;
}
export interface PageResponse<T> {
success: boolean;
message: string;
data?: T[];
totalPages?: number;
}
export interface Tag {
id: number;
name: string;
}
export interface CreateResourceParams {
title: string;
alternative_titles: string[];
tags: number[];
article: string;
images: number[];
}
export interface Image {
id: number;
width: number;
height: number;
}
export interface Resource {
id: number;
title: string;
created_at: string;
tags: Tag[];
image?: Image;
author: User;
}
export interface ResourceDetails {
id: number;
title: string;
alternativeTitles: string[];
article: string;
createdAt: string;
tags: Tag[];
images: Image[];
files: RFile[];
author: User;
}
export interface Storage {
id: number;
name: string;
type: string;
maxSize: number;
currentSize: number;
createdAt: string;
}
export interface RFile {
id: number;
filename: string;
description: string;
}
export interface UploadingFile {
id: number;
filename: string;
description: string;
totalSize?: number;
blockSize: number;
blocksCount: number;
storageId: number;
resourceId: number;
}

View File

@@ -0,0 +1,525 @@
import axios from 'axios';
import {app} from "../app.ts";
import {
CreateResourceParams,
RFile,
PageResponse,
Resource,
ResourceDetails,
Response,
Storage,
Tag,
UploadingFile,
User,
UserWithToken
} from "./models.ts";
class Network {
baseUrl = ''
apiBaseUrl = '/api'
constructor() {
this.init()
}
init() {
if (import.meta.env.MODE === 'development') {
this.baseUrl = 'http://localhost:3000';
this.apiBaseUrl = 'http://localhost:3000/api';
}
axios.defaults.validateStatus = _ => true
axios.interceptors.request.use((config) => {
if (app.token) {
config.headers['Authorization'] = app.token;
}
return config
})
axios.interceptors.response.use(
(response) => {
if (response.status >= 400 && response.status < 500) {
const data = response.data;
if (data.message) {
throw new Error(data.message);
} else {
throw new Error(`Invalid response: ${response.status}`);
}
} else if (response.status >= 500) {
throw new Error(`Server error: ${response.status}`);
} else {
return response
}
},
(error) => {
return Promise.reject(error);
})
}
async login(username: string, password: string): Promise<Response<UserWithToken>> {
try {
const response = await axios.postForm(`${this.apiBaseUrl}/user/login`, {
username,
password
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async register(username: string, password: string): Promise<Response<UserWithToken>> {
try {
const response = await axios.postForm(`${this.apiBaseUrl}/user/register`, {
username,
password
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async changePassword(oldPassword: string, newPassword: string): Promise<Response<UserWithToken>> {
try {
const response = await axios.postForm(`${this.apiBaseUrl}/user/password`, {
old_password: oldPassword,
new_password: newPassword
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async changeAvatar(file: File): Promise<Response<User>> {
try {
const formData = new FormData();
formData.append('avatar', file);
const response = await axios.put(`${this.apiBaseUrl}/user/avatar`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
getUserAvatar(user: User): string {
return this.baseUrl + user.avatar_path
}
async setUserAdmin(userId: number, isAdmin: boolean): Promise<Response<User>> {
try {
const response = await axios.postForm(`${this.apiBaseUrl}/user/set_admin`, {
user_id: userId,
is_admin: isAdmin ? 'true' : 'false'
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async setUserUploadPermission(userId: number, canUpload: boolean): Promise<Response<User>> {
try {
const response = await axios.postForm(`${this.apiBaseUrl}/user/set_upload_permission`, {
user_id: userId,
can_upload: canUpload ? 'true' : 'false'
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async listUsers(page: number): Promise<PageResponse<User>> {
try {
const response = await axios.get(`${this.apiBaseUrl}/user/list`, {
params: { page }
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async searchUsers(username: string, page: number): Promise<PageResponse<User>> {
try {
const response = await axios.get(`${this.apiBaseUrl}/user/search`, {
params: {
username,
page
}
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async deleteUser(userId: number): Promise<Response<void>> {
try {
const response = await axios.postForm(`${this.apiBaseUrl}/user/delete`, {
user_id: userId
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async searchTags(keyword: string): Promise<Response<Tag[]>> {
try {
const response = await axios.get(`${this.apiBaseUrl}/tag/search`, {
params: {
keyword
}
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async createTag(name: string): Promise<Response<Tag>> {
try {
const response = await axios.postForm(`${this.apiBaseUrl}/tag`, {
name
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
/**
* Upload image and return the image id
*/
async uploadImage(file: File): Promise<Response<number>> {
try {
const data = await file.arrayBuffer()
const response = await axios.put(`${this.apiBaseUrl}/image`, data, {
headers: {
'Content-Type': 'application/octet-stream',
},
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async deleteImage(id: number): Promise<Response<void>> {
try {
const response = await axios.delete(`${this.apiBaseUrl}/image/${id}`)
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
getImageUrl(id: number): string {
return `${this.apiBaseUrl}/image/${id}`
}
async createResource(params: CreateResourceParams): Promise<Response<number>> {
console.log(this)
try {
const response = await axios.post(`${this.apiBaseUrl}/resource`, params)
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async getResources(page: number): Promise<PageResponse<Resource>> {
try {
const response = await axios.get(`${this.apiBaseUrl}/resource`, {
params: {
page
}
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async searchResources(keyword: string, page: number): Promise<PageResponse<Resource>> {
try {
const response = await axios.get(`${this.apiBaseUrl}/resource/search`, {
params: {
keyword,
page
}
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async getResourceDetails(id: number): Promise<Response<ResourceDetails>> {
try {
const response = await axios.get(`${this.apiBaseUrl}/resource/${id}`)
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async createS3Storage(name: string, endPoint: string, accessKeyID: string,
secretAccessKey: string, bucketName: string, maxSizeInMB: number): Promise<Response<any>> {
try {
const response = await axios.post(`${this.apiBaseUrl}/storage/s3`, {
name,
endPoint,
accessKeyID,
secretAccessKey,
bucketName,
maxSizeInMB
});
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async createLocalStorage(name: string, path: string, maxSizeInMB: number): Promise<Response<any>> {
try {
const response = await axios.post(`${this.apiBaseUrl}/storage/local`, {
name,
path,
maxSizeInMB
});
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async listStorages(): Promise<Response<Storage[]>> {
try {
const response = await axios.get(`${this.apiBaseUrl}/storage`);
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async deleteStorage(id: number): Promise<Response<void>> {
try {
const response = await axios.delete(`${this.apiBaseUrl}/storage/${id}`);
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async initFileUpload(filename: string, description: string, fileSize: number,
resourceId: number, storageId: number): Promise<Response<UploadingFile>> {
try {
const response = await axios.post(`${this.apiBaseUrl}/files/upload/init`, {
filename,
description,
file_size: fileSize,
resource_id: resourceId,
storage_id: storageId
});
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async uploadFileBlock(fileId: number, index: number, blockData: Blob): Promise<Response<any>> {
try {
const formData = new FormData();
formData.append('block', blockData);
const response = await axios.post(
`${this.apiBaseUrl}/files/upload/block/${fileId}/${index}`,
formData,
{
headers: {
'Content-Type': 'multipart/form-data'
}
}
);
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async finishFileUpload(fileId: number): Promise<Response<RFile>> {
try {
const response = await axios.post(`${this.apiBaseUrl}/files/upload/finish/${fileId}`);
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async createRedirectFile(filename: string, description: string,
resourceId: number, redirectUrl: string): Promise<Response<RFile>> {
try {
const response = await axios.post(`${this.apiBaseUrl}/files/redirect`, {
filename,
description,
resource_id: resourceId,
redirect_url: redirectUrl
});
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async getFile(fileId: number): Promise<Response<RFile>> {
try {
const response = await axios.get(`${this.apiBaseUrl}/files/${fileId}`);
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async updateFile(fileId: number, filename: string, description: string): Promise<Response<RFile>> {
try {
const response = await axios.put(`${this.apiBaseUrl}/files/${fileId}`, {
filename,
description
});
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
async deleteFile(fileId: number): Promise<Response<void>> {
try {
const response = await axios.delete(`${this.apiBaseUrl}/files/${fileId}`);
return response.data;
} catch (e: any) {
console.error(e);
return {
success: false,
message: e.toString(),
};
}
}
}
export const network = new Network();

View File

@@ -0,0 +1,6 @@
import ResourcesView from "../components/resources_view.tsx";
import {network} from "../network/network.ts";
export default function HomePage() {
return <ResourcesView loader={(page) => network.getResources(page)}></ResourcesView>
}

View File

@@ -0,0 +1,69 @@
import {FormEvent, useState} from "react";
import {network} from "../network/network.ts";
import {app} from "../app.ts";
import {useNavigate} from "react-router";
import {useTranslation} from "react-i18next";
export default function LoginPage() {
const {t} = useTranslation();
const [isLoading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const navigate = useNavigate();
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!username || !password) {
setError(t("Username and password cannot be empty"));
return;
}
setLoading(true);
const res = await network.login(username, password);
if (res.success) {
app.user = res.data!;
app.token = res.data!.token;
app.saveData();
navigate("/", {replace: true});
} else {
setError(res.message);
setLoading(false);
}
};
return <div className={"flex items-center justify-center w-full h-full bg-base-200"} id={"login-page"}>
<div className={"w-96 card card-border bg-base-100 border-base-300"}>
<form onSubmit={onSubmit}>
<div className={"card-body"}>
<h1 className={"text-2xl font-bold"}>{t("Login")}</h1>
{error && <div role="alert" className="alert alert-error my-2">
<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"/>
</svg>
<span>{error}</span>
</div>}
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">{t("Username")}</legend>
<input type="text" className="input w-full" value={username} onChange={(e) => setUsername(e.target.value)}/>
</fieldset>
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">{t("Password")}</legend>
<input type="password" className="input w-full" value={password} onChange={(e) => setPassword(e.target.value)}/>
</fieldset>
<button className={"btn my-4 btn-primary"} type={"submit"}>
{isLoading && <span className="loading loading-spinner"></span>}
{t("Continue")}
</button>
<button className="btn" type={"button"} onClick={() => {
navigate("/register", {replace: true});
}}>
{t("Don't have an account? Register")}
</button>
</div>
</form>
</div>
</div>
}

View File

@@ -0,0 +1,87 @@
import {app} from "../app.ts";
import {MdMenu, MdOutlinePerson, MdOutlineStorage} from "react-icons/md";
import {ReactNode, useEffect, useState} from "react";
import StorageView from "./manage_storage_page.tsx";
import UserView from "./manage_user_page.tsx";
import { useTranslation } from "react-i18next";
import {ErrorAlert} from "../components/alert.tsx";
export default function ManagePage() {
const { t } = useTranslation();
const [page, setPage] = useState(0);
const [lg, setLg] = useState(window.innerWidth >= 1024);
useEffect(() => {
const handleResize = () => {
setLg(window.innerWidth >= 1024);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
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.")}/>
}
const buildItem = (title: string, icon: ReactNode, p: number) => {
return <li key={title} onClick={() => setPage(p)} className={"my-1"}>
<a className={`flex items-center h-9 px-4 ${page == p && "bg-primary text-primary-content"}`}>
{icon}
<span className={"text"}>
{title}
</span>
</a>
</li>
}
const pageNames = [
t("Storage"),
t("Users")
]
const pageComponents = [
<StorageView/>,
<UserView/>
]
return <div className="drawer lg:drawer-open">
<input id="my-drawer-2" type="checkbox" className="drawer-toggle"/>
<div className="drawer-content" style={{
height: "calc(100vh - 64px)",
}}>
<div className={"flex w-full h-14 items-center gap-2 px-3"}>
<label className={"btn btn-square btn-ghost lg:hidden"} htmlFor="my-drawer-2">
<MdMenu size={24}/>
</label>
<h1 className={"text-xl font-bold"}>
{pageNames[page]}
</h1>
</div>
<div>
{pageComponents[page]}
</div>
</div>
<div className="drawer-side" style={{
height: lg ? "calc(100vh - 64px)" : "100vh",
}}>
<label htmlFor="my-drawer-2" aria-label="close sidebar" className="drawer-overlay"></label>
<ul className="menu bg-base-100 min-h-full lg:min-h-0 w-72 px-4 lg:mt-1">
<h2 className={"text-lg font-bold p-4"}>
{t("Manage")}
</h2>
{buildItem(t("Storage"), <MdOutlineStorage className={"text-xl"}/>, 0)}
{buildItem(t("Users"), <MdOutlinePerson className={"text-xl"}/>, 1)}
</ul>
</div>
</div>
}

View File

@@ -0,0 +1,336 @@
import {useEffect, useState} from "react";
import {Storage} from "../network/models.ts";
import {network} from "../network/network.ts";
import showToast from "../components/toast.ts";
import Loading from "../components/loading.tsx";
import {MdAdd, MdDelete} from "react-icons/md";
import {ErrorAlert} from "../components/alert.tsx";
import { useTranslation } from "react-i18next";
export default function StorageView() {
const { t } = useTranslation();
const [storages, setStorages] = useState<Storage[] | null>(null);
const [loadingId, setLoadingId] = useState<number | null>(null);
useEffect(() => {
network.listStorages().then((response) => {
if (response.success) {
setStorages(response.data!);
} else {
showToast({
message: response.message,
type: "error"
});
}
})
}, []);
if (storages == null) {
return <Loading/>
}
const updateStorages = async () => {
setStorages(null)
const response = await network.listStorages();
if (response.success) {
setStorages(response.data!);
} else {
showToast({
message: response.message,
type: "error"
});
}
}
const handleDelete = async (id: number) => {
if (loadingId != null) {
return;
}
setLoadingId(id);
const response = await network.deleteStorage(id);
if (response.success) {
showToast({
message: t("Storage deleted successfully"),
});
updateStorages();
} else {
showToast({
message: response.message,
type: "error"
});
}
setLoadingId(null);
}
return <>
<div role="alert" className={`alert alert-info alert-outline ${storages.length !== 0 && "hidden"} mx-4 mb-4`}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
className="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>
{t("No storage found. Please create a new storage.")}
</span>
</div>
<div className={`rounded-box border border-base-content/10 bg-base-100 mx-4 mb-4 overflow-x-auto ${storages.length === 0 ? "hidden" : ""}`}>
<table className={"table"}>
<thead>
<tr>
<td>{t("Name")}</td>
<td>{t("Created At")}</td>
<td>{t("Action")}</td>
</tr>
</thead>
<tbody>
{
storages.map((s) => {
return <tr key={s.id} className={"hover"}>
<td>
{s.name}
</td>
<td>
{(new Date(s.createdAt)).toLocaleString()}
</td>
<td>
<button className={"btn btn-square"} type={"button"} onClick={() => {
const dialog = document.getElementById(`confirm_delete_dialog_${s.id}`) as HTMLDialogElement;
dialog.showModal();
}}>
{loadingId === s.id ? <span className={"loading loading-spinner loading-sm"}></span> : <MdDelete size={24}/>}
</button>
<dialog id={`confirm_delete_dialog_${s.id}`} className="modal">
<div className="modal-box">
<h3 className="text-lg font-bold">{t("Delete Storage")}</h3>
<p className="py-4">
{t("Are you sure you want to delete this storage? This action cannot be undone.")}
</p>
<div className="modal-action">
<form method="dialog">
<button className="btn">{t("Cancel")}</button>
</form>
<button className="btn btn-error" onClick={() => {
handleDelete(s.id);
}}>
{t("Delete")}
</button>
</div>
</div>
</dialog>
</td>
</tr>
})
}
</tbody>
</table>
</div>
<div className={"flex flex-row-reverse px-4"}>
<NewStorageDialog onAdded={updateStorages}/>
</div>
</>
}
enum StorageType {
local,
s3,
}
function NewStorageDialog({onAdded}: { onAdded: () => void }) {
const { t } = useTranslation();
const [storageType, setStorageType] = useState<StorageType | null>(null);
const [params, setParams] = useState({
name: "",
path: "",
endPoint: "",
accessKeyID: "",
secretAccessKey: "",
bucketName: "",
maxSizeInMB: 0,
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async () => {
if (storageType == null) {
return;
}
setIsLoading(true);
let response;
if (storageType === StorageType.local) {
if (params.path === "" || params.name === "" || params.maxSizeInMB <= 0) {
setError(t("All fields are required"));
setIsLoading(false);
return;
}
response = await network.createLocalStorage(params.name, params.path, params.maxSizeInMB);
} else if (storageType === StorageType.s3) {
if (params.endPoint === "" || params.accessKeyID === "" || params.secretAccessKey === "" || params.bucketName === "" || params.name === "" || params.maxSizeInMB <= 0) {
setError(t("All fields are required"));
setIsLoading(false);
return;
}
response = await network.createS3Storage(params.name, params.endPoint, params.accessKeyID, params.secretAccessKey, params.bucketName, params.maxSizeInMB);
}
if (response!.success) {
showToast({
message: t("Storage created successfully"),
});
onAdded();
const dialog = document.getElementById("new_storage_dialog") as HTMLDialogElement;
dialog.close();
} else {
setError(response!.message);
}
setIsLoading(false);
}
return <>
<button className="btn" onClick={()=> {
const dialog = document.getElementById("new_storage_dialog") as HTMLDialogElement;
dialog.showModal();
}}>
<MdAdd/>
{t("New Storage")}
</button>
<dialog id="new_storage_dialog" className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg pb-4">{t("New Storage")}</h3>
<p className={"text-sm font-bold p-2"}>{t("Type")}</p>
<form className="filter mb-2">
<input className="btn btn-square" type="reset" value="×" onClick={() => {
setStorageType(null);
}}/>
<input className="btn" type="radio" name="type" aria-label={t("Local")} onInput={() => {
setStorageType(StorageType.local);
}}/>
<input className="btn" type="radio" name="type" aria-label={t("S3")} onInput={() => {
setStorageType(StorageType.s3);
}}/>
</form>
{
storageType === StorageType.local && <>
<label className="input w-full my-2">
{t("Name")}
<input type="text" className="w-full" value={params.name} onChange={(e) => {
setParams({
...params,
name: e.target.value,
})
}}/>
</label>
<label className="input w-full my-2">
{t("Path")}
<input type="text" className="w-full" value={params.path} onChange={(e) => {
setParams({
...params,
path: e.target.value,
})
}}/>
</label>
<label className="input w-full my-2">
{t("Max Size (MB)")}
<input
type="number"
className="validator"
required
min="0"
value={params.maxSizeInMB.toString()}
onChange={(e) => {
setParams({
...params,
maxSizeInMB: parseInt(e.target.value),
})
}}
/>
</label>
</>
}
{
storageType === StorageType.s3 && <>
<label className="input w-full my-2">
{t("Name")}
<input type="text" className="w-full" value={params.name} onChange={(e) => {
setParams({
...params,
name: e.target.value,
})
}}/>
</label>
<label className="input w-full my-2">
{t("Endpoint")}
<input type="text" className="w-full" value={params.endPoint} onChange={(e) => {
setParams({
...params,
endPoint: e.target.value,
})
}}/>
</label>
<label className="input w-full my-2">
{t("Access Key ID")}
<input type="text" className="w-full" value={params.accessKeyID} onChange={(e) => {
setParams({
...params,
accessKeyID: e.target.value,
})
}}/>
</label>
<label className="input w-full my-2">
{t("Secret Access Key")}
<input type="text" className="w-full" value={params.secretAccessKey} onChange={(e) => {
setParams({
...params,
secretAccessKey: e.target.value,
})
}}/>
</label>
<label className="input w-full my-2">
{t("Bucket Name")}
<input type="text" className="w-full" value={params.bucketName} onChange={(e) => {
setParams({
...params,
bucketName: e.target.value,
})
}}/>
</label>
<label className="input w-full my-2">
{t("Max Size (MB)")}
<input
type="number"
className="validator"
required
min="0"
value={params.maxSizeInMB.toString()}
onChange={(e) => {
setParams({
...params,
maxSizeInMB: parseInt(e.target.value),
})
}}
/>
</label>
</>
}
{error !== "" && <ErrorAlert message={error} className={"my-2"}/>}
<div className="modal-action">
<form method="dialog">
<button className="btn btn-ghost">{t("Close")}</button>
</form>
<button className={"btn btn-primary"} onClick={handleSubmit} type={"button"}>
{isLoading && <span className={"loading loading-spinner loading-sm mr-2"}></span>}
{t("Submit")}
</button>
</div>
</div>
</dialog>
</>
}

View File

@@ -0,0 +1,267 @@
import { createRef, useCallback, useEffect, useState } from "react";
import { User } from "../network/models";
import { network } from "../network/network";
import showToast from "../components/toast";
import Loading from "../components/loading";
import { MdMoreHoriz, MdSearch } from "react-icons/md";
import Pagination from "../components/pagination";
import showPopup, { PopupMenuItem } from "../components/popup";
import { useTranslation } from "react-i18next";
export default function UserView() {
const { t } = useTranslation();
const [searchKeyword, setSearchKeyword] = useState("");
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
return <>
<div className={"flex flex-row justify-between items-center mx-4 my-4"}>
<form className={"flex flex-row gap-2 items-center w-64"} onSubmit={(e) => {
e.preventDefault();
setPage(0);
const input = e.currentTarget.querySelector("input[type=search]") as HTMLInputElement;
setSearchKeyword(input.value);
}}>
<label className="input">
<MdSearch size={20} className="opacity-50" />
<input type="search" className="grow" placeholder={t("Search")} id="search" />
</label>
</form>
</div>
<UserTable page={page} searchKeyword={searchKeyword} key={`${page}&${searchKeyword}`} totalPagesCallback={setTotalPages} />
<div className={"flex flex-row justify-center items-center my-4"}>
{totalPages ? <Pagination page={page} setPage={setPage} totalPages={totalPages} /> : null}
</div>
</>
}
function UserTable({ page, searchKeyword, totalPagesCallback }: { page: number, searchKeyword: string, totalPagesCallback: (totalPages: number) => void }) {
const { t } = useTranslation();
const [users, setUsers] = useState<User[] | null>(null);
const fetchUsers = useCallback(() => {
if (searchKeyword) {
network.searchUsers(searchKeyword, page).then((response) => {
if (response.success) {
setUsers(response.data!);
totalPagesCallback(response.totalPages!);
} else {
showToast({
type: "error",
message: response.message,
})
}
});
} else {
network.listUsers(page).then((response) => {
if (response.success) {
setUsers(response.data!);
totalPagesCallback(response.totalPages!);
} else {
showToast({
type: "error",
message: response.message,
})
}
});
}
}, [page, searchKeyword, totalPagesCallback]);
useEffect(() => {
fetchUsers();
}, [page, searchKeyword]);
const handleChanged = useCallback(async () => {
setUsers(null);
fetchUsers();
}, [fetchUsers]);
if (users === null) {
return <Loading />;
}
return <div className={`rounded-box border border-base-content/10 bg-base-100 mx-4 mb-4 overflow-x-auto`}>
<table className={"table"}>
<thead>
<tr>
<td>{t("Username")}</td>
<td>{t("Created At")}</td>
<td>{t("Admin")}</td>
<td>{t("Can Upload")}</td>
<td>{t("Actions")}</td>
</tr>
</thead>
<tbody>
{
users.map((u) => {
return <UserRow key={u.id} user={u} onChanged={handleChanged} />
})
}
</tbody>
</table>
</div>
}
function UserRow({ user, onChanged }: { user: User, onChanged: () => void }) {
const { t } = useTranslation();
const [isLoading, setIsLoading] = useState(false);
const buttonRef = createRef<HTMLButtonElement>();
const handleDelete = async () => {
if (isLoading) {
return;
}
setIsLoading(true);
const res = await network.deleteUser(user.id);
if (res.success) {
showToast({
type: "success",
message: t("User deleted successfully"),
});
onChanged();
} else {
showToast({
type: "error",
message: res.message,
});
}
setIsLoading(false);
}
const handleSetAdmin = async () => {
if (isLoading) {
return;
}
setIsLoading(true);
const res = await network.setUserAdmin(user.id, true);
if (res.success) {
showToast({
type: "success",
message: t("User set as admin successfully"),
});
onChanged();
} else {
showToast({
type: "error",
message: res.message,
});
}
setIsLoading(false);
}
const handleSetUser = async () => {
if (isLoading) {
return;
}
setIsLoading(true);
const res = await network.setUserAdmin(user.id, false);
if (res.success) {
showToast({
type: "success",
message: t("User set as user successfully"),
});
onChanged();
} else {
showToast({
type: "error",
message: res.message,
});
}
setIsLoading(false);
}
const handleSetUploadPermission = async () => {
if (isLoading) {
return;
}
setIsLoading(true);
const res = await network.setUserUploadPermission(user.id, true);
if (res.success) {
showToast({
type: "success",
message: t("User set as upload permission successfully"),
});
onChanged();
} else {
showToast({
type: "error",
message: res.message,
});
}
setIsLoading(false);
}
const handleRemoveUploadPermission = async () => {
if (isLoading) {
return;
}
setIsLoading(true);
const res = await network.setUserUploadPermission(user.id, false);
if (res.success) {
showToast({
type: "success",
message: t("User removed upload permission successfully"),
});
onChanged();
} else {
showToast({
type: "error",
message: res.message,
});
}
setIsLoading(false);
}
return <tr key={user.id} className={"hover"}>
<td>
{user.username}
</td>
<td>
{(new Date(user.created_at)).toLocaleDateString()}
</td>
<td>
{user.is_admin ? t("Yes") : t("No")}
</td>
<td>
{user.can_upload ? t("Yes") : t("No")}
</td>
<td>
<div className="dropdown dropdown-bottom dropdown-end">
<button ref={buttonRef} className="btn btn-square m-1" 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={() => {
const dialog = document.getElementById(`delete_user_dialog_${user.id}`) as HTMLDialogElement;
dialog.showModal();
}}>
<a>{t("Delete")}</a>
</PopupMenuItem>
{user.is_admin ? <PopupMenuItem onClick={handleSetUser}><a>{t("Set as user")}</a></PopupMenuItem> : <PopupMenuItem onClick={handleSetAdmin}><a>{t("Set as admin")}</a></PopupMenuItem>}
{user.is_admin ? (
user.can_upload ? <PopupMenuItem onClick={handleRemoveUploadPermission}><a>{t("Remove upload permission")}</a></PopupMenuItem> : <PopupMenuItem onClick={handleSetUploadPermission}><a>{t("Grant upload permission")}</a></PopupMenuItem>
) : null}
</ul>, buttonRef.current!);
}}>
{isLoading
? <span className="loading loading-spinner loading-sm"></span>
: <MdMoreHoriz size={20} className="opacity-50" />}
</button>
<dialog id={`delete_user_dialog_${user.id}`} className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg">{t("Delete User")}</h3>
<p className="py-4">{t("Are you sure you want to delete user")} <span className="font-bold">{user.username}</span>? {t("This action cannot be undone.")}</p>
<div className="modal-action">
<form method="dialog">
<button className="btn btn-ghost">{t("Close")}</button>
<button className="btn btn-error" onClick={handleDelete}>{t("Delete")}</button>
</form>
</div>
</div>
</dialog>
</div>
</td>
</tr>
}

View File

@@ -0,0 +1,361 @@
import {useRef, useState} from "react";
import {MdAdd, MdDelete, MdOutlineInfo} from "react-icons/md";
import {Tag} from "../network/models.ts";
import {network} from "../network/network.ts";
import {LuInfo} from "react-icons/lu";
import {useNavigate} from "react-router";
import showToast from "../components/toast.ts";
import {useTranslation} from "react-i18next";
import {app} from "../app.ts";
import {ErrorAlert} from "../components/alert.tsx";
export default function PublishPage() {
const [title, setTitle] = useState<string>("")
const [altTitles, setAltTitles] = useState<string[]>([])
const [tags, setTags] = useState<Tag[]>([])
const [article, setArticle] = useState<string>("")
const [images, setImages] = useState<number[]>([])
const [isUploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setSubmitting] = useState(false)
const navigate = useNavigate()
const { t } = useTranslation();
const handleSubmit = async () => {
if (isSubmitting) {
return
}
if (!title) {
setError(t("Title cannot be empty"))
return
}
for (let i = 0; i < altTitles.length; i++) {
if (!altTitles[i]) {
setError(t("Alternative title cannot be empty"))
return
}
}
if (!tags || tags.length === 0) {
setError(t("At least one tag required"))
return
}
if (!article) {
setError(t("Description cannot be empty"))
return
}
const res = await network.createResource({
title: title,
alternative_titles: altTitles,
tags: tags.map((tag) => tag.id),
article: article,
images: images,
})
if (res.success) {
setSubmitting(false)
navigate("/resources/" + res.data!, {replace: true})
} else {
setSubmitting(false)
setError(res.message)
}
}
const addImage = () => {
if (isUploading) {
return
}
const input = document.createElement("input")
input.type = "file"
input.accept = "image/*"
input.onchange = async () => {
const files = input.files
if (!files || files.length === 0) {
return
}
const image = files[0]
setUploading(true)
const res = await network.uploadImage(image)
if (res.success) {
setUploading(false)
setImages([...images, res.data!])
} else {
setUploading(false)
showToast({message: t("Failed to upload image"), type: "error"})
}
}
input.click()
}
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.")}/>
}
return <div className={"p-4"}>
<h1 className={"text-2xl font-bold my-4"}>{t("Publish Resource")}</h1>
<div role="alert" className="alert alert-info mb-2 alert-dash">
<MdOutlineInfo size={24}/>
<span>{t("All information, images, and files can be modified after publishing")}</span>
</div>
<p className={"my-1"}>{t("Title")}</p>
<input type="text" className="input w-full" value={title} onChange={(e) => setTitle(e.target.value)}/>
<div className={"h-4"}></div>
<p className={"my-1"}>{t("Alternative Titles")}</p>
{
altTitles.map((title, index) => {
return <div key={index} className={"flex items-center my-2"}>
<input type="text" className="input w-full" value={title} onChange={(e) => {
const newAltTitles = [...altTitles]
newAltTitles[index] = e.target.value
setAltTitles(newAltTitles)
}}/>
<button className={"btn btn-square btn-error ml-2"} type={"button"} onClick={() => {
const newAltTitles = [...altTitles]
newAltTitles.splice(index, 1)
setAltTitles(newAltTitles)
}}>
<MdDelete size={24}/>
</button>
</div>
})
}
<button className={"btn my-2"} type={"button"} onClick={() => {
setAltTitles([...altTitles, ""])
}}>
<MdAdd/>
{t("Add Alternative Title")}
</button>
<div className={"h-2"}></div>
<p className={"my-1"}>{t("Tags")}</p>
<p className={"my-1 pb-1"}>
{
tags.map((tag, index) => {
return <span key={index} className={"badge badge-primary mr-2"}>{tag.name}</span>
})
}
</p>
<TagInput onAdd={(tag) => {
setTags([...tags, tag])
}}/>
<div className={"h-4"}></div>
<p className={"my-1"}>{t("Description")}</p>
<textarea className="textarea w-full min-h-80 p-4" value={article} onChange={(e) => setArticle(e.target.value)}/>
<div className={"flex items-center py-1 "}>
<MdOutlineInfo className={"inline mr-1"}/>
<span className={"text-sm"}>{t("Use Markdown format")}</span>
</div>
<div className={"h-4"}></div>
<p className={"my-1"}>{t("Images")}</p>
<div role="alert" className="alert alert-info alert-soft my-2">
<MdOutlineInfo size={24}/>
<span>{t("Images will not be displayed automatically, you need to reference them in the description")}</span>
</div>
<div className={`rounded-box border border-base-content/5 bg-base-100 ${images.length === 0 ? "hidden" : ""}`}>
<table className={"table"}>
<thead>
<tr>
<td>{t("Preview")}</td>
<td>{t("Link")}</td>
<td>{t("Action")}</td>
</tr>
</thead>
<tbody>
{
images.map((image, index) => {
return <tr key={index} className={"hover"}>
<td>
<img src={network.getImageUrl(image)} className={"w-16 h-16 object-cover card"} alt={"image"}/>
</td>
<td>
{network.getImageUrl(image)}
</td>
<td>
<button className={"btn btn-square"} type={"button"} onClick={() => {
const id = images[index]
const newImages = [...images]
newImages.splice(index, 1)
setImages(newImages)
network.deleteImage(id)
}}>
<MdDelete size={24}/>
</button>
</td>
</tr>
})
}
</tbody>
</table>
</div>
<button className={"btn my-2"} type={"button"} onClick={addImage}>
{isUploading ? <span className="loading loading-spinner"></span> : <MdAdd/>}
{t("Upload Image")}
</button>
<div className={"h-4"}></div>
{
error && <div role="alert" className="alert alert-error my-2 shadow">
<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"/>
</svg>
<span>{t("Error")}: {error}</span>
</div>
}
<div className={"flex flex-row-reverse mt-4"}>
<button className={"btn btn-accent shadow"} onClick={handleSubmit}>
{isSubmitting && <span className="loading loading-spinner"></span>}
{t("Publish")}
</button>
</div>
</div>
}
function TagInput({onAdd}: { onAdd: (tag: Tag) => void }) {
const [keyword, setKeyword] = useState<string>("")
const [tags, setTags] = useState<Tag[]>([])
const [error, setError] = useState<string | null>(null)
const [isLoading, setLoading] = useState(false)
const debounce = useRef(new Debounce(500))
const {t} = useTranslation();
const searchTags = async (keyword: string) => {
if (keyword.length === 0) {
return
}
setLoading(true)
setTags([])
setError(null)
const res = await network.searchTags(keyword)
if (!res.success) {
setError(res.message)
setLoading(false)
return
}
setTags(res.data!)
setLoading(false)
}
const handleChange = async (v: string) => {
setKeyword(v)
setTags([])
setError(null)
if (v.length !== 0) {
setLoading(true)
debounce.current.run(() => searchTags(v))
} else {
setLoading(false)
debounce.current.cancel()
}
}
const handleCreateTag = async (name: string) => {
setLoading(true)
const res = await network.createTag(name)
if (!res.success) {
setError(res.message)
setLoading(false)
return
}
onAdd(res.data!)
setKeyword("")
setTags([])
setLoading(false)
const input = document.getElementById("search_tags_input") as HTMLInputElement
input.blur()
}
let dropdownContent = <></>
if (error) {
dropdownContent = <div className="alert alert-error my-2">
<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"/>
</svg>
<span>{error}</span>
</div>
} else if (!keyword) {
dropdownContent = <div className="flex flex-row py-2 px-4">
<LuInfo size={20}/>
<span className={"w-2"}/>
<span className={"flex-1"}>{t("Please enter a search keyword")}</span>
</div>
} else if(isLoading) {
dropdownContent = <div className="flex flex-row py-2 px-4">
<span className={"loading loading-spinner loading-sm"}></span>
<span className={"w-2"}/>
<span className={"flex-1"}>{t("Searching...")}</span>
</div>
} else {
const haveExactMatch = tags.find((t) => t.name === keyword) !== undefined
dropdownContent = <>
{
tags.map((t) => {
return <li key={t.id} onClick={() => {
onAdd(t);
setKeyword("")
setTags([])
const input = document.getElementById("search_tags_input") as HTMLInputElement
input.blur()
}}><a>{t.name}</a></li>
})
}
{
!haveExactMatch && <li onClick={() => {
handleCreateTag(keyword)
}}><a>{t("Create Tag")}: {keyword}</a></li>
}
</>
}
return <div className={"dropdown dropdown-end"}>
<label className="input">
<svg className="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g
stroke-linejoin="round"
stroke-linecap="round"
stroke-width="2.5"
fill="none"
stroke="currentColor"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</g>
</svg>
<input autoComplete={"off"} id={"search_tags_input"} tabIndex={0} type="text" className="grow" placeholder={t("Search Tags")} value={keyword} onChange={(e) => handleChange(e.target.value)}/>
</label>
<ul tabIndex={0} className="dropdown-content menu bg-base-100 rounded-box z-1 w-52 p-2 shadow mt-2 border border-base-300">
{dropdownContent}
</ul>
</div>
}
class Debounce {
private timer: number | null = null
private readonly delay: number
constructor(delay: number) {
this.delay = delay
}
run(callback: () => void) {
if (this.timer) {
clearTimeout(this.timer)
}
this.timer = setTimeout(() => {
callback()
}, this.delay)
}
cancel() {
if (this.timer) {
clearTimeout(this.timer)
this.timer = null
}
}
}

View File

@@ -0,0 +1,78 @@
import {FormEvent, useState} from "react";
import {network} from "../network/network.ts";
import {app} from "../app.ts";
import {useNavigate} from "react-router";
import {useTranslation} from "react-i18next";
export default function RegisterPage() {
const {t} = useTranslation();
const [isLoading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const navigate = useNavigate();
const onSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!username || !password) {
setError(t("Username and password cannot be empty"));
return;
}
if (password !== confirmPassword) {
setError(t("Passwords do not match"));
return;
}
setLoading(true);
const res = await network.register(username, password);
if (res.success) {
app.user = res.data!;
app.token = res.data!.token;
app.saveData();
navigate("/", {replace: true});
} else {
setError(res.message);
setLoading(false);
}
};
return <div className={"flex items-center justify-center w-full h-full bg-base-200"} id={"register-page"}>
<div className={"w-96 card card-border bg-base-100 border-base-300"}>
<form onSubmit={onSubmit}>
<div className={"card-body"}>
<h1 className={"text-2xl font-bold"}>{t("Register")}</h1>
{error && <div role="alert" className="alert alert-error my-2">
<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"/>
</svg>
<span>{error}</span>
</div>}
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">{t("Username")}</legend>
<input type="text" className="input w-full" value={username} onChange={(e) => setUsername(e.target.value)}/>
</fieldset>
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">{t("Password")}</legend>
<input type="password" className="input w-full" value={password} onChange={(e) => setPassword(e.target.value)}/>
</fieldset>
<fieldset className="fieldset w-full">
<legend className="fieldset-legend">{t("Confirm Password")}</legend>
<input type="password" className="input w-full" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)}/>
</fieldset>
<button className={"btn my-4 btn-primary"} type={"submit"}>
{isLoading && <span className="loading loading-spinner"></span>}
{t("Continue")}
</button>
<button className="btn" type={"button"} onClick={() => {
navigate("/login", {replace: true});
}}>
{t("Already have an account? Login")}
</button>
</div>
</form>
</div>
</div>
}

View File

@@ -0,0 +1,151 @@
import { useParams } from "react-router";
import {createContext, useCallback, useEffect, useState} from "react";
import {ResourceDetails, RFile} from "../network/models.ts";
import { network } from "../network/network.ts";
import showToast from "../components/toast.ts";
import Markdown from "react-markdown";
import "../markdown.css";
import Loading from "../components/loading.tsx";
import {MdAdd, MdOutlineArticle, MdOutlineComment, MdOutlineDataset} from "react-icons/md";
import {app} from "../app.ts";
export default function ResourcePage() {
const params = useParams()
const idStr = params.id
const id = idStr ? parseInt(idStr) : NaN
const [resource, setResource] = useState<ResourceDetails | null>(null)
const reload = useCallback(async () => {
if (!isNaN(id)) {
setResource(null)
const res = await network.getResourceDetails(id)
if (res.success) {
setResource(res.data!)
} else {
showToast({ message: res.message, type: "error" })
}
}
}, [id])
useEffect(() => {
if (!isNaN(id)) {
network.getResourceDetails(id).then((res) => {
if (res.success) {
setResource(res.data!)
} else {
showToast({ message: res.message, type: "error" })
}
})
}
}, [id])
if (isNaN(id)) {
return <div className="alert alert-error shadow-lg">
<div>
<span>Resource ID is required</span>
</div>
</div>
}
if (!resource) {
return <Loading />
}
return <context.Provider value={reload}>
<div className={"pt-2"}>
<h1 className={"text-2xl font-bold px-4 py-2"}>{resource.title}</h1>
{
resource.alternativeTitles.map((e) => {
return <h2 className={"text-lg px-4 py-1 text-gray-700 dark:text-gray-300"}>{e}</h2>
})
}
<button className="border-b-2 mx-4 py-1 cursor-pointer border-transparent hover:border-primary transition-colors duration-200 ease-in-out">
<div className="flex items-center ">
<div className="avatar">
<div className="w-6 rounded-full">
<img src={network.getUserAvatar(resource.author)} alt={"avatar"} />
</div>
</div>
<div className="w-2"></div>
<div className="text-sm">{resource.author.username}</div>
</div>
</button>
<p className={"px-4 pt-2"}>
{
resource.tags.map((e) => {
return <span className="badge badge-primary mr-2">{e.name}</span>
})
}
</p>
<div className="tabs tabs-box my-4 mx-2 p-4">
<label className="tab ">
<input type="radio" name="my_tabs" defaultChecked/>
<MdOutlineArticle className="text-xl mr-2"/>
<span className="text-sm">
Description
</span>
</label>
<div className="tab-content p-2">
<Article article={resource.article} />
</div>
<label className="tab">
<input type="radio" name="my_tabs"/>
<MdOutlineDataset className="text-xl mr-2"/>
<span className="text-sm">
Files
</span>
</label>
<div className="tab-content p-2">
<Files files={resource.files} />
</div>
<label className="tab">
<input type="radio" name="my_tabs"/>
<MdOutlineComment className="text-xl mr-2"/>
<span className="text-sm">
Comments
</span>
</label>
<div className="tab-content p-2">Comments</div>
</div>
<div className="h-4"></div>
</div>
</context.Provider>
}
const context = createContext<() => void>(() => {})
function Article({ article }: { article: string }) {
return <article>
<Markdown>{article}</Markdown>
</article>
}
function FileTile({ file }: { file: RFile }) {
// TODO: implement file tile
return <div></div>
}
function Files({files}: { files: RFile[]}) {
return <div>
{
files.map((file) => {
return <FileTile file={file} key={file.id}></FileTile>
})
}
{
app.isAdmin() && <div className={"flex flex-row-reverse"}>
<button className={"btn btn-accent shadow"}>
<MdAdd size={24}/>
<span className={"text-sm"}>
Upload
</span>
</button>
</div>
}
</div>
}

View File

@@ -0,0 +1,25 @@
import {useSearchParams} from "react-router";
import {network} from "../network/network.ts";
import ResourcesView from "../components/resources_view.tsx";
import {useEffect} from "react";
import {useTranslation} from "react-i18next";
export default function SearchPage() {
const [params, _] = useSearchParams()
const { t } = useTranslation();
const keyword = params.get("keyword")
useEffect(() => {}, [])
if (keyword === null || keyword === "") {
return <div role="alert" className="alert alert-info alert-dash">
<span>{t("Enter a search keyword to continue")}</span>
</div>
}
return <div key={keyword}>
<h1 className={"text-2xl px-4 pt-4 font-bold my-2"}>{t("Search")}: {keyword}</h1>
<ResourcesView loader={(page) => network.searchResources(keyword, page)}></ResourcesView>
</div>
}

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

8
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss(),],
})

36
go.mod Normal file
View File

@@ -0,0 +1,36 @@
module nysoure
go 1.24
require (
github.com/gofiber/fiber/v3 v3.0.0-beta.4
github.com/golang-jwt/jwt/v5 v5.2.2
golang.org/x/crypto v0.37.0
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.26.1
)
require github.com/chai2010/webp v1.4.0
require (
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/fxamacker/cbor/v2 v2.8.0 // indirect
github.com/gofiber/schema v1.3.0 // indirect
github.com/gofiber/utils/v2 v2.0.0-beta.8 // indirect
github.com/google/uuid v1.6.0
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.28 // indirect
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect
github.com/tinylib/msgp v1.2.5 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.61.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/image v0.27.0
golang.org/x/net v0.39.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.25.0 // indirect
)

63
go.sum Normal file
View File

@@ -0,0 +1,63 @@
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU=
github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gofiber/fiber/v3 v3.0.0-beta.4 h1:KzDSavvhG7m81NIsmnu5l3ZDbVS4feCidl4xlIfu6V0=
github.com/gofiber/fiber/v3 v3.0.0-beta.4/go.mod h1:/WFUoHRkZEsGHyy2+fYcdqi109IVOFbVwxv1n1RU+kk=
github.com/gofiber/schema v1.3.0 h1:K3F3wYzAY+aivfCCEHPufCthu5/13r/lzp1nuk6mr3Q=
github.com/gofiber/schema v1.3.0/go.mod h1:YYwj01w3hVfaNjhtJzaqetymL56VW642YS3qZPhuE6c=
github.com/gofiber/utils/v2 v2.0.0-beta.8 h1:ZifwbHZqZO3YJsx1ZhDsWnPjaQ7C0YD20LHt+DQeXOU=
github.com/gofiber/utils/v2 v2.0.0-beta.8/go.mod h1:1lCBo9vEF4RFEtTgWntipnaScJZQiM8rrsYycLZ4n9c=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY=
github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po=
github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.61.0 h1:VV08V0AfoRaFurP1EWKvQQdPTZHiUzaVoulX1aBDgzU=
github.com/valyala/fasthttp v1.61.0/go.mod h1:wRIV/4cMwUPWnRcDno9hGnYZGh78QzODFfo1LTUhBog=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w=
golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.26.1 h1:ghB2gUI9FkS46luZtn6DLZ0f6ooBJ5IbVej2ENFDjRw=
gorm.io/gorm v1.26.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=

38
main.go Normal file
View File

@@ -0,0 +1,38 @@
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"
)
func main() {
app := fiber.New(fiber.Config{
BodyLimit: 8 * 1024 * 1024,
})
app.Use(logger.New(logger.Config{
Format: "[${ip}]:${port} ${status} - ${method} ${path}\n",
}))
app.Use(middleware.ErrorHandler)
app.Use(middleware.JwtMiddleware)
app.Use(cors.New(cors.ConfigDefault))
apiG := app.Group("/api")
{
api.AddUserRoutes(apiG)
api.AddTagRoutes(apiG)
api.AddImageRoutes(apiG)
api.AddResourceRoutes(apiG)
api.AddStorageRoutes(apiG)
api.AddFileRoutes(apiG)
}
log.Fatal(app.Listen(":3000"))
}

254
server/api/file.go Normal file
View File

@@ -0,0 +1,254 @@
package api
import (
"fmt"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/log"
"mime/multipart"
"nysoure/server/model"
"nysoure/server/service"
"strconv"
)
func AddFileRoutes(router fiber.Router) {
fileGroup := router.Group("/files")
{
fileGroup.Post("/upload/init", initUpload)
fileGroup.Post("/upload/block/:id/:index", uploadBlock)
fileGroup.Post("/upload/finish/:id", finishUpload)
fileGroup.Post("/redirect", createRedirectFile)
fileGroup.Get("/:id", getFile)
fileGroup.Put("/:id", updateFile)
fileGroup.Delete("/:id", deleteFile)
}
}
// initUpload 初始化文件上传过程
func initUpload(c fiber.Ctx) error {
uid := c.Locals("uid").(uint)
type InitUploadRequest struct {
Filename string `json:"filename"`
Description string `json:"description"`
FileSize int64 `json:"file_size"`
ResourceID uint `json:"resource_id"`
StorageID uint `json:"storage_id"`
}
var req InitUploadRequest
if err := c.Bind().Body(&req); err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的请求参数",
})
}
result, err := service.CreateUploadingFile(uid, req.Filename, req.Description, req.FileSize, req.ResourceID, req.StorageID)
if err != nil {
return err
}
return c.JSON(model.Response[*model.UploadingFileView]{
Success: true,
Data: result,
})
}
// uploadBlock 上传文件块
func uploadBlock(c fiber.Ctx) error {
uid := c.Locals("uid").(uint)
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的文件ID",
})
}
index, err := strconv.Atoi(c.Params("index"))
if err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的块索引",
})
}
file, err := c.Request().MultipartForm()
if err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的文件数据",
})
}
if len(file.File["block"]) == 0 {
return c.JSON(model.Response[any]{
Success: false,
Message: "没有找到文件块",
})
}
fileHeader := file.File["block"][0]
fileContent, err := fileHeader.Open()
if err != nil {
log.Error("打开文件块失败: ", err)
return c.JSON(model.Response[any]{
Success: false,
Message: "打开文件块失败",
})
}
defer func(fileContent multipart.File) {
_ = fileContent.Close()
}(fileContent)
data := make([]byte, fileHeader.Size)
if _, err := fileContent.Read(data); err != nil {
log.Error("读取文件块失败: ", err)
return c.JSON(model.Response[any]{
Success: false,
Message: "读取文件块失败",
})
}
if err := service.UploadBlock(uid, uint(id), index, data); err != nil {
return err
}
return c.JSON(model.Response[any]{
Success: true,
Message: fmt.Sprintf("块 %d 上传成功", index),
})
}
// finishUpload 完成文件上传
func finishUpload(c fiber.Ctx) error {
uid := c.Locals("uid").(uint)
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的文件ID",
})
}
result, err := service.FinishUploadingFile(uid, uint(id))
if err != nil {
return err
}
return c.JSON(model.Response[*model.FileView]{
Success: true,
Data: result,
})
}
// createRedirectFile 创建重定向文件
func createRedirectFile(c fiber.Ctx) error {
uid := c.Locals("uid").(uint)
type CreateRedirectFileRequest struct {
Filename string `json:"filename"`
Description string `json:"description"`
ResourceID uint `json:"resource_id"`
RedirectURL string `json:"redirect_url"`
}
var req CreateRedirectFileRequest
if err := c.Bind().Body(&req); err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的请求参数",
})
}
result, err := service.CreateRedirectFile(uid, req.Filename, req.Description, req.ResourceID, req.RedirectURL)
if err != nil {
return err
}
return c.JSON(model.Response[*model.FileView]{
Success: true,
Data: result,
})
}
// getFile 获取文件信息
func getFile(c fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的文件ID",
})
}
file, err := service.GetFile(uint(id))
if err != nil {
return err
}
return c.JSON(model.Response[*model.FileView]{
Success: true,
Data: file,
})
}
// updateFile 更新文件信息
func updateFile(c fiber.Ctx) error {
uid := c.Locals("uid").(uint)
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的文件ID",
})
}
type UpdateFileRequest struct {
Filename string `json:"filename"`
Description string `json:"description"`
}
var req UpdateFileRequest
if err := c.Bind().Body(&req); err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的请求参数",
})
}
result, err := service.UpdateFile(uid, uint(id), req.Filename, req.Description)
if err != nil {
return err
}
return c.JSON(model.Response[*model.FileView]{
Success: true,
Data: result,
})
}
// deleteFile 删除文件
func deleteFile(c fiber.Ctx) error {
uid := c.Locals("uid").(uint)
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
if err != nil {
return c.JSON(model.Response[any]{
Success: false,
Message: "无效的文件ID",
})
}
if err := service.DeleteFile(uid, uint(id)); err != nil {
return err
}
return c.JSON(model.Response[any]{
Success: true,
Message: "文件删除成功",
})
}

86
server/api/image.go Normal file
View File

@@ -0,0 +1,86 @@
package api
import (
"github.com/gofiber/fiber/v3"
"net/http"
"nysoure/server/model"
"nysoure/server/service"
"strconv"
"strings"
)
func handleUploadImage(c fiber.Ctx) error {
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
if err := service.HavePermissionToUpload(uid); err != nil {
return err
}
data := c.Body()
contentType := http.DetectContentType(data)
if !strings.HasPrefix(contentType, "image/") {
return model.NewRequestError("Invalid image format")
}
id, err := service.CreateImage(data)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[uint]{
Success: true,
Data: id,
Message: "Image uploaded successfully",
})
}
func handleGetImage(c fiber.Ctx) error {
idStr := c.Params("id")
if idStr == "" {
return model.NewRequestError("Image ID is required")
}
id, err := strconv.Atoi(idStr)
if err != nil {
return model.NewRequestError("Invalid image ID")
}
image, err := service.GetImage(uint(id))
if err != nil {
return err
}
contentType := http.DetectContentType(image)
c.Set("Content-Type", contentType)
return c.Send(image)
}
func handleDeleteImage(c fiber.Ctx) error {
idStr := c.Params("id")
if idStr == "" {
return model.NewRequestError("Image ID is required")
}
id, err := strconv.Atoi(idStr)
if err != nil {
return model.NewRequestError("Invalid image ID")
}
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
if err := service.HavePermissionToUpload(uid); err != nil {
return err
}
if err := service.DeleteImage(uint(id)); err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[any]{
Success: true,
Message: "Image deleted successfully",
})
}
func AddImageRoutes(api fiber.Router) {
image := api.Group("/image")
{
image.Put("/", handleUploadImage)
image.Get("/:id", handleGetImage)
image.Delete("/:id", handleDeleteImage)
}
}

139
server/api/resource.go Normal file
View File

@@ -0,0 +1,139 @@
package api
import (
"encoding/json"
"nysoure/server/model"
"nysoure/server/service"
"strconv"
"github.com/gofiber/fiber/v3"
)
func handleCreateResource(c fiber.Ctx) error {
var params service.ResourceCreateParams
body := c.Body()
err := json.Unmarshal(body, &params)
if err != nil {
return model.NewRequestError("Invalid request body")
}
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("You must be logged in to create a resource")
}
id, err := service.CreateResource(uid, &params)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[uint]{
Success: true,
Data: id,
Message: "Resource created successfully",
})
}
func handleGetResource(c fiber.Ctx) error {
idStr := c.Params("id")
if idStr == "" {
return model.NewRequestError("Resource ID is required")
}
id, err := strconv.Atoi(idStr)
if err != nil {
return model.NewRequestError("Invalid resource ID")
}
resource, err := service.GetResource(uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[model.ResourceDetailView]{
Success: true,
Data: *resource,
Message: "Resource retrieved successfully",
})
}
func handleDeleteResource(c fiber.Ctx) error {
idStr := c.Params("id")
if idStr == "" {
return model.NewRequestError("Resource ID is required")
}
id, err := strconv.Atoi(idStr)
if err != nil {
return model.NewRequestError("Invalid resource ID")
}
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("You must be logged in to delete a resource")
}
err = service.DeleteResource(uid, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[any]{
Success: true,
Data: nil,
Message: "Resource deleted successfully",
})
}
func handleListResources(c fiber.Ctx) error {
pageStr := c.Query("page")
if pageStr == "" {
pageStr = "1"
}
page, err := strconv.Atoi(pageStr)
if err != nil {
return model.NewRequestError("Invalid page number")
}
resources, maxPage, err := service.GetResourceList(page)
if err != nil {
return err
}
if resources == nil {
resources = []model.ResourceView{}
}
return c.Status(fiber.StatusOK).JSON(model.PageResponse[model.ResourceView]{
Success: true,
Data: resources,
TotalPages: maxPage,
Message: "Resources retrieved successfully",
})
}
func handleSearchResources(c fiber.Ctx) error {
query := c.Query("keyword")
if query == "" {
return model.NewRequestError("Search query is required")
}
pageStr := c.Query("page")
if pageStr == "" {
pageStr = "1"
}
page, err := strconv.Atoi(pageStr)
if err != nil {
return model.NewRequestError("Invalid page number")
}
resources, totalPages, err := service.SearchResource(query, page)
if err != nil {
return err
}
if resources == nil {
resources = []model.ResourceView{}
}
return c.Status(fiber.StatusOK).JSON(model.PageResponse[model.ResourceView]{
Success: true,
Data: resources,
TotalPages: totalPages,
Message: "Resources retrieved successfully",
})
}
func AddResourceRoutes(api fiber.Router) {
resource := api.Group("/resource")
{
resource.Post("/", handleCreateResource)
resource.Get("/search", handleSearchResources)
resource.Get("/", handleListResources)
resource.Get("/:id", handleGetResource)
resource.Delete("/:id", handleDeleteResource)
}
}

118
server/api/storage.go Normal file
View File

@@ -0,0 +1,118 @@
package api
import (
"nysoure/server/model"
"nysoure/server/service"
"strconv"
"github.com/gofiber/fiber/v3"
)
func handleCreateS3Storage(c fiber.Ctx) error {
var params service.CreateS3StorageParams
if err := c.Bind().JSON(&params); err != nil {
return model.NewRequestError("Invalid request body")
}
if params.Name == "" || params.EndPoint == "" || params.AccessKeyID == "" ||
params.SecretAccessKey == "" || params.BucketName == "" {
return model.NewRequestError("All fields are required")
}
if params.MaxSizeInMB <= 0 {
return model.NewRequestError("Max size must be greater than 0")
}
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("You are not authorized to perform this action")
}
err := service.CreateS3Storage(uid, params)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).JSON(model.Response[any]{
Success: true,
Message: "S3 storage created successfully",
})
}
func handleCreateLocalStorage(c fiber.Ctx) error {
var params service.CreateLocalStorageParams
if err := c.Bind().JSON(&params); err != nil {
return model.NewRequestError("Invalid request body")
}
if params.Name == "" || params.Path == "" {
return model.NewRequestError("All fields are required")
}
if params.MaxSizeInMB <= 0 {
return model.NewRequestError("Max size must be greater than 0")
}
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("You are not authorized to perform this action")
}
err := service.CreateLocalStorage(uid, params)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).JSON(model.Response[any]{
Success: true,
Message: "Local storage created successfully",
})
}
func handleListStorages(c fiber.Ctx) error {
storages, err := service.ListStorages()
if err != nil {
return err
}
if storages == nil {
storages = []model.StorageView{}
}
return c.Status(fiber.StatusOK).JSON(model.Response[*[]model.StorageView]{
Success: true,
Data: &storages,
Message: "Storages retrieved successfully",
})
}
func handleDeleteStorage(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.DeleteStorage(uid, uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[any]{
Success: true,
Message: "Storage deleted successfully",
})
}
func AddStorageRoutes(r fiber.Router) {
s := r.Group("storage")
s.Post("/s3", handleCreateS3Storage)
s.Post("/local", handleCreateLocalStorage)
s.Get("/", handleListStorages)
s.Delete("/:id", handleDeleteStorage)
}

68
server/api/tag.go Normal file
View File

@@ -0,0 +1,68 @@
package api
import (
"github.com/gofiber/fiber/v3"
"nysoure/server/model"
"nysoure/server/service"
"strconv"
)
func handleCreateTag(c fiber.Ctx) error {
tag := c.FormValue("name")
if tag == "" {
return model.NewRequestError("name is required")
}
t, err := service.CreateTag(tag)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[model.TagView]{
Success: true,
Data: *t,
Message: "Tag created successfully",
})
}
func handleSearchTag(c fiber.Ctx) error {
keyword := c.Query("keyword")
if keyword == "" {
return model.NewRequestError("Keyword is required")
}
tags, err := service.SearchTag(keyword)
if tags == nil {
tags = []model.TagView{}
}
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[*[]model.TagView]{
Success: true,
Data: &tags,
Message: "Tags retrieved successfully",
})
}
func handleDeleteTag(c fiber.Ctx) error {
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return model.NewRequestError("Invalid tag ID")
}
err = service.DeleteTag(uint(id))
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[any]{
Success: true,
Data: nil,
Message: "Tag deleted successfully",
})
}
func AddTagRoutes(api fiber.Router) {
tag := api.Group("/tag")
{
tag.Post("/", handleCreateTag)
tag.Get("/search", handleSearchTag)
tag.Delete("/:id", handleDeleteTag)
}
}

282
server/api/user.go Normal file
View File

@@ -0,0 +1,282 @@
package api
import (
"io"
"net/http"
"nysoure/server/model"
"nysoure/server/service"
"strconv"
"github.com/gofiber/fiber/v3"
)
func handleUserRegister(c fiber.Ctx) error {
username := c.FormValue("username")
password := c.FormValue("password")
if username == "" || password == "" {
return model.NewRequestError("Username and password are required")
}
if len(password) < 6 {
return model.NewRequestError("Password must be at least 6 characters long")
}
if len(username) < 3 {
return model.NewRequestError("Username must be at least 3 characters long")
}
user, err := service.CreateUser(username, password)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[model.UserViewWithToken]{
Success: true,
Data: user,
Message: "User created successfully",
})
}
func handleUserLogin(c fiber.Ctx) error {
username := c.FormValue("username")
password := c.FormValue("password")
if username == "" || password == "" {
return model.NewRequestError("Username and password are required")
}
user, err := service.Login(username, password)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[model.UserViewWithToken]{
Success: true,
Data: user,
Message: "Login successful",
})
}
func handleUserChangePassword(c fiber.Ctx) error {
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
oldPassword := c.FormValue("old_password")
newPassword := c.FormValue("new_password")
if oldPassword == "" || newPassword == "" {
return model.NewRequestError("Old and new passwords are required")
}
user, err := service.ChangePassword(uid, oldPassword, newPassword)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[model.UserViewWithToken]{
Success: true,
Data: user,
Message: "Password changed successfully",
})
}
func handleUserChangeAvatar(c fiber.Ctx) error {
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
file, err := c.FormFile("avatar")
if err != nil {
return model.NewRequestError("Avatar file is required")
}
f, err := file.Open()
if err != nil {
return err
}
imageData, err := io.ReadAll(f)
_ = f.Close()
if err != nil {
return err
}
user, err := service.ChangeAvatar(uid, imageData)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[model.UserView]{
Success: true,
Data: user,
Message: "Avatar changed successfully",
})
}
func handleGetUserAvatar(c fiber.Ctx) error {
idStr := c.Params("id")
uid, err := strconv.Atoi(idStr)
if err != nil {
return model.NewRequestError("Invalid user ID")
}
avatar, err := service.GetAvatar(uint(uid))
if err != nil {
return err
}
contentType := http.DetectContentType(avatar)
c.Set("Content-Type", contentType)
c.Set("Cache-Control", "public, max-age=31536000")
return c.Send(avatar)
}
func handleSetUserAdmin(c fiber.Ctx) error {
adminID, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
userIDStr := c.FormValue("user_id")
if userIDStr == "" {
return model.NewRequestError("User ID is required")
}
userID, err := strconv.Atoi(userIDStr)
if err != nil {
return model.NewRequestError("Invalid user ID")
}
isAdminStr := c.FormValue("is_admin")
if isAdminStr == "" {
return model.NewRequestError("is_admin parameter is required")
}
isAdmin := isAdminStr == "true" || isAdminStr == "1"
user, err := service.SetUserAdmin(adminID, uint(userID), isAdmin)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[model.UserView]{
Success: true,
Data: user,
Message: "User admin status updated successfully",
})
}
func handleSetUserUploadPermission(c fiber.Ctx) error {
adminID, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
userIDStr := c.FormValue("user_id")
if userIDStr == "" {
return model.NewRequestError("User ID is required")
}
userID, err := strconv.Atoi(userIDStr)
if err != nil {
return model.NewRequestError("Invalid user ID")
}
canUploadStr := c.FormValue("can_upload")
if canUploadStr == "" {
return model.NewRequestError("can_upload parameter is required")
}
canUpload := canUploadStr == "true" || canUploadStr == "1"
user, err := service.SetUserUploadPermission(adminID, uint(userID), canUpload)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[model.UserView]{
Success: true,
Data: user,
Message: "User upload permission updated successfully",
})
}
func handleListUsers(c fiber.Ctx) error {
adminID, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
pageStr := c.Query("page", "1")
page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 {
page = 1
}
users, totalPages, err := service.ListUsers(adminID, page)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.PageResponse[model.UserView]{
Success: true,
TotalPages: totalPages,
Data: users,
Message: "Users retrieved successfully",
})
}
func handleSearchUsers(c fiber.Ctx) error {
adminID, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
username := c.Query("username", "")
if username == "" {
return model.NewRequestError("Username search parameter is required")
}
pageStr := c.Query("page", "1")
page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 {
page = 1
}
users, totalPages, err := service.SearchUsers(adminID, username, page)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.PageResponse[model.UserView]{
Success: true,
TotalPages: totalPages,
Data: users,
Message: "Users found successfully",
})
}
func handleDeleteUser(c fiber.Ctx) error {
adminID, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
userIDStr := c.FormValue("user_id")
if userIDStr == "" {
return model.NewRequestError("User ID is required")
}
userID, err := strconv.Atoi(userIDStr)
if err != nil {
return model.NewRequestError("Invalid user ID")
}
if err := service.DeleteUser(adminID, uint(userID)); err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[any]{
Success: true,
Message: "User deleted successfully",
})
}
func AddUserRoutes(r fiber.Router) {
u := r.Group("user")
u.Post("/register", handleUserRegister)
u.Post("/login", handleUserLogin)
u.Put("/avatar", handleUserChangeAvatar)
u.Post("/password", handleUserChangePassword)
u.Get("/avatar/:id", handleGetUserAvatar)
u.Post("/set_admin", handleSetUserAdmin)
u.Post("/set_upload_permission", handleSetUserUploadPermission)
u.Get("/list", handleListUsers)
u.Get("/search", handleSearchUsers)
u.Post("/delete", handleDeleteUser)
}

23
server/dao/db.go Normal file
View File

@@ -0,0 +1,23 @@
package dao
import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"nysoure/server/model"
)
var db *gorm.DB
func init() {
var err error
db, err = gorm.Open(sqlite.Open("test.db"), &gorm.Config{})
if err != nil {
panic("failed to connect database")
}
_ = db.AutoMigrate(&model.User{}, &model.Resource{}, &model.Image{}, &model.Tag{}, &model.Storage{}, &model.File{}, &model.UploadingFile{}, &model.Statistic{})
}
func GetDB() *gorm.DB {
return db
}

137
server/dao/file.go Normal file
View File

@@ -0,0 +1,137 @@
package dao
import (
"errors"
"gorm.io/gorm"
"nysoure/server/model"
"time"
)
func CreateUploadingFile(filename string, description string, fileSize int64, blockSize int64, tempPath string, resourceID, storageID, userID uint) (*model.UploadingFile, error) {
blocksCount := (fileSize + blockSize - 1) / blockSize
uf := &model.UploadingFile{
Filename: filename,
Description: description,
TotalSize: fileSize,
BlockSize: blockSize,
TempPath: tempPath,
Blocks: make(model.UploadingFileBlocks, blocksCount),
TargetResourceID: resourceID,
TargetStorageID: storageID,
UserID: userID,
}
if err := db.Create(uf).Error; err != nil {
return nil, err
}
return uf, nil
}
func GetUploadingFile(id uint) (*model.UploadingFile, error) {
uf := &model.UploadingFile{}
if err := db.Where("id = ?", id).First(uf).Error; err != nil {
return nil, err
}
return uf, nil
}
func UpdateUploadingBlock(id uint, blockIndex int) error {
uf := &model.UploadingFile{}
if err := db.Where("id = ?", id).First(uf).Error; err != nil {
return err
}
if blockIndex < 0 || blockIndex >= uf.BlocksCount() {
return nil
}
uf.Blocks[blockIndex] = true
return db.Save(uf).Error
}
func DeleteUploadingFile(id uint) error {
uf := &model.UploadingFile{}
if err := db.Where("id = ?", id).First(uf).Error; err != nil {
return err
}
if err := db.Delete(uf).Error; err != nil {
return err
}
return nil
}
func GetUploadingFilesOlderThan(time time.Time) ([]model.UploadingFile, error) {
var files []model.UploadingFile
if err := db.Where("updated_at < ?", time).Find(&files).Error; err != nil {
return nil, err
}
return files, nil
}
func CreateFile(filename string, description string, resourceID uint, storageID *uint, storageKey string, redirectUrl string) (*model.File, error) {
if storageID == nil && redirectUrl == "" {
return nil, errors.New("storageID and redirectUrl cannot be both empty")
}
f := &model.File{
Filename: filename,
Description: description,
ResourceID: resourceID,
StorageID: storageID,
RedirectUrl: redirectUrl,
StorageKey: storageKey,
}
if err := db.Create(f).Error; err != nil {
return nil, err
}
return f, nil
}
func GetFile(id uint) (*model.File, error) {
f := &model.File{}
if err := db.Where("id = ?", id).First(f).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, model.NewNotFoundError("file not found")
}
return nil, err
}
return f, nil
}
func GetFilesByResourceID(rID uint) ([]model.File, error) {
var files []model.File
if err := db.Where("resource_id = ?", rID).Find(&files).Error; err != nil {
return nil, err
}
return files, nil
}
func DeleteFile(id uint) error {
f := &model.File{}
if err := db.Where("id = ?", id).First(f).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.NewNotFoundError("file not found")
}
return err
}
if err := db.Delete(f).Error; err != nil {
return err
}
return nil
}
func UpdateFile(id uint, filename string, description string) (*model.File, error) {
f := &model.File{}
if err := db.Where("id = ?", id).First(f).Error; err != nil {
return nil, err
}
if filename != "" {
f.Filename = filename
}
if description != "" {
f.Description = description
}
if err := db.Save(f).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, model.NewNotFoundError("file not found")
}
return nil, err
}
return f, nil
}

56
server/dao/image.go Normal file
View File

@@ -0,0 +1,56 @@
package dao
import (
"errors"
"nysoure/server/model"
"time"
"gorm.io/gorm"
)
func CreateImage(name string, width, height int) (model.Image, error) {
// Create a new image in the database
i := model.Image{FileName: name, Width: width, Height: height}
if err := db.Create(&i).Error; err != nil {
return model.Image{}, err
}
return i, nil
}
func GetImageByID(id uint) (model.Image, error) {
// Retrieve an image by its ID from the database
var i model.Image
if err := db.First(&i, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.Image{}, model.NewNotFoundError("Image not found")
}
return model.Image{}, err
}
return i, nil
}
func DeleteImage(id uint) error {
// Delete an image from the database
i := model.Image{}
i.ID = id
if err := db.Delete(&i).Error; err != nil {
return err
}
return nil
}
func GetUnusedImages() ([]model.Image, error) {
// Retrieve all images that are not used in any post
var images []model.Image
oneDayAgo := time.Now().Add(-24 * time.Hour)
if err := db.
Where("NOT EXISTS (SELECT 1 FROM resource_images WHERE image_id = images.id)").
Where("created_at < ?", oneDayAgo).
Find(&images).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return images, nil
}

169
server/dao/resource.go Normal file
View File

@@ -0,0 +1,169 @@
package dao
import (
"errors"
"nysoure/server/model"
"strings"
"gorm.io/gorm"
)
func CreateResource(r model.Resource) (model.Resource, error) {
// Create a new resource in the database
if err := db.Create(&r).Error; err != nil {
return model.Resource{}, err
}
return r, nil
}
func GetResourceByID(id uint) (model.Resource, error) {
// Retrieve a resource by its ID from the database
var r model.Resource
if err := db.Preload("User").Preload("Images").Preload("Tags").Preload("Files").First(&r, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.Resource{}, model.NewNotFoundError("Resource not found")
}
return model.Resource{}, err
}
return r, nil
}
func GetResourceList(page, pageSize int) ([]model.Resource, int, error) {
// Retrieve a list of resources with pagination
var resources []model.Resource
var total int64
if err := db.Model(&model.Resource{}).Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Offset((page - 1) * pageSize).Limit(pageSize).Preload("User").Preload("Images").Preload("Tags").Order("created_at DESC").Find(&resources).Error; err != nil {
return nil, 0, err
}
totalPages := int(total) / pageSize
return resources, int(totalPages), nil
}
func UpdateResource(r model.Resource) error {
// Update a resource in the database
if err := db.Save(&r).Error; err != nil {
return err
}
return nil
}
func DeleteResource(id uint) error {
// Delete a resource from the database
r := model.Resource{}
r.ID = id
if err := db.Delete(&r).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return nil
}
func Search(query string, page, pageSize int) ([]model.Resource, int, error) {
query = strings.TrimSpace(query)
keywords := strings.Split(query, " ")
resource, err := searchWithKeyword(keywords[0])
if err != nil {
return nil, 0, err
}
if len(keywords) > 1 {
for _, keyword := range keywords[1:] {
r := make([]model.Resource, 0, len(resource))
for _, res := range resource {
if strings.Contains(res.Title, keyword) {
r = append(r, res)
continue
}
ok := false
for _, at := range res.AlternativeTitles {
if strings.Contains(at, keyword) {
r = append(r, res)
ok = true
break
}
}
if ok {
continue
}
for _, tag := range res.Tags {
if tag.Name == keyword {
r = append(r, res)
ok = true
break
}
}
}
resource = r
}
}
startIndex := (page - 1) * pageSize
endIndex := startIndex + pageSize
if startIndex > len(resource) {
return nil, 0, nil
}
if endIndex > len(resource) {
endIndex = len(resource)
}
totalPages := len(resource) / pageSize
result := make([]model.Resource, 0, endIndex-startIndex)
for i := startIndex; i < endIndex; i++ {
var r model.Resource
if err := db.Model(&r).Preload("User").Preload("Images").Preload("Tags").Where("id=?", resource[i].ID).First(&r).Error; err != nil {
return nil, 0, err
}
result = append(result, r)
}
return result, totalPages, nil
}
func searchWithKeyword(keyword string) ([]model.Resource, error) {
if len(keyword) == 0 {
return nil, nil
}
if len([]rune(keyword)) < 20 {
var tag model.Tag
if err := db.Where("name = ?", keyword).First(&tag).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
} else {
if err := db.Model(&tag).Preload("Resources").Find(&tag).Error; err != nil {
return nil, err
}
return tag.Resources, nil
}
}
if len([]rune(keyword)) < 80 {
var resources []model.Resource
if err := db.Where("title LIKE ?", "%"+keyword+"%").Or("alternative_titles LIKE ?", "%"+keyword+"%").Find(&resources).Error; err != nil {
return nil, err
}
return resources, nil
}
return nil, model.NewRequestError("Keyword too long")
}
func GetResourceByTag(tagID uint, page int, pageSize int) ([]model.Resource, int, error) {
var tag model.Tag
var total int64
total = db.Model(&model.Tag{}).Where("id = ?", tagID).Association("Resources").Count()
if err := db.Model(&model.Tag{}).Where("id = ?", tagID).Preload("User").Preload("Resources", func(tx *gorm.DB) *gorm.DB {
return tx.Offset((page - 1) * pageSize).Limit(pageSize)
}).First(&tag).Error; err != nil {
return nil, 0, err
}
totalPages := int(total) / pageSize
return tag.Resources, totalPages, nil
}

22
server/dao/statistic.go Normal file
View File

@@ -0,0 +1,22 @@
package dao
import "nysoure/server/model"
func SetStatistic(key string, value int64) error {
statistic := &model.Statistic{
Key: key,
Value: value,
}
if err := db.Save(statistic).Error; err != nil {
return err
}
return nil
}
func GetStatistic(key string) int64 {
statistic := &model.Statistic{}
if err := db.Where("key = ?", key).First(statistic).Error; err != nil {
return 0
}
return statistic.Value
}

24
server/dao/storage.go Normal file
View File

@@ -0,0 +1,24 @@
package dao
import "nysoure/server/model"
func CreateStorage(s model.Storage) (model.Storage, error) {
err := db.Model(&s).Create(&s).Error
return s, err
}
func DeleteStorage(id uint) error {
return db.Model(&model.Storage{}).Where("id = ?", id).Delete(&model.Storage{}).Error
}
func GetStorages() ([]model.Storage, error) {
var storages []model.Storage
err := db.Model(&model.Storage{}).Find(&storages).Error
return storages, err
}
func GetStorage(id uint) (model.Storage, error) {
var storage model.Storage
err := db.Model(&model.Storage{}).Where("id = ?", id).First(&storage).Error
return storage, err
}

60
server/dao/tag.go Normal file
View File

@@ -0,0 +1,60 @@
package dao
import (
"errors"
"nysoure/server/model"
"gorm.io/gorm"
)
func CreateTag(tag string) (model.Tag, error) {
// Create a new tag in the database
t := model.Tag{Name: tag}
if err := db.Create(&t).Error; err != nil {
return model.Tag{}, err
}
return t, nil
}
func SearchTag(keyword string) ([]model.Tag, error) {
// Search for a tag by its name in the database
var t []model.Tag
if err := db.Model(&model.Tag{}).Where("name Like ?", "%"+keyword+"%").Limit(10).Find(&t).Error; err != nil {
return nil, err
}
return t, nil
}
func DeleteTag(id uint) error {
// Delete a tag from the database
t := model.Tag{}
t.ID = id
if err := db.Delete(&t).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return nil
}
func GetTagByID(id uint) (model.Tag, error) {
// Retrieve a tag by its ID from the database
var t model.Tag
if err := db.First(&t, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.Tag{}, model.NewNotFoundError("Tag not found")
}
return model.Tag{}, err
}
return t, nil
}
func GetTagByName(name string) (model.Tag, error) {
// Retrieve a tag by its name from the database
var t model.Tag
if err := db.Where("name = ?", name).First(&t).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.Tag{}, model.NewNotFoundError("Tag not found")
}
return model.Tag{}, err
}
return t, nil
}

120
server/dao/user.go Normal file
View File

@@ -0,0 +1,120 @@
package dao
import (
"errors"
"gorm.io/gorm"
"nysoure/server/model"
)
func CreateUser(username string, hashedPassword []byte) (model.User, error) {
isEmpty, err := IsUserDataBaseEmpty()
if err != nil {
return model.User{}, err
}
user := model.User{
Username: username,
PasswordHash: hashedPassword,
IsAdmin: isEmpty,
}
exists, err := ExistsUser(username)
if err != nil {
return user, err
}
if exists {
return user, &model.RequestError{
Message: "User already exists",
}
}
if err := db.Create(&user).Error; err != nil {
return user, err
}
return user, nil
}
func ExistsUser(username string) (bool, error) {
var count int64
if err := db.Model(&model.User{}).Where("username = ?", username).Count(&count).Error; err != nil {
return false, err
}
return count > 0, nil
}
func GetUserByUsername(username string) (model.User, error) {
var user model.User
if err := db.Where("username = ?", username).First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return user, model.NewNotFoundError("User not found")
}
return user, err
}
return user, nil
}
func GetUserByID(id uint) (model.User, error) {
var user model.User
if err := db.First(&user, id).Error; err != nil {
return user, err
}
return user, nil
}
func UpdateUser(user model.User) error {
if err := db.Save(&user).Error; err != nil {
return err
}
return nil
}
func IsUserDataBaseEmpty() (bool, error) {
var user model.User
if err := db.First(&user).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return true, nil
}
return false, err
}
return false, nil
}
// 获取分页用户列表
func ListUsers(page, pageSize int) ([]model.User, int64, error) {
var users []model.User
var total int64
// 获取总数
if err := db.Model(&model.User{}).Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页获取用户
offset := (page - 1) * pageSize
if err := db.Offset(offset).Limit(pageSize).Order("id desc").Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}
// 根据用户名搜索用户
func SearchUsersByUsername(username string, page, pageSize int) ([]model.User, int64, error) {
var users []model.User
var total int64
// 获取符合条件的总数
if err := db.Model(&model.User{}).Where("username LIKE ?", "%"+username+"%").Count(&total).Error; err != nil {
return nil, 0, err
}
// 分页获取符合条件的用户
offset := (page - 1) * pageSize
if err := db.Where("username LIKE ?", "%"+username+"%").Offset(offset).Limit(pageSize).Order("id desc").Find(&users).Error; err != nil {
return nil, 0, err
}
return users, total, nil
}
// 删除用户
func DeleteUser(id uint) error {
return db.Delete(&model.User{}, id).Error
}

View File

@@ -0,0 +1,69 @@
package middleware
import (
"errors"
"github.com/gofiber/fiber/v3/log"
"nysoure/server/model"
"github.com/gofiber/fiber/v3"
)
func ErrorHandler(c fiber.Ctx) error {
err := c.Next()
if err != nil {
var requestErr *model.RequestError
var unauthorizedErr *model.UnAuthorizedError
var notFoundErr *model.NotFoundError
if errors.As(err, &requestErr) {
log.Error("Request Error: ", err)
return c.Status(fiber.StatusBadRequest).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: requestErr.Error(),
})
} else if errors.As(err, &unauthorizedErr) {
log.Error("Unauthorized Error: ", err)
return c.Status(fiber.StatusUnauthorized).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: unauthorizedErr.Error(),
})
} else if errors.As(err, &notFoundErr) {
log.Error("Not Found Error: ", err)
return c.Status(fiber.StatusNotFound).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: notFoundErr.Error(),
})
} else if errors.Is(err, fiber.ErrNotFound) {
return c.Status(fiber.StatusNotFound).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: "Not found",
})
} else if errors.Is(err, fiber.ErrMethodNotAllowed) {
return c.Status(fiber.StatusMethodNotAllowed).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: "Method not allowed",
})
} else {
var fiberErr *fiber.Error
if errors.As(err, &fiberErr) {
if fiberErr.Code == fiber.StatusNotFound {
return c.Status(fiber.StatusNotFound).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: "Not found",
})
}
}
return c.Status(fiber.StatusInternalServerError).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: "Internal server error",
})
}
}
return nil
}

View File

@@ -0,0 +1,19 @@
package middleware
import (
"github.com/gofiber/fiber/v3"
"nysoure/server/model"
"nysoure/server/utils"
)
func JwtMiddleware(c fiber.Ctx) error {
token := c.Get("Authorization")
if token != "" {
id, err := utils.ParseToken(token)
if err != nil {
return model.NewUnAuthorizedError("Invalid token")
}
c.Locals("uid", id)
}
return c.Next()
}

79
server/model/error.go Normal file
View File

@@ -0,0 +1,79 @@
package model
import (
"errors"
)
type RequestError struct {
Message string `json:"message"`
}
func (e *RequestError) Error() string {
return e.Message
}
func NewRequestError(message string) *RequestError {
return &RequestError{
Message: message,
}
}
func IsRequestError(err error) bool {
var requestError *RequestError
ok := errors.As(err, &requestError)
return ok
}
type UnAuthorizedError struct {
Message string `json:"message"`
}
func (e *UnAuthorizedError) Error() string {
return e.Message
}
func NewUnAuthorizedError(message string) *UnAuthorizedError {
return &UnAuthorizedError{
Message: message,
}
}
func IsUnAuthorizedError(err error) bool {
var unAuthorizedError *UnAuthorizedError
ok := errors.As(err, &unAuthorizedError)
return ok
}
type NotFoundError struct {
Message string `json:"message"`
}
func (e *NotFoundError) Error() string {
return e.Message
}
func NewNotFoundError(message string) *NotFoundError {
return &NotFoundError{
Message: message,
}
}
func IsNotFoundError(err error) bool {
var notFoundError *NotFoundError
ok := errors.As(err, &notFoundError)
return ok
}
type InternalServerError struct {
Message string `json:"message"`
}
func (e *InternalServerError) Error() string {
return e.Message
}
func NewInternalServerError(message string) *InternalServerError {
return &InternalServerError{
Message: message,
}
}

33
server/model/file.go Normal file
View File

@@ -0,0 +1,33 @@
package model
import (
"gorm.io/gorm"
)
type File struct {
gorm.Model
Filename string
Description string
StorageKey string
StorageID *uint `gorm:"default:null"`
Storage Storage
ResourceID uint
RedirectUrl string
Resource Resource `gorm:"foreignKey:ResourceID"`
UserID uint
User User `gorm:"foreignKey:UserID"`
}
type FileView struct {
ID uint `json:"id"`
Filename string `json:"filename"`
Description string `json:"description"`
}
func (f *File) ToView() *FileView {
return &FileView{
ID: f.ID,
Filename: f.Filename,
Description: f.Description,
}
}

26
server/model/image.go Normal file
View File

@@ -0,0 +1,26 @@
package model
import "gorm.io/gorm"
type Image struct {
gorm.Model
FileName string
Width int
Height int
// An image can only belong to one resource, or it doesn't belong to any resource and is waiting for usage.
Resource []Resource `gorm:"many2many:resource_images;"`
}
type ImageView struct {
ID uint `json:"id"`
Width int `json:"width"`
Height int `json:"height"`
}
func (i *Image) ToView() ImageView {
return ImageView{
ID: i.ID,
Width: i.Width,
Height: i.Height,
}
}

89
server/model/resource.go Normal file
View File

@@ -0,0 +1,89 @@
package model
import (
"time"
"gorm.io/gorm"
)
type Resource struct {
gorm.Model
Title string
AlternativeTitles []string `gorm:"serializer:json"`
Article string
Images []Image `gorm:"many2many:resource_images;"`
Tags []Tag `gorm:"many2many:resource_tags;"`
Files []File `gorm:"foreignKey:ResourceID"`
UserID uint
User User
}
type ResourceView struct {
ID uint `json:"id"`
Title string `json:"title"`
CreatedAt time.Time `json:"created_at"`
Tags []TagView `json:"tags"`
Image *ImageView `json:"image"`
Author UserView `json:"author"`
}
type ResourceDetailView struct {
ID uint `json:"id"`
Title string `json:"title"`
AlternativeTitles []string `json:"alternativeTitles"`
Article string `json:"article"`
CreatedAt time.Time `json:"createdAt"`
Tags []TagView `json:"tags"`
Images []ImageView `json:"images"`
Files []FileView `json:"files"`
Author UserView `json:"author"`
}
func (r *Resource) ToView() ResourceView {
tags := make([]TagView, len(r.Tags))
for i, tag := range r.Tags {
tags[i] = *tag.ToView()
}
var image *ImageView
if len(r.Images) > 0 {
v := r.Images[0].ToView()
image = &v
}
return ResourceView{
ID: r.ID,
Title: r.Title,
CreatedAt: r.CreatedAt,
Tags: tags,
Image: image,
Author: r.User.ToView(),
}
}
func (r *Resource) ToDetailView() ResourceDetailView {
images := make([]ImageView, len(r.Images))
for i, image := range r.Images {
images[i] = image.ToView()
}
tags := make([]TagView, len(r.Tags))
for i, tag := range r.Tags {
tags[i] = *tag.ToView()
}
files := make([]FileView, len(r.Files))
for i, file := range r.Files {
files[i] = *file.ToView()
}
return ResourceDetailView{
ID: r.ID,
Title: r.Title,
AlternativeTitles: r.AlternativeTitles,
Article: r.Article,
CreatedAt: r.CreatedAt,
Tags: tags,
Images: images,
Files: files,
Author: r.User.ToView(),
}
}

14
server/model/response.go Normal file
View File

@@ -0,0 +1,14 @@
package model
type Response[T any] struct {
Success bool `json:"success"`
Data T `json:"data,omitempty"`
Message string `json:"message,omitempty"`
}
type PageResponse[T any] struct {
Success bool `json:"success"`
TotalPages int `json:"totalPages"`
Data []T `json:"data"`
Message string `json:"message,omitempty"`
}

View File

@@ -0,0 +1,6 @@
package model
type Statistic struct {
Key string `gorm:"primaryKey"`
Value int64
}

35
server/model/storage.go Normal file
View File

@@ -0,0 +1,35 @@
package model
import (
"gorm.io/gorm"
"time"
)
type Storage struct {
gorm.Model
Name string
Type string
Config string
MaxSize int64
CurrentSize int64
}
type StorageView struct {
ID uint `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
MaxSize int64 `json:"maxSize"`
CurrentSize int64 `json:"currentSize"`
CreatedAt time.Time `json:"createdAt"`
}
func (s *Storage) ToView() StorageView {
return StorageView{
ID: s.ID,
Name: s.Name,
Type: s.Type,
MaxSize: s.MaxSize,
CurrentSize: s.CurrentSize,
CreatedAt: s.CreatedAt,
}
}

21
server/model/tag.go Normal file
View File

@@ -0,0 +1,21 @@
package model
import "gorm.io/gorm"
type Tag struct {
gorm.Model
Name string `gorm:"unique"`
Resources []Resource `gorm:"many2many:resource_tags;"`
}
type TagView struct {
ID uint `json:"id"`
Name string `json:"name"`
}
func (t *Tag) ToView() *TagView {
return &TagView{
ID: t.ID,
Name: t.Name,
}
}

View File

@@ -0,0 +1,77 @@
package model
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/schema"
"reflect"
)
type UploadingFile struct {
gorm.Model
Filename string
Description string
TargetResourceID uint
TargetStorageID uint
UserID uint
BlockSize int64
TotalSize int64
Blocks UploadingFileBlocks `gorm:"type:blob"`
TempPath string
Resource Resource `gorm:"foreignKey:TargetResourceID"`
Storage Storage `gorm:"foreignKey:TargetStorageID"`
}
func (uf *UploadingFile) BlocksCount() int {
return int((uf.TotalSize + uf.BlockSize - 1) / uf.BlockSize)
}
type UploadingFileBlocks []bool
func (ufb *UploadingFileBlocks) Scan(ctx context.Context, field *schema.Field, dst reflect.Value, dbValue interface{}) (err error) {
data, ok := dbValue.([]byte)
if !ok {
return nil
}
*ufb = make([]bool, len(data)*8)
for i, b := range data {
for j := 0; j < 8; j++ {
(*ufb)[i*8+j] = (b>>j)&1 == 1
}
}
return nil
}
func (ufb UploadingFileBlocks) Value(ctx context.Context, field *schema.Field, dbValue reflect.Value) (value interface{}, err error) {
data := make([]byte, (len(ufb)+7)/8)
for i, b := range ufb {
if b {
data[i/8] |= 1 << (i % 8)
}
}
return data, nil
}
type UploadingFileView struct {
ID uint `json:"id"`
Filename string `json:"filename"`
Description string `json:"description"`
TotalSize int64 `json:"totalSize"`
BlockSize int64 `json:"blockSize"`
BlocksCount int `json:"blocksCount"`
StorageID uint `json:"storageId"`
ResourceID uint `json:"resourceId"`
}
func (uf *UploadingFile) ToView() *UploadingFileView {
return &UploadingFileView{
ID: uf.ID,
Filename: uf.Filename,
Description: uf.Description,
TotalSize: uf.TotalSize,
BlockSize: uf.BlockSize,
BlocksCount: uf.BlocksCount(),
StorageID: uf.TargetStorageID,
ResourceID: uf.TargetResourceID,
}
}

49
server/model/user.go Normal file
View File

@@ -0,0 +1,49 @@
package model
import (
"fmt"
"gorm.io/gorm"
"time"
)
type User struct {
gorm.Model
Username string
PasswordHash []byte
IsAdmin bool
CanUpload bool
AvatarVersion int
Resources []Resource `gorm:"foreignKey:UserID"`
}
type UserView struct {
ID uint `json:"id"`
Username string `json:"username"`
CreatedAt time.Time `json:"created_at"`
AvatarPath string `json:"avatar_path"`
IsAdmin bool `json:"is_admin"`
CanUpload bool `json:"can_upload"`
}
type UserViewWithToken struct {
UserView
Token string `json:"token"`
}
func (u User) ToView() UserView {
return UserView{
ID: u.ID,
Username: u.Username,
CreatedAt: u.CreatedAt,
AvatarPath: fmt.Sprintf("/api/user/avatar/%d?v=%d", u.ID, u.AvatarVersion),
IsAdmin: u.IsAdmin,
CanUpload: u.CanUpload || u.IsAdmin,
}
}
func (u UserView) WithToken(token string) UserViewWithToken {
return UserViewWithToken{
UserView: u,
Token: token,
}
}

320
server/service/file.go Normal file
View File

@@ -0,0 +1,320 @@
package service
import (
"github.com/gofiber/fiber/v3/log"
"github.com/google/uuid"
"nysoure/server/dao"
"nysoure/server/model"
"nysoure/server/storage"
"nysoure/server/utils"
"os"
"path/filepath"
"strconv"
"time"
)
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")
}
func updateUploadingSize(offset int64) {
c := dao.GetStatistic("uploading_size")
c += offset
_ = dao.SetStatistic("uploading_size", c)
}
func getTempDir() (string, error) {
name := uuid.NewString()
path := filepath.Join(utils.GetStoragePath(), "uploading", name)
if err := os.MkdirAll(path, os.ModePerm); err != nil {
return "", err
}
return path, nil
}
func init() {
go func() {
// Wait for 1 minute to ensure the database is ready
time.Sleep(time.Minute)
for {
oneDayAgo := time.Now().Add(-24 * time.Hour)
oldFiles, err := dao.GetUploadingFilesOlderThan(oneDayAgo)
if err != nil {
log.Error("failed to get old uploading files: ", err)
} else {
for _, file := range oldFiles {
if err := os.RemoveAll(file.TempPath); err != nil {
log.Error("failed to remove temp dir: ", err)
}
if err := dao.DeleteUploadingFile(file.ID); err != nil {
log.Error("failed to delete uploading file: ", err)
}
updateUploadingSize(-file.TotalSize)
}
}
// Sleep for 1 hour
time.Sleep(1 * time.Hour)
}
}()
}
func CreateUploadingFile(uid uint, filename string, description string, fileSize int64, resourceID, storageID uint) (*model.UploadingFileView, error) {
if filename == "" {
return nil, model.NewRequestError("filename is empty")
}
canUpload, err := checkUserCanUpload(uid)
if err != nil {
log.Error("failed to check user permission: ", err)
return nil, model.NewInternalServerError("failed to check user permission")
}
if !canUpload {
return nil, model.NewUnAuthorizedError("user cannot upload file")
}
if fileSize > maxFileSize {
return nil, model.NewRequestError("file size exceeds the limit")
}
currentUploadingSize := getUploadingSize(uid)
if currentUploadingSize+fileSize > 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")
}
tempPath, err := getTempDir()
if err != nil {
log.Error("failed to create temp dir: ", err)
return nil, model.NewInternalServerError("failed to create temp dir")
}
uploadingFile, err := dao.CreateUploadingFile(filename, description, fileSize, blockSize, tempPath, resourceID, storageID, uid)
if err != nil {
log.Error("failed to create uploading file: ", err)
_ = os.Remove(tempPath)
return nil, model.NewInternalServerError("failed to create uploading file")
}
updateUploadingSize(fileSize)
return uploadingFile.ToView(), nil
}
func UploadBlock(uid uint, fid uint, index int, data []byte) error {
uploadingFile, err := dao.GetUploadingFile(fid)
if err != nil {
log.Error("failed to get uploading file: ", err)
return model.NewNotFoundError("file not found")
}
if uploadingFile.UserID != uid {
return model.NewUnAuthorizedError("user cannot upload file")
}
if len(data) > int(uploadingFile.BlockSize) {
return model.NewRequestError("block size exceeds the limit")
}
if index != uploadingFile.BlocksCount()-1 && len(data) != int(uploadingFile.BlockSize) {
return model.NewRequestError("block size is not correct")
}
if index < 0 || index >= uploadingFile.BlocksCount() {
return model.NewRequestError("block index is not correct")
}
if uploadingFile.Blocks[index] {
return model.NewRequestError("block already uploaded")
}
path := filepath.Join(uploadingFile.TempPath, strconv.Itoa(index))
if err := os.WriteFile(path, data, os.ModePerm); err != nil {
log.Error("failed to write block file: ", err)
return model.NewInternalServerError("failed to write block file")
}
uploadingFile.Blocks[index] = true
if err := dao.UpdateUploadingBlock(fid, index); err != nil {
log.Error("failed to update uploading file: ", err)
_ = os.Remove(path)
return model.NewInternalServerError("failed to update uploading file")
}
return nil
}
func FinishUploadingFile(uid uint, fid uint) (*model.FileView, error) {
uploadingFile, err := dao.GetUploadingFile(fid)
if err != nil {
log.Error("failed to get uploading file: ", err)
return nil, model.NewNotFoundError("file not found")
}
if uploadingFile.UserID != uid {
return nil, model.NewUnAuthorizedError("user cannot upload file")
}
for i := 0; i < uploadingFile.BlocksCount(); i++ {
if !uploadingFile.Blocks[i] {
return nil, model.NewRequestError("file is not completely uploaded")
}
}
tempRemoved := false
defer func() {
if !tempRemoved {
if err := os.RemoveAll(uploadingFile.TempPath); err != nil {
log.Error("failed to remove temp dir: ", err)
}
}
if err := dao.DeleteUploadingFile(fid); err != nil {
log.Error("failed to delete uploading file: ", err)
}
updateUploadingSize(-uploadingFile.TotalSize)
}()
resultFilePath := filepath.Join(utils.GetStoragePath(), uuid.NewString())
file, err := os.OpenFile(resultFilePath, os.O_CREATE|os.O_WRONLY, os.ModePerm)
if err != nil {
log.Error("failed to open result file: ", err)
return nil, model.NewInternalServerError("failed to finish uploading file. please re-upload")
}
defer func() {
_ = os.Remove(resultFilePath)
}()
for i := 0; i < uploadingFile.BlocksCount(); i++ {
blockPath := filepath.Join(uploadingFile.TempPath, strconv.Itoa(i))
data, err := os.ReadFile(blockPath)
if err != nil {
log.Error("failed to read block file: ", err)
_ = file.Close()
return nil, model.NewInternalServerError("failed to finish uploading file. please re-upload")
}
if _, err := file.Write(data); err != nil {
log.Error("failed to write result file: ", err)
_ = file.Close()
return nil, model.NewInternalServerError("failed to finish uploading file. please re-upload")
}
}
_ = file.Close()
_ = os.RemoveAll(uploadingFile.TempPath)
tempRemoved = true
s, err := dao.GetStorage(uploadingFile.TargetStorageID)
if err != nil {
log.Error("failed to get storage: ", err)
return nil, model.NewInternalServerError("failed to finish uploading file. please re-upload")
}
iStorage := storage.NewStorage(s)
if iStorage == nil {
log.Error("failed to find storage: ", err)
return nil, model.NewInternalServerError("failed to finish uploading file. please re-upload")
}
storageKey, err := iStorage.Upload(resultFilePath)
if err != nil {
log.Error("failed to upload file: ", err)
return nil, model.NewInternalServerError("failed to finish uploading file. please re-upload")
}
dbFile, err := dao.CreateFile(uploadingFile.Filename, uploadingFile.Description, uploadingFile.TargetResourceID, &uploadingFile.TargetStorageID, storageKey, "")
if err != nil {
log.Error("failed to create file in db: ", err)
_ = iStorage.Delete(storageKey)
return nil, model.NewInternalServerError("failed to finish uploading file. please re-upload")
}
return dbFile.ToView(), nil
}
func CreateRedirectFile(uid uint, filename string, description string, resourceID uint, redirectUrl string) (*model.FileView, error) {
canUpload, err := checkUserCanUpload(uid)
if err != nil {
log.Error("failed to check user permission: ", err)
return nil, model.NewInternalServerError("failed to check user permission")
}
if !canUpload {
return nil, model.NewUnAuthorizedError("user cannot upload file")
}
file, err := dao.CreateFile(filename, description, resourceID, nil, "", redirectUrl)
if err != nil {
log.Error("failed to create file in db: ", err)
return nil, model.NewInternalServerError("failed to create file in db")
}
return file.ToView(), nil
}
func DeleteFile(uid uint, fid uint) error {
file, err := dao.GetFile(fid)
if err != nil {
log.Error("failed to get file: ", err)
return model.NewNotFoundError("file not found")
}
isAdmin, err := checkUserIsAdmin(uid)
if err != nil {
log.Error("failed to check user permission: ", err)
return model.NewInternalServerError("failed to check user permission")
}
if !isAdmin && file.UserID != uid {
return model.NewUnAuthorizedError("user cannot delete file")
}
iStorage := storage.NewStorage(file.Storage)
if iStorage == nil {
log.Error("failed to find storage: ", err)
return model.NewInternalServerError("failed to find storage")
}
if err := iStorage.Delete(file.StorageKey); err != nil {
log.Error("failed to delete file from storage: ", err)
return model.NewInternalServerError("failed to delete file from storage")
}
if err := dao.DeleteFile(fid); err != nil {
log.Error("failed to delete file from db: ", err)
return model.NewInternalServerError("failed to delete file from db")
}
return nil
}
func UpdateFile(uid uint, fid uint, filename string, description string) (*model.FileView, error) {
file, err := dao.GetFile(fid)
if err != nil {
log.Error("failed to get file: ", err)
return nil, model.NewNotFoundError("file not found")
}
isAdmin, err := checkUserIsAdmin(uid)
if err != nil {
log.Error("failed to check user permission: ", err)
return nil, model.NewInternalServerError("failed to check user permission")
}
if !isAdmin && file.UserID != uid {
return nil, model.NewUnAuthorizedError("user cannot update file")
}
file, err = dao.UpdateFile(fid, filename, description)
if err != nil {
log.Error("failed to update file in db: ", err)
return nil, model.NewInternalServerError("failed to update file in db")
}
return file.ToView(), nil
}
func GetFile(fid uint) (*model.FileView, error) {
file, err := dao.GetFile(fid)
if err != nil {
log.Error("failed to get file: ", err)
return nil, model.NewNotFoundError("file not found")
}
return file.ToView(), nil
}

129
server/service/image.go Normal file
View File

@@ -0,0 +1,129 @@
package service
import (
"bytes"
"errors"
"github.com/gofiber/fiber/v3/log"
"github.com/google/uuid"
"image"
"net/http"
"nysoure/server/dao"
"nysoure/server/model"
"nysoure/server/utils"
"os"
"time"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
_ "golang.org/x/image/webp"
"github.com/chai2010/webp"
)
func init() {
// Start a goroutine to delete unused images every hour
go func() {
// Wait for 1 minute to ensure the database is ready
time.Sleep(time.Minute)
for {
images, err := dao.GetUnusedImages()
if err != nil {
log.Errorf("Failed to get unused images: %v", err)
}
if len(images) > 0 {
for _, i := range images {
err := DeleteImage(i.ID)
if err != nil {
log.Errorf("Failed to delete unused image %d: %v", i.ID, err)
}
}
}
time.Sleep(time.Hour)
}
}()
}
func CreateImage(data []byte) (uint, error) {
if len(data) == 0 {
return 0, model.NewRequestError("Image data is empty")
} else if len(data) > 1024*1024*5 {
return 0, model.NewRequestError("Image data is too large")
}
imageDir := utils.GetStoragePath() + "/images/"
if _, err := os.Stat(imageDir); os.IsNotExist(err) {
if err := os.MkdirAll(imageDir, 0755); err != nil {
return 0, err
}
}
contentType := http.DetectContentType(data)
if contentType != "image/jpeg" && contentType != "image/png" && contentType != "image/gif" && contentType != "image/webp" {
return 0, model.NewRequestError("Invalid image format")
}
// Reformat the image data to webp format if necessary
img, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return 0, errors.New("failed to decode image data")
}
if img.Bounds().Dx() == 0 || img.Bounds().Dy() == 0 {
return 0, errors.New("invalid image dimensions")
}
if contentType != "image/webp" {
buf := new(bytes.Buffer)
if err := webp.Encode(buf, img, &webp.Options{Quality: 80}); err != nil {
return 0, errors.New("failed to encode image data to webp format")
}
data = buf.Bytes()
contentType = "image/webp"
}
filename := uuid.New().String()
if err := os.WriteFile(imageDir+filename, data, 0644); err != nil {
return 0, errors.New("failed to save image file")
}
i, err := dao.CreateImage(filename, img.Bounds().Dx(), img.Bounds().Dy())
if err != nil {
_ = os.Remove(imageDir + filename)
return 0, err
}
return i.ID, nil
}
func GetImage(id uint) ([]byte, error) {
i, err := dao.GetImageByID(id)
if err != nil {
return nil, err
}
imageDir := utils.GetStoragePath() + "/images/"
if _, err := os.Stat(imageDir); os.IsNotExist(err) {
return nil, model.NewNotFoundError("Image not found")
}
data, err := os.ReadFile(imageDir + i.FileName)
if err != nil {
return nil, errors.New("Failed to read image file")
}
return data, nil
}
func DeleteImage(id uint) error {
i, err := dao.GetImageByID(id)
if err != nil {
return err
}
imageDir := utils.GetStoragePath() + "/images/"
_ = os.Remove(imageDir + i.FileName)
if err := dao.DeleteImage(id); err != nil {
return err
}
return nil
}

129
server/service/resource.go Normal file
View File

@@ -0,0 +1,129 @@
package service
import (
"nysoure/server/dao"
"nysoure/server/model"
"gorm.io/gorm"
)
const (
pageSize = 20
)
type ResourceCreateParams struct {
Title string `json:"title" binding:"required"`
AlternativeTitles []string `json:"alternative_titles"`
Tags []uint `json:"tags"`
Article string `json:"article"`
Images []uint `json:"images"`
}
func CreateResource(uid uint, params *ResourceCreateParams) (uint, error) {
canUpload, err := checkUserCanUpload(uid)
if err != nil {
return 0, err
}
if !canUpload {
return 0, model.NewUnAuthorizedError("You have not permission to upload resources")
}
images := make([]model.Image, len(params.Images))
for i, id := range params.Images {
images[i] = model.Image{
Model: gorm.Model{
ID: id,
},
}
}
tags := make([]model.Tag, len(params.Tags))
for i, id := range params.Tags {
tags[i] = model.Tag{
Model: gorm.Model{
ID: id,
},
}
}
r := model.Resource{
Title: params.Title,
AlternativeTitles: params.AlternativeTitles,
Article: params.Article,
Images: images,
Tags: tags,
UserID: uid,
}
if r, err = dao.CreateResource(r); err != nil {
return 0, err
}
return r.ID, nil
}
func GetResource(id uint) (*model.ResourceDetailView, error) {
r, err := dao.GetResourceByID(id)
if err != nil {
return nil, err
}
v := r.ToDetailView()
return &v, nil
}
func GetResourceList(page int) ([]model.ResourceView, int, error) {
resources, totalPages, err := dao.GetResourceList(page, pageSize)
if err != nil {
return nil, 0, err
}
var views []model.ResourceView
for _, r := range resources {
views = append(views, r.ToView())
}
return views, totalPages, nil
}
func SearchResource(keyword string, page int) ([]model.ResourceView, int, error) {
resources, totalPages, err := dao.Search(keyword, page, pageSize)
if err != nil {
return nil, 0, err
}
var views []model.ResourceView
for _, r := range resources {
views = append(views, r.ToView())
}
return views, totalPages, nil
}
func DeleteResource(uid, id uint) error {
isAdmin, err := checkUserIsAdmin(uid)
if err != nil {
return err
}
if !isAdmin {
r, err := dao.GetResourceByID(id)
if err != nil {
return err
}
if r.UserID != uid {
return model.NewUnAuthorizedError("You have not permission to delete this resource")
}
}
if err := dao.DeleteResource(id); err != nil {
return err
}
return nil
}
func GetResourcesWithTag(tag string, page int) ([]model.ResourceView, int, error) {
t, err := dao.GetTagByName(tag)
if err != nil {
return nil, 0, err
}
tagID := t.ID
resources, totalPages, err := dao.GetResourceByTag(tagID, page, pageSize)
if err != nil {
return nil, 0, err
}
var views []model.ResourceView
for _, r := range resources {
views = append(views, r.ToView())
}
return views, totalPages, nil
}

104
server/service/storage.go Normal file
View File

@@ -0,0 +1,104 @@
package service
import (
"github.com/gofiber/fiber/v3/log"
"nysoure/server/dao"
"nysoure/server/model"
"nysoure/server/storage"
"os"
)
type CreateS3StorageParams struct {
Name string `json:"name"`
EndPoint string `json:"endPoint"`
AccessKeyID string `json:"accessKeyID"`
SecretAccessKey string `json:"secretAccessKey"`
BucketName string `json:"bucketName"`
MaxSizeInMB uint `json:"maxSizeInMB"`
}
func CreateS3Storage(uid uint, params CreateS3StorageParams) 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 create s3 storage")
}
s3 := storage.S3Storage{
EndPoint: params.EndPoint,
AccessKeyID: params.AccessKeyID,
SecretAccessKey: params.SecretAccessKey,
BucketName: params.BucketName,
}
s := model.Storage{
Name: params.Name,
Type: s3.Type(),
Config: s3.ToString(),
MaxSize: int64(params.MaxSizeInMB) * 1024 * 1024,
}
_, err = dao.CreateStorage(s)
return err
}
type CreateLocalStorageParams struct {
Name string `json:"name"`
Path string `json:"path"`
MaxSizeInMB uint `json:"maxSizeInMB"`
}
func CreateLocalStorage(uid uint, params CreateLocalStorageParams) 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 create local storage")
}
local := storage.LocalStorage{
Path: params.Path,
}
err = os.MkdirAll(params.Path, os.ModePerm)
if err != nil {
log.Errorf("create local storage dir failed: %s", err)
return model.NewInternalServerError("create local storage dir failed")
}
s := model.Storage{
Name: params.Name,
Type: local.Type(),
Config: local.ToString(),
MaxSize: int64(params.MaxSizeInMB) * 1024 * 1024,
}
_, err = dao.CreateStorage(s)
return err
}
func ListStorages() ([]model.StorageView, error) {
storages, err := dao.GetStorages()
if err != nil {
return nil, err
}
var result []model.StorageView
for _, s := range storages {
result = append(result, s.ToView())
}
return result, nil
}
func DeleteStorage(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 delete storage")
}
err = dao.DeleteStorage(id)
if err != nil {
return err
}
return nil
}

38
server/service/tag.go Normal file
View File

@@ -0,0 +1,38 @@
package service
import (
"nysoure/server/dao"
"nysoure/server/model"
)
func CreateTag(name string) (*model.TagView, error) {
t, err := dao.CreateTag(name)
if err != nil {
return nil, err
}
return t.ToView(), nil
}
func GetTag(id uint) (*model.TagView, error) {
t, err := dao.GetTagByID(id)
if err != nil {
return nil, err
}
return t.ToView(), nil
}
func SearchTag(name string) ([]model.TagView, error) {
tags, err := dao.SearchTag(name)
if err != nil {
return nil, err
}
var tagViews []model.TagView
for _, t := range tags {
tagViews = append(tagViews, *t.ToView())
}
return tagViews, nil
}
func DeleteTag(id uint) error {
return dao.DeleteTag(id)
}

263
server/service/user.go Normal file
View File

@@ -0,0 +1,263 @@
package service
import (
"errors"
"fmt"
"nysoure/server/dao"
"nysoure/server/model"
"nysoure/server/static"
"nysoure/server/utils"
"os"
"strconv"
"golang.org/x/crypto/bcrypt"
)
const (
embedAvatarCount = 1
)
func CreateUser(username, password string) (model.UserViewWithToken, error) {
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return model.UserViewWithToken{}, err
}
user, err := dao.CreateUser(username, hashedPassword)
if err != nil {
return model.UserViewWithToken{}, err
}
token, err := utils.GenerateToken(user.ID)
if err != nil {
return model.UserViewWithToken{}, err
}
return user.ToView().WithToken(token), nil
}
func Login(username, password string) (model.UserViewWithToken, error) {
user, err := dao.GetUserByUsername(username)
if err != nil {
if model.IsNotFoundError(err) {
return model.UserViewWithToken{}, model.NewRequestError("User not found")
}
return model.UserViewWithToken{}, err
}
if err := bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(password)); err != nil {
return model.UserViewWithToken{}, model.NewRequestError("Invalid password")
}
token, err := utils.GenerateToken(user.ID)
if err != nil {
return model.UserViewWithToken{}, err
}
return user.ToView().WithToken(token), nil
}
func ChangePassword(id uint, oldPassword, newPassword string) (model.UserViewWithToken, error) {
user, err := dao.GetUserByID(id)
if err != nil {
return model.UserViewWithToken{}, err
}
if err := bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(oldPassword)); err != nil {
return model.UserViewWithToken{}, err
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return model.UserViewWithToken{}, err
}
user.PasswordHash = hashedPassword
if err := dao.UpdateUser(user); err != nil {
return model.UserViewWithToken{}, err
}
token, err := utils.GenerateToken(user.ID)
if err != nil {
return model.UserViewWithToken{}, err
}
return user.ToView().WithToken(token), nil
}
func ChangeAvatar(id uint, image []byte) (model.UserView, error) {
user, err := dao.GetUserByID(id)
if err != nil {
return model.UserView{}, err
}
if len(image) > 4*1024*1024 {
return model.UserView{}, errors.New("image size is too large")
}
avatarDir := utils.GetStoragePath() + "/avatar"
if _, err := os.Stat(avatarDir); os.IsNotExist(err) {
if err := os.MkdirAll(avatarDir, os.ModePerm); err != nil {
return model.UserView{}, errors.New("failed to create avatar directory")
}
}
avatarPath := avatarDir + "/" + strconv.Itoa(int(user.ID))
if err := os.WriteFile(avatarPath, image, 0644); err != nil {
return model.UserView{}, errors.New("failed to save avatar")
}
user.AvatarVersion++
if err := dao.UpdateUser(user); err != nil {
return model.UserView{}, err
}
return user.ToView(), nil
}
func GetAvatar(id uint) ([]byte, error) {
avatarPath := utils.GetStoragePath() + "/avatar/" + strconv.Itoa(int(id))
if _, err := os.Stat(avatarPath); os.IsNotExist(err) {
return getEmbedAvatar(id)
}
image, err := os.ReadFile(avatarPath)
if err != nil {
return nil, errors.New("failed to read avatar")
}
return image, nil
}
func getEmbedAvatar(id uint) ([]byte, error) {
fileIndex := id%embedAvatarCount + 1
fileName := fmt.Sprintf("avatars/%d.png", fileIndex)
return static.Static.ReadFile(fileName)
}
func HavePermissionToUpload(id uint) error {
user, err := dao.GetUserByID(id)
if err != nil {
return err
}
if !user.IsAdmin && !user.CanUpload {
return model.NewUnAuthorizedError("User does not have permission to upload")
}
return nil
}
func SetUserAdmin(adminID uint, targetUserID uint, isAdmin bool) (model.UserView, error) {
if adminID == targetUserID {
return model.UserView{}, model.NewRequestError("You cannot modify your own admin status")
}
adminUser, err := dao.GetUserByID(adminID)
if err != nil {
return model.UserView{}, err
}
if !adminUser.IsAdmin {
return model.UserView{}, model.NewUnAuthorizedError("Only administrators can modify admin status")
}
targetUser, err := dao.GetUserByID(targetUserID)
if err != nil {
return model.UserView{}, err
}
targetUser.IsAdmin = isAdmin
if err := dao.UpdateUser(targetUser); err != nil {
return model.UserView{}, err
}
return targetUser.ToView(), nil
}
func SetUserUploadPermission(adminID uint, targetUserID uint, canUpload bool) (model.UserView, error) {
adminUser, err := dao.GetUserByID(adminID)
if err != nil {
return model.UserView{}, err
}
if !adminUser.IsAdmin {
return model.UserView{}, model.NewUnAuthorizedError("Only administrators can modify upload permissions")
}
targetUser, err := dao.GetUserByID(targetUserID)
if err != nil {
return model.UserView{}, err
}
targetUser.CanUpload = canUpload
if err := dao.UpdateUser(targetUser); err != nil {
return model.UserView{}, err
}
return targetUser.ToView(), nil
}
func ListUsers(adminID uint, page int) ([]model.UserView, int, error) {
admin, err := dao.GetUserByID(adminID)
if err != nil {
return nil, 0, err
}
if !admin.IsAdmin {
return nil, 0, model.NewUnAuthorizedError("Only administrators can list users")
}
if page < 1 {
page = 1
}
pageSize := 10
users, total, err := dao.ListUsers(page, pageSize)
if err != nil {
return nil, 0, err
}
userViews := make([]model.UserView, len(users))
for i, user := range users {
userViews[i] = user.ToView()
}
totalPages := int((total + int64(pageSize) - 1) / int64(pageSize))
return userViews, totalPages, nil
}
func SearchUsers(adminID uint, username string, page int) ([]model.UserView, int, error) {
admin, err := dao.GetUserByID(adminID)
if err != nil {
return nil, 0, err
}
if !admin.IsAdmin {
return nil, 0, model.NewUnAuthorizedError("Only administrators can search users")
}
if page < 1 {
page = 1
}
pageSize := 10
users, total, err := dao.SearchUsersByUsername(username, page, pageSize)
if err != nil {
return nil, 0, err
}
userViews := make([]model.UserView, len(users))
for i, user := range users {
userViews[i] = user.ToView()
}
totalPages := int((total + int64(pageSize) - 1) / int64(pageSize))
return userViews, totalPages, nil
}
func DeleteUser(adminID uint, targetUserID uint) error {
admin, err := dao.GetUserByID(adminID)
if err != nil {
return err
}
if !admin.IsAdmin {
return model.NewUnAuthorizedError("Only administrators can delete users")
}
// Check if user is trying to delete themselves
if adminID == targetUserID {
return model.NewRequestError("You cannot delete your own account")
}
// Check if target user exists
_, err = dao.GetUserByID(targetUserID)
if err != nil {
return err
}
return dao.DeleteUser(targetUserID)
}

19
server/service/utils.go Normal file
View File

@@ -0,0 +1,19 @@
package service
import "nysoure/server/dao"
func checkUserCanUpload(uid uint) (bool, error) {
user, err := dao.GetUserByID(uid)
if err != nil {
return false, err
}
return user.IsAdmin || user.CanUpload, nil
}
func checkUserIsAdmin(uid uint) (bool, error) {
user, err := dao.GetUserByID(uid)
if err != nil {
return false, err
}
return user.IsAdmin, nil
}

BIN
server/static/avatars/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

6
server/static/static.go Normal file
View File

@@ -0,0 +1,6 @@
package static
import "embed"
//go:embed *
var Static embed.FS

61
server/storage/local.go Normal file
View File

@@ -0,0 +1,61 @@
package storage
import (
"io"
"os"
"github.com/google/uuid"
)
type LocalStorage struct {
Path string
}
func (s *LocalStorage) Upload(filePath string) (string, error) {
id := uuid.New().String()
inputPath := s.Path + "/" + id
input, err := os.OpenFile(inputPath, os.O_RDWR|os.O_CREATE, 0755)
if err != nil {
return "", err
}
defer input.Close()
output, err := os.Open(filePath)
if err != nil {
return "", err
}
defer output.Close()
_, err = io.Copy(input, output)
if err != nil {
return "", err
}
return id, nil
}
func (s *LocalStorage) Download(storageKey string) (string, error) {
path := s.Path + "/" + storageKey
if _, err := os.Stat(path); os.IsNotExist(err) {
return "", ErrFileUnavailable
}
return path, nil
}
func (s *LocalStorage) Delete(storageKey string) error {
path := s.Path + "/" + storageKey
if _, err := os.Stat(path); os.IsNotExist(err) {
return nil
}
return os.Remove(path)
}
func (s *LocalStorage) ToString() string {
return s.Path
}
func (s *LocalStorage) FromString(config string) error {
s.Path = config
return nil
}
func (s *LocalStorage) Type() string {
return "local"
}

52
server/storage/s3.go Normal file
View File

@@ -0,0 +1,52 @@
package storage
import (
"encoding/json"
"errors"
)
type S3Storage struct {
EndPoint string
AccessKeyID string
SecretAccessKey string
BucketName string
}
func (s *S3Storage) Upload(filePath string) (string, error) {
// TODO: Implement S3 upload logic here
return "", nil
}
func (s *S3Storage) Download(storageKey string) (string, error) {
// TODO: Implement S3 download logic here
return "", nil
}
func (s *S3Storage) Delete(storageKey string) error {
// TODO: Implement S3 delete logic here
return nil
}
func (s *S3Storage) ToString() string {
data, _ := json.Marshal(s)
return string(data)
}
func (s *S3Storage) FromString(config string) error {
var s3Config S3Storage
if err := json.Unmarshal([]byte(config), &s3Config); err != nil {
return err
}
s.EndPoint = s3Config.EndPoint
s.AccessKeyID = s3Config.AccessKeyID
s.SecretAccessKey = s3Config.SecretAccessKey
s.BucketName = s3Config.BucketName
if s.EndPoint == "" || s.AccessKeyID == "" || s.SecretAccessKey == "" || s.BucketName == "" {
return errors.New("invalid S3 configuration")
}
return nil
}
func (s *S3Storage) Type() string {
return "s3"
}

48
server/storage/storage.go Normal file
View File

@@ -0,0 +1,48 @@
package storage
import (
"errors"
"nysoure/server/model"
)
var (
// ErrFileUnavailable is returned when the file is unavailable.
// When this error is returned, it is required to delete the file info from the database.
ErrFileUnavailable = errors.New("file unavailable")
)
type IStorage interface {
// Upload uploads a file to the storage and returns the storage key.
Upload(filePath string) (string, error)
// Download return the download url of the file with the given storage key.
Download(storageKey string) (string, error)
// Delete deletes the file with the given storage key.
Delete(storageKey string) error
// ToString returns the storage configuration as a string.
ToString() string
// FromString initializes the storage configuration from a string.
FromString(config string) error
// Type returns the type of the storage.
Type() string
}
func NewStorage(s model.Storage) IStorage {
switch s.Type {
case "s3":
r := S3Storage{}
err := r.FromString(s.Config)
if err != nil {
return nil
}
return &r
case "local":
r := LocalStorage{}
err := r.FromString(s.Config)
if err != nil {
return nil
}
return &r
}
return nil
}

62
server/utils/jwt.go Normal file
View File

@@ -0,0 +1,62 @@
package utils
import (
"errors"
"github.com/golang-jwt/jwt/v5"
"math/rand"
"os"
"time"
)
var (
key []byte
)
func init() {
secretFilePath := GetStoragePath() + "/jwt_secret.key"
secret, err := os.ReadFile(secretFilePath)
if err != nil {
key = secret
} else {
// Initialize the key with a random value
chars := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")
key = make([]byte, 32)
for i := range key {
r := rand.Intn(len(chars))
key[i] = byte(chars[r])
}
err = os.WriteFile(secretFilePath, key, 0644)
}
}
func GenerateToken(userID uint) (string, error) {
t := jwt.NewWithClaims(jwt.SigningMethodHS256,
jwt.MapClaims{
"id": userID,
"exp": time.Now().Add(7 * 24 * time.Hour).Unix(),
})
s, err := t.SignedString(key)
if err != nil {
return "", err
}
return s, nil
}
func ParseToken(token string) (uint, error) {
t, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
return key, nil
})
if err != nil {
return 0, err
}
if claims, ok := t.Claims.(jwt.MapClaims); ok && t.Valid {
id := uint(claims["id"].(float64))
expF := claims["exp"].(float64)
exp := time.Unix(int64(expF), 0)
if time.Now().After(exp) {
return 0, errors.New("token expired")
}
return id, nil
}
return 0, errors.New("invalid token")
}

26
server/utils/storage.go Normal file
View File

@@ -0,0 +1,26 @@
package utils
import (
"os"
"runtime"
)
var path string
func GetStoragePath() string {
if path != "" {
return path
}
if runtime.GOOS == "linux" {
path = "/var/lib/nysoure"
} else {
userDir, _ := os.UserHomeDir()
path = userDir + "/.nysoure"
}
if _, err := os.Stat(path); os.IsNotExist(err) {
if err := os.MkdirAll(path, os.ModePerm); err != nil {
panic("failed to create storage directory")
}
}
return path
}