mirror of
https://github.com/wgh136/nysoure.git
synced 2025-09-27 04:17:23 +00:00
Initial commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.idea
|
||||
*.iml
|
||||
test.db
|
||||
.idea/
|
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
54
frontend/README.md
Normal 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
42
frontend/eslint.config.js
Normal 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
12
frontend/index.html
Normal 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
6334
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
frontend/package.json
Normal file
40
frontend/package.json
Normal 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
39
frontend/src/app.ts
Normal 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
28
frontend/src/app.tsx
Normal 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>
|
||||
)
|
||||
}
|
9
frontend/src/components/alert.tsx
Normal file
9
frontend/src/components/alert.tsx
Normal 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>;
|
||||
}
|
10
frontend/src/components/loading.tsx
Normal file
10
frontend/src/components/loading.tsx
Normal 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>;
|
||||
}
|
191
frontend/src/components/navigator.tsx
Normal file
191
frontend/src/components/navigator.tsx
Normal 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
|
||||
}
|
36
frontend/src/components/pagination.tsx
Normal file
36
frontend/src/components/pagination.tsx
Normal 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>
|
||||
}
|
61
frontend/src/components/popup.tsx
Normal file
61
frontend/src/components/popup.tsx
Normal 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>
|
||||
}
|
45
frontend/src/components/resource_card.tsx
Normal file
45
frontend/src/components/resource_card.tsx
Normal 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>
|
||||
}
|
44
frontend/src/components/resources_view.tsx
Normal file
44
frontend/src/components/resources_view.tsx
Normal 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>
|
||||
}
|
14
frontend/src/components/toast.ts
Normal file
14
frontend/src/components/toast.ts
Normal 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
275
frontend/src/i18n.ts
Normal 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
113
frontend/src/index.css
Normal 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
26
frontend/src/main.tsx
Normal 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
81
frontend/src/markdown.css
Normal 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;
|
||||
}
|
||||
}
|
91
frontend/src/network/models.ts
Normal file
91
frontend/src/network/models.ts
Normal 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;
|
||||
}
|
525
frontend/src/network/network.ts
Normal file
525
frontend/src/network/network.ts
Normal 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();
|
||||
|
6
frontend/src/pages/home_page.tsx
Normal file
6
frontend/src/pages/home_page.tsx
Normal 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>
|
||||
}
|
69
frontend/src/pages/login_page.tsx
Normal file
69
frontend/src/pages/login_page.tsx
Normal 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>
|
||||
}
|
87
frontend/src/pages/manage_page.tsx
Normal file
87
frontend/src/pages/manage_page.tsx
Normal 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>
|
||||
}
|
||||
|
336
frontend/src/pages/manage_storage_page.tsx
Normal file
336
frontend/src/pages/manage_storage_page.tsx
Normal 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>
|
||||
</>
|
||||
}
|
267
frontend/src/pages/manage_user_page.tsx
Normal file
267
frontend/src/pages/manage_user_page.tsx
Normal 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>
|
||||
}
|
361
frontend/src/pages/publish_page.tsx
Normal file
361
frontend/src/pages/publish_page.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
78
frontend/src/pages/register_page.tsx
Normal file
78
frontend/src/pages/register_page.tsx
Normal 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>
|
||||
}
|
151
frontend/src/pages/resource_details_page.tsx
Normal file
151
frontend/src/pages/resource_details_page.tsx
Normal 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>
|
||||
}
|
25
frontend/src/pages/search_page.tsx
Normal file
25
frontend/src/pages/search_page.tsx
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
26
frontend/tsconfig.app.json
Normal file
26
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
24
frontend/tsconfig.node.json
Normal file
24
frontend/tsconfig.node.json
Normal 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
8
frontend/vite.config.ts
Normal 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
36
go.mod
Normal 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
63
go.sum
Normal 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
38
main.go
Normal 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
254
server/api/file.go
Normal 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
86
server/api/image.go
Normal 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
139
server/api/resource.go
Normal 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, ¶ms)
|
||||
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, ¶ms)
|
||||
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
118
server/api/storage.go
Normal 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(¶ms); 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(¶ms); 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
68
server/api/tag.go
Normal 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
282
server/api/user.go
Normal 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
23
server/dao/db.go
Normal 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
137
server/dao/file.go
Normal 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
56
server/dao/image.go
Normal 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
169
server/dao/resource.go
Normal 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
22
server/dao/statistic.go
Normal 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
24
server/dao/storage.go
Normal 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
60
server/dao/tag.go
Normal 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
120
server/dao/user.go
Normal 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
|
||||
}
|
69
server/middleware/error_handler.go
Normal file
69
server/middleware/error_handler.go
Normal 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, ¬FoundErr) {
|
||||
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
|
||||
}
|
19
server/middleware/jwt_middleware.go
Normal file
19
server/middleware/jwt_middleware.go
Normal 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
79
server/model/error.go
Normal 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, ¬FoundError)
|
||||
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
33
server/model/file.go
Normal 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
26
server/model/image.go
Normal 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
89
server/model/resource.go
Normal 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
14
server/model/response.go
Normal 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"`
|
||||
}
|
6
server/model/statistic.go
Normal file
6
server/model/statistic.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package model
|
||||
|
||||
type Statistic struct {
|
||||
Key string `gorm:"primaryKey"`
|
||||
Value int64
|
||||
}
|
35
server/model/storage.go
Normal file
35
server/model/storage.go
Normal 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
21
server/model/tag.go
Normal 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,
|
||||
}
|
||||
}
|
77
server/model/uploading_file.go
Normal file
77
server/model/uploading_file.go
Normal 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
49
server/model/user.go
Normal 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
320
server/service/file.go
Normal 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
129
server/service/image.go
Normal 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
129
server/service/resource.go
Normal 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
104
server/service/storage.go
Normal 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
38
server/service/tag.go
Normal 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
263
server/service/user.go
Normal 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
19
server/service/utils.go
Normal 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
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
6
server/static/static.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package static
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed *
|
||||
var Static embed.FS
|
61
server/storage/local.go
Normal file
61
server/storage/local.go
Normal 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
52
server/storage/s3.go
Normal 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
48
server/storage/storage.go
Normal 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
62
server/utils/jwt.go
Normal 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
26
server/utils/storage.go
Normal 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
|
||||
}
|
Reference in New Issue
Block a user