Compare commits

...

49 Commits

Author SHA1 Message Date
9cd743af7f fix: stat path 2025-12-14 15:35:44 +08:00
79bae828d8 feat: add StatMiddleware to main application 2025-12-14 15:20:14 +08:00
a42087ce5c fix 2025-12-14 14:33:23 +08:00
a9d2f05562 feat: prometheus 2025-12-14 14:11:33 +08:00
31b9fb5d45 fix: unused import 2025-12-10 20:54:21 +08:00
116efcdf93 feat: cover 2025-12-10 20:50:48 +08:00
9ad8d9d7e9 feat: update home page select 2025-12-10 20:32:41 +08:00
8e2ab62297 feat: error page 2025-12-09 14:15:57 +08:00
cb61ce99bf fix: format release date to YYYY-MM-DD in edit resource page 2025-12-07 22:40:15 +08:00
2767f8a30f feat: add release date sorting options and internationalization support 2025-12-07 20:41:49 +08:00
5a5c2edfda fix: correct JSON binding in updateResourceReleaseDate function 2025-12-07 20:08:12 +08:00
d860bdf06a feat: add release date display to resource details page 2025-12-07 19:25:30 +08:00
a3de195eca Add dev update resource api. 2025-12-07 19:14:14 +08:00
6cabff8f8d feat: frontend support for release date 2025-12-07 18:54:01 +08:00
78f6130b23 feat: add release date field to resource models and parameters 2025-12-07 18:39:59 +08:00
ddd856529b fix: retrieve resource details before adding to index in createIndex 2025-12-06 17:13:35 +08:00
48638111ec fix RebuildSearchIndex 2025-12-06 17:06:15 +08:00
d255ecc503 feat: add logging for search index rebuilding progress 2025-12-06 16:59:46 +08:00
00321b01c3 fix: run createIndex in a goroutine during search index rebuild 2025-12-06 16:51:09 +08:00
59904223b4 fix: use os.RemoveAll to ensure complete removal of search index 2025-12-06 16:46:06 +08:00
b732e1be83 dev api 2025-12-06 16:39:06 +08:00
fd86d6c221 API for rebuilding search index 2025-12-06 16:32:32 +08:00
fbe8ac27bf Search charaters 2025-12-06 16:15:38 +08:00
fb1f47c0c0 dev api 2025-12-06 16:10:31 +08:00
ecfea63edd fix: enable explicit TLS for FTP connections in Upload and Delete methods 2025-12-02 21:57:50 +08:00
ae547522ed fix: update NotificationButton styling and integrate useNavigator in NotificationPage 2025-11-30 19:49:29 +08:00
96cdd2c41c fix: filter user notifications by notify_to field 2025-11-30 19:39:35 +08:00
566234c30c fix: enforce not null constraint and default value for UnreadNotificationsCount 2025-11-30 19:36:07 +08:00
4a6c214709 feat: notifications 2025-11-30 19:24:51 +08:00
4550720cbb fix: implement subdirectory structure for image storage and retrieval 2025-11-29 15:27:43 +08:00
e833783da1 fix: remove unnecessary nil check for resampled images 2025-11-29 13:11:16 +08:00
1406f76fbb fix: clarify behavior for existing query parameters in downloadFile redirect 2025-11-29 12:21:03 +08:00
6040f88034 fix: handle query parameters in downloadFile redirect 2025-11-29 12:20:53 +08:00
23269ad9d1 fix: set Cache-Control header for index.html response 2025-11-28 23:06:47 +08:00
57b0a10c4d fix: UI 2025-11-28 21:02:30 +08:00
26f5308d9a feat: Update UI 2025-11-28 19:55:35 +08:00
4f1600296c feat: add Chinese translation for "Download" in i18n data 2025-11-28 19:47:13 +08:00
1a120d2378 feat: add touch event listener for mouse move handling in GalleryFullscreen 2025-11-28 19:39:08 +08:00
a0fb279b29 feat: pass nsfw prop to GalleryFullscreen and update image nsfw check 2025-11-28 19:35:31 +08:00
1d78207004 feat: add UnsupportedRegionMiddleware to handle requests from unsupported regions 2025-11-28 19:12:31 +08:00
1544c535de fix: reduce background overlay opacity in Navigator component 2025-11-27 23:07:20 +08:00
48790ef5e0 feat: add download token handling and update verification flow in CloudflarePopup 2025-11-27 22:21:53 +08:00
dd2eab4c4b feat: enhance GalleryFullscreen with thumbnail navigation and hover effects 2025-11-27 22:04:59 +08:00
5febba690b feat: add redirection to original image if resampled image is not available 2025-11-27 21:51:17 +08:00
574e762fd1 fix: remove unnecessary padding from fullscreen gallery image 2025-11-27 21:43:20 +08:00
7d41f8f5a5 feat: add Gallery component for image display and navigation 2025-11-27 21:42:05 +08:00
2ae04c3180 feat: implement auto-scrolling carousel for pinned resources 2025-11-27 21:37:35 +08:00
940393c150 feat: implement download token generation for secure file access 2025-11-27 20:03:17 +08:00
e671083f09 feat: add FTP storage functionality with API integration 2025-11-27 19:45:38 +08:00
46 changed files with 2297 additions and 714 deletions

View File

@@ -30,3 +30,9 @@ BACKUP_SCHEDULE=0 2 * * *
# Retention policy (days) # Retention policy (days)
BACKUP_RETENTION_DAYS=30 BACKUP_RETENTION_DAYS=30
# Download Configuration
DOWNLOAD_SECRET_KEY=your_download_secret_key_here
# Access Key for Development API
DEV_ACCESS_KEY=your_dev_access_key_here

View File

@@ -19,6 +19,7 @@ import CreateCollectionPage from "./pages/create_collection_page.tsx";
import CollectionPage from "./pages/collection_page.tsx"; import CollectionPage from "./pages/collection_page.tsx";
import { i18nData } from "./i18n.ts"; import { i18nData } from "./i18n.ts";
import { i18nContext } from "./utils/i18n.ts"; import { i18nContext } from "./utils/i18n.ts";
import NotificationPage from "./pages/notification_page.tsx";
export default function App() { export default function App() {
return ( return (
@@ -49,6 +50,7 @@ export default function App() {
element={<CreateCollectionPage />} element={<CreateCollectionPage />}
/> />
<Route path={"/collection/:id"} element={<CollectionPage />} /> <Route path={"/collection/:id"} element={<CollectionPage />} />
<Route path={"/notifications"} element={<NotificationPage />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@@ -0,0 +1,431 @@
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import {
MdOutlineChevronLeft,
MdOutlineChevronRight,
MdOutlineClose,
} from "react-icons/md";
import { network } from "../network/network.ts";
import Badge from "./badge.tsx";
export default function Gallery({
images,
nsfw,
}: {
images: number[];
nsfw: number[];
}) {
const [currentIndex, setCurrentIndex] = useState(0);
const [direction, setDirection] = useState(0); // 方向1=向右,-1=向左
const [isHovered, setIsHovered] = useState(false);
const [width, setWidth] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const updateWidth = () => {
if (containerRef.current) {
setWidth(containerRef.current.clientWidth);
}
};
updateWidth();
window.addEventListener("resize", updateWidth);
return () => {
window.removeEventListener("resize", updateWidth);
};
}, []);
// 预加载下一张图片
useEffect(() => {
if (!images || images.length <= 1) return;
const nextIndex = (currentIndex + 1) % images.length;
const nextImageUrl = network.getImageUrl(images[nextIndex]);
const img = new Image();
img.src = nextImageUrl;
}, [currentIndex, images]);
if (!images || images.length === 0) {
return <></>;
}
const goToPrevious = () => {
setDirection(-1);
setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
};
const goToNext = () => {
setDirection(1);
setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
};
const goToIndex = (index: number) => {
setDirection(index > currentIndex ? 1 : -1);
setCurrentIndex(index);
};
if (nsfw == null) {
nsfw = [];
}
// 如果图片数量超过8张显示数字而不是圆点
const showDots = images.length <= 8;
return (
<>
<GalleryFullscreen
dialogRef={dialogRef}
images={images}
nsfw={nsfw}
currentIndex={currentIndex}
direction={direction}
goToPrevious={goToPrevious}
goToNext={goToNext}
setDirection={setDirection}
setCurrentIndex={setCurrentIndex}
/>
<div
className="relative w-full overflow-hidden rounded-xl bg-base-100-tr82 shadow-sm"
style={{ aspectRatio: "16/9" }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 图片区域 */}
<div
ref={containerRef}
className="w-full h-full relative"
onClick={() => {
dialogRef.current?.showModal();
}}
>
{width > 0 && (
<AnimatePresence initial={false} custom={direction} mode="sync">
<motion.div
key={currentIndex}
className="absolute inset-0 w-full h-full"
variants={{
enter: (dir: number) => ({
x: dir > 0 ? width : -width,
}),
center: {
x: 0,
transition: { duration: 0.3, ease: "linear" },
},
exit: (dir: number) => ({
x: dir > 0 ? -width : width,
transition: { duration: 0.3, ease: "linear" },
}),
}}
initial="enter"
animate="center"
exit="exit"
custom={direction}
>
<GalleryImage
src={network.getImageUrl(images[currentIndex])}
nfsw={nsfw.includes(images[currentIndex])}
/>
</motion.div>
</AnimatePresence>
)}
</div>
{/* 左右按钮 */}
{images.length > 1 && (
<>
<button
className={`absolute left-2 top-1/2 -translate-y-1/2 transition-opacity hover:cursor-pointer ${
isHovered ? "opacity-100" : "opacity-0"
}`}
onClick={goToPrevious}
>
<MdOutlineChevronLeft size={28} />
</button>
<button
className={`absolute right-2 top-1/2 -translate-y-1/2 transition-opacity hover:cursor-pointer ${
isHovered ? "opacity-100" : "opacity-0"
}`}
onClick={goToNext}
>
<MdOutlineChevronRight size={28} />
</button>
</>
)}
{/* 底部指示器 */}
{images.length > 1 && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2">
{showDots ? (
/* 圆点指示器 */
<div className="flex gap-2">
{images.map((_, index) => (
<button
key={index}
className={`w-2 h-2 rounded-full transition-all ${
index === currentIndex
? "bg-primary w-4"
: "bg-base-content/30 hover:bg-base-content/50"
}`}
onClick={() => goToIndex(index)}
aria-label={`Go to image ${index + 1}`}
/>
))}
</div>
) : (
/* 数字指示器 */
<div className="bg-base-100/20 px-2 py-1 rounded-full text-xs">
{currentIndex + 1} / {images.length}
</div>
)}
</div>
)}
</div>
</>
);
}
function GalleryFullscreen({
dialogRef,
images,
nsfw,
currentIndex,
direction,
goToPrevious,
goToNext,
setDirection,
setCurrentIndex,
}: {
dialogRef: React.RefObject<HTMLDialogElement | null>;
images: number[];
nsfw: number[];
currentIndex: number;
direction: number;
goToPrevious: () => void;
goToNext: () => void;
setDirection: (direction: number) => void;
setCurrentIndex: (index: number) => void;
}) {
const [width, setWidth] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const thumbnailContainerRef = useRef<HTMLDivElement>(null);
const hideTimeoutRef = useRef<number | null>(null);
const [isHovered, setIsHovered] = useState(true);
useEffect(() => {
const updateWidth = () => {
if (containerRef.current) {
console.log(containerRef.current.clientWidth);
setWidth(containerRef.current.clientWidth);
}
};
updateWidth();
window.addEventListener("resize", updateWidth);
return () => {
window.removeEventListener("resize", updateWidth);
};
}, []);
useEffect(() => {
const handleMouseMove = () => {
setIsHovered(true);
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
hideTimeoutRef.current = setTimeout(() => {
setIsHovered(false);
}, 2000);
};
if (dialogRef.current?.open) {
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("touchstart", handleMouseMove);
}
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("touchstart", handleMouseMove);
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
};
}, [dialogRef.current?.open, setIsHovered]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (dialogRef.current?.open) {
if (e.key === "ArrowLeft") {
e.preventDefault();
goToPrevious();
} else if (e.key === "ArrowRight") {
e.preventDefault();
goToNext();
} else if (e.key === "Escape") {
dialogRef.current?.close();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [dialogRef, goToPrevious, goToNext]);
useEffect(() => {
if (thumbnailContainerRef.current && dialogRef.current?.open) {
const thumbnail = thumbnailContainerRef.current.children[currentIndex] as HTMLElement;
if (thumbnail) {
thumbnail.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center" });
}
}
}, [currentIndex, dialogRef]);
return (
<dialog
ref={dialogRef}
onClick={() => {
dialogRef.current?.close();
}}
className="modal"
onMouseLeave={() => setIsHovered(false)}
>
<div
ref={containerRef}
className="modal-box w-full h-full max-h-screen max-w-screen p-4 bg-transparent shadow-none relative overflow-clip"
>
{width > 0 && (
<AnimatePresence initial={false} custom={direction} mode="sync">
<motion.div
key={`fullscreen-${currentIndex}`}
className="absolute inset-0 w-full h-full"
variants={{
enter: (dir: number) => ({
x: dir > 0 ? width : -width,
}),
center: {
x: 0,
transition: { duration: 0.3, ease: "linear" },
},
exit: (dir: number) => ({
x: dir > 0 ? -width : width,
transition: { duration: 0.3, ease: "linear" },
}),
}}
initial="enter"
animate="center"
exit="exit"
custom={direction}
>
<img
src={network.getImageUrl(images[currentIndex])}
alt=""
className="w-full h-full object-contain rounded-xl select-none"
/>
</motion.div>
</AnimatePresence>
)}
{/* 全屏模式下的左右切换按钮 */}
{images.length > 1 && (
<>
<button
className={`absolute left-4 top-1/2 -translate-y-1/2 cursor-pointer hover:bg-base-100/60 rounded-full p-2 transition-colors focus:border-none focus:outline-none`}
onClick={(e) => {
e.stopPropagation();
goToPrevious();
}}
>
<MdOutlineChevronLeft size={24} />
</button>
<button
className={`absolute right-4 top-1/2 -translate-y-1/2 cursor-pointer hover:bg-base-100/60 rounded-full p-2 transition-colors focus:border-none focus:outline-none`}
onClick={(e) => {
e.stopPropagation();
goToNext();
}}
>
<MdOutlineChevronRight size={24} />
</button>
{/* 图片缩略图列表 */}
<div
className={`absolute bottom-4 left-1/2 -translate-x-1/2 transition-opacity ${
isHovered ? "opacity-100" : "opacity-0"
}`}
onClick={(e) => e.stopPropagation()}
>
<div
ref={thumbnailContainerRef}
className="flex gap-2 overflow-x-auto max-w-[80vw] px-2 py-2 bg-base-100/60 rounded-xl scrollbar-thin scrollbar-thumb-base-content/30 scrollbar-track-transparent"
>
{images.map((imageId, index) => (
<button
key={index}
className={`flex-shrink-0 w-16 h-16 rounded-lg overflow-hidden transition-all ${
index === currentIndex
? "ring-2 ring-primary scale-110 "
: `${nsfw.includes(imageId) ? "blur-sm hover:blur-none" : "opacity-60 hover:opacity-100"}`
}`}
onClick={(e) => {
e.stopPropagation();
const newDirection = index > currentIndex ? 1 : -1;
setDirection(newDirection);
setCurrentIndex(index);
}}
>
<img
src={network.getResampledImageUrl(imageId)}
alt={`Thumbnail ${index + 1}`}
className={`w-full h-full object-cover select-none`}
/>
</button>
))}
</div>
</div>
{/* 关闭按钮 */}
<button
className={`absolute top-4 right-4 cursor-pointer hover:bg-base-100/60 rounded-full p-2 transition-colors`}
onClick={(e) => {
e.stopPropagation();
dialogRef.current?.close();
}}
>
<MdOutlineClose size={24} />
</button>
</>
)}
</div>
</dialog>
);
}
function GalleryImage({ src, nfsw }: { src: string; nfsw: boolean }) {
const [show, setShow] = useState(!nfsw);
return (
<div className="relative w-full h-full">
<img
src={src}
alt=""
className={`w-full h-full object-cover transition-all duration-300 ${!show ? "blur-xl" : ""}`}
/>
{!show && (
<>
<div
className="absolute inset-0 bg-base-content/20 cursor-pointer"
onClick={(event) => {
setShow(true);
event.stopPropagation();
}}
/>
<div className="absolute top-4 left-4">
<Badge className="badge-error shadow-lg">NSFW</Badge>
</div>
</>
)}
</div>
);
}

View File

@@ -2,11 +2,10 @@ import { app } from "../app.ts";
import { network } from "../network/network.ts"; import { network } from "../network/network.ts";
import { useNavigate, useOutlet } from "react-router"; import { useNavigate, useOutlet } from "react-router";
import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useContext, useEffect, useState } from "react";
import { MdArrowUpward, MdOutlinePerson, MdSearch } from "react-icons/md"; import { MdArrowUpward, MdOutlinePerson, MdSearch, MdNotifications } from "react-icons/md";
import { useTranslation } from "../utils/i18n"; import { useTranslation } from "../utils/i18n";
import UploadingSideBar from "./uploading_side_bar.tsx"; import UploadingSideBar from "./uploading_side_bar.tsx";
import { ThemeSwitcher } from "./theme_switcher.tsx"; import { ThemeSwitcher } from "./theme_switcher.tsx";
import { IoLogoGithub } from "react-icons/io";
import { useAppContext } from "./AppContext.tsx"; import { useAppContext } from "./AppContext.tsx";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
@@ -67,7 +66,7 @@ export default function Navigator() {
{/* Background overlay */} {/* Background overlay */}
{background && ( {background && (
<div <div
className="bg-base-100 opacity-60 dark:opacity-40" className="bg-base-100 opacity-20 dark:opacity-40"
style={{ style={{
position: "fixed", position: "fixed",
top: 0, top: 0,
@@ -234,16 +233,7 @@ export default function Navigator() {
<SearchBar /> <SearchBar />
<UploadingSideBar /> <UploadingSideBar />
<ThemeSwitcher /> <ThemeSwitcher />
<a {app.isLoggedIn() && <NotificationButton />}
className={"hidden sm:inline"}
href="https://github.com/wgh136/nysoure"
target="_blank"
rel="noopener noreferrer"
>
<button className={"btn btn-circle btn-ghost"}>
<IoLogoGithub size={24} />
</button>
</a>
{app.isLoggedIn() ? ( {app.isLoggedIn() ? (
<UserButton /> <UserButton />
) : ( ) : (
@@ -554,3 +544,41 @@ function FloatingToTopButton() {
</button> </button>
); );
} }
function NotificationButton() {
const [count, setCount] = useState(0);
const navigate = useNavigate();
useEffect(() => {
const fetchCount = async () => {
if (!app.isLoggedIn()) {
return;
}
const res = await network.getUserNotificationsCount();
if (res.success && res.data !== undefined) {
setCount(res.data);
}
};
fetchCount();
const interval = setInterval(fetchCount, 60000); // 每分钟请求一次
return () => clearInterval(interval);
}, []);
return (
<div className="indicator">
{count > 0 && <span className="bg-error text-white text-xs rounded-full px-1 indicator-item">
{count > 99 ? "99+" : count}
</span>}
<button
className="btn btn-ghost btn-circle"
onClick={() => {
navigate("/notifications");
}}
>
<MdNotifications size={24} />
</button>
</div>
);
}

View File

@@ -156,8 +156,8 @@ export const i18nData = {
"Cloudflare Turnstile Secret Key": "Cloudflare Turnstile 密钥", "Cloudflare Turnstile Secret Key": "Cloudflare Turnstile 密钥",
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.": "If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.":
"如果设置了 Cloudflare Turnstile 密钥,将在注册和下载时启用验证", "如果设置了 Cloudflare Turnstile 密钥,将在注册和下载时启用验证",
"The first image will be used as the cover image": "You can select a cover image using the radio button in the Cover column":
"第一张图片将用作封面图片", "您可以使用封面列中的单选按钮选择封面图片",
"Please enter a search keyword": "请输入搜索关键词", "Please enter a search keyword": "请输入搜索关键词",
"Searching...": "搜索中...", "Searching...": "搜索中...",
"Create Tag": "创建标签", "Create Tag": "创建标签",
@@ -179,6 +179,8 @@ export const i18nData = {
"Views Descending": "浏览量降序", "Views Descending": "浏览量降序",
"Downloads Ascending": "下载量升序", "Downloads Ascending": "下载量升序",
"Downloads Descending": "下载量降序", "Downloads Descending": "下载量降序",
"Release Date Ascending": "发布日期升序",
"Release Date Descending": "发布日期降序",
"File Url": "文件链接", "File Url": "文件链接",
"Provide a file url for the server to download, and the file will be moved to the selected storage.": "Provide a file url for the server to download, and the file will be moved to the selected storage.":
"提供一个文件链接供服务器下载,文件将被移动到选定的存储中。", "提供一个文件链接供服务器下载,文件将被移动到选定的存储中。",
@@ -261,6 +263,9 @@ export const i18nData = {
"File Size": "文件大小", "File Size": "文件大小",
"Tag": "标签", "Tag": "标签",
"Optional": "可选", "Optional": "可选",
"Download": "下载",
"Notifications": "通知",
"Release Date": "发售日期",
}, },
}, },
"zh-TW": { "zh-TW": {
@@ -420,8 +425,8 @@ export const i18nData = {
"Cloudflare Turnstile Secret Key": "Cloudflare Turnstile 密鑰", "Cloudflare Turnstile Secret Key": "Cloudflare Turnstile 密鑰",
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.": "If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.":
"如果設置了 Cloudflare Turnstile 密鑰,將在註冊和下載時啟用驗證", "如果設置了 Cloudflare Turnstile 密鑰,將在註冊和下載時啟用驗證",
"The first image will be used as the cover image": "You can select a cover image using the radio button in the Cover column":
"第一張圖片將用作封面圖片", "您可以使用封面列中的單選按鈕選擇封面圖片",
"Please enter a search keyword": "請輸入搜尋關鍵字", "Please enter a search keyword": "請輸入搜尋關鍵字",
"Searching...": "搜尋中...", "Searching...": "搜尋中...",
"Create Tag": "創建標籤", "Create Tag": "創建標籤",
@@ -443,6 +448,8 @@ export const i18nData = {
"Views Descending": "瀏覽量降序", "Views Descending": "瀏覽量降序",
"Downloads Ascending": "下載量升序", "Downloads Ascending": "下載量升序",
"Downloads Descending": "下載量降序", "Downloads Descending": "下載量降序",
"Release Date Ascending": "發布日期升序",
"Release Date Descending": "發布日期降序",
"File Url": "檔案連結", "File Url": "檔案連結",
"Provide a file url for the server to download, and the file will be moved to the selected storage.": "Provide a file url for the server to download, and the file will be moved to the selected storage.":
"提供一個檔案連結供伺服器下載,檔案將被移動到選定的儲存中。", "提供一個檔案連結供伺服器下載,檔案將被移動到選定的儲存中。",
@@ -525,6 +532,9 @@ export const i18nData = {
"File Size": "檔案大小", "File Size": "檔案大小",
"Tag": "標籤", "Tag": "標籤",
"Optional": "可選", "Optional": "可選",
"Download": "下載",
"Notifications": "通知",
"Release Date": "發售日期",
}, },
}, },
}; };

View File

@@ -44,9 +44,11 @@ export interface CreateResourceParams {
title: string; title: string;
alternative_titles: string[]; alternative_titles: string[];
links: RLink[]; links: RLink[];
release_date?: string;
tags: number[]; tags: number[];
article: string; article: string;
images: number[]; images: number[];
cover_id?: number;
gallery: number[]; gallery: number[];
gallery_nsfw: number[]; gallery_nsfw: number[];
characters: CharacterParams[]; characters: CharacterParams[];
@@ -77,6 +79,7 @@ export interface Resource {
id: number; id: number;
title: string; title: string;
created_at: string; created_at: string;
release_date?: string;
tags: Tag[]; tags: Tag[];
image?: Image; image?: Image;
author: User; author: User;
@@ -89,8 +92,10 @@ export interface ResourceDetails {
links: RLink[]; links: RLink[];
article: string; article: string;
createdAt: string; createdAt: string;
releaseDate?: string;
tags: Tag[]; tags: Tag[];
images: Image[]; images: Image[];
coverId?: number;
files: RFile[]; files: RFile[];
author: User; author: User;
views: number; views: number;
@@ -194,6 +199,8 @@ export enum RSort {
ViewsDesc = 3, ViewsDesc = 3,
DownloadsAsc = 4, DownloadsAsc = 4,
DownloadsDesc = 5, DownloadsDesc = 5,
ReleaseDateAsc = 6,
ReleaseDateDesc = 7,
} }
export enum ActivityType { export enum ActivityType {

View File

@@ -457,6 +457,28 @@ class Network {
); );
} }
async createFTPStorage(
name: string,
host: string,
username: string,
password: string,
basePath: string,
domain: string,
maxSizeInMB: number,
): Promise<Response<any>> {
return this._callApi(() =>
axios.post(`${this.apiBaseUrl}/storage/ftp`, {
name,
host,
username,
password,
basePath,
domain,
maxSizeInMB,
}),
);
}
async listStorages(): Promise<Response<Storage[]>> { async listStorages(): Promise<Response<Storage[]>> {
return this._callApi(() => axios.get(`${this.apiBaseUrl}/storage`)); return this._callApi(() => axios.get(`${this.apiBaseUrl}/storage`));
} }
@@ -708,6 +730,26 @@ class Network {
); );
} }
async getUserNotifications(page: number = 1): Promise<PageResponse<Activity>> {
return this._callApi(() =>
axios.get(`${this.apiBaseUrl}/notification`, {
params: { page },
}),
);
}
async resetUserNotificationsCount(): Promise<Response<void>> {
return this._callApi(() =>
axios.post(`${this.apiBaseUrl}/notification/reset`),
);
}
async getUserNotificationsCount(): Promise<Response<number>> {
return this._callApi(() =>
axios.get(`${this.apiBaseUrl}/notification/count`),
);
}
async createCollection( async createCollection(
title: string, title: string,
article: string, article: string,

View File

@@ -25,9 +25,11 @@ import CharacterEditer, { FetchVndbCharactersButton } from "../components/charac
export default function EditResourcePage() { export default function EditResourcePage() {
const [title, setTitle] = useState<string>(""); const [title, setTitle] = useState<string>("");
const [altTitles, setAltTitles] = useState<string[]>([]); const [altTitles, setAltTitles] = useState<string[]>([]);
const [releaseDate, setReleaseDate] = useState<string | undefined>(undefined);
const [tags, setTags] = useState<Tag[]>([]); const [tags, setTags] = useState<Tag[]>([]);
const [article, setArticle] = useState<string>(""); const [article, setArticle] = useState<string>("");
const [images, setImages] = useState<number[]>([]); const [images, setImages] = useState<number[]>([]);
const [coverId, setCoverId] = useState<number | undefined>(undefined);
const [links, setLinks] = useState<{ label: string; url: string }[]>([]); const [links, setLinks] = useState<{ label: string; url: string }[]>([]);
const [galleryImages, setGalleryImages] = useState<number[]>([]); const [galleryImages, setGalleryImages] = useState<number[]>([]);
const [galleryNsfw, setGalleryNsfw] = useState<number[]>([]); const [galleryNsfw, setGalleryNsfw] = useState<number[]>([]);
@@ -58,9 +60,11 @@ export default function EditResourcePage() {
setTags(data.tags); setTags(data.tags);
setArticle(data.article); setArticle(data.article);
setImages(data.images.map((i) => i.id)); setImages(data.images.map((i) => i.id));
setCoverId(data.coverId);
setLinks(data.links ?? []); setLinks(data.links ?? []);
setGalleryImages(data.gallery ?? []); setGalleryImages(data.gallery ?? []);
setGalleryNsfw(data.galleryNsfw ?? []); setGalleryNsfw(data.galleryNsfw ?? []);
setReleaseDate(data.releaseDate?.split("T")[0] ?? undefined);
setCharacters(data.characters ?? []); setCharacters(data.characters ?? []);
setLoading(false); setLoading(false);
} else { } else {
@@ -104,10 +108,12 @@ export default function EditResourcePage() {
tags: tags.map((tag) => tag.id), tags: tags.map((tag) => tag.id),
article: article, article: article,
images: images, images: images,
cover_id: coverId,
links: links, links: links,
gallery: galleryImages, gallery: galleryImages,
gallery_nsfw: galleryNsfw, gallery_nsfw: galleryNsfw,
characters: characters, characters: characters,
release_date: releaseDate,
}); });
if (res.success) { if (res.success) {
setSubmitting(false); setSubmitting(false);
@@ -194,6 +200,14 @@ export default function EditResourcePage() {
{t("Add Alternative Title")} {t("Add Alternative Title")}
</button> </button>
<div className={"h-2"}></div> <div className={"h-2"}></div>
<p className={"my-1"}>{t("Release Date")}</p>
<input
type="date"
className="input"
value={releaseDate || ""}
onChange={(e) => setReleaseDate(e.target.value || undefined)}
/>
<div className={"h-4"}></div>
<p className={"my-1"}>{t("Links")}</p> <p className={"my-1"}>{t("Links")}</p>
<div className={"flex flex-col"}> <div className={"flex flex-col"}>
{links.map((link, index) => { {links.map((link, index) => {
@@ -317,7 +331,7 @@ export default function EditResourcePage() {
"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",
)} )}
</p> </p>
<p>{t("The first image will be used as the cover image")}</p> <p>{t("You can select a cover image using the radio button in the Cover column")}</p>
</div> </div>
</div> </div>
<div <div
@@ -328,6 +342,7 @@ export default function EditResourcePage() {
<tr> <tr>
<td>{t("Preview")}</td> <td>{t("Preview")}</td>
<td>{"Markdown"}</td> <td>{"Markdown"}</td>
<td>{t("Cover")}</td>
<td>{t("Gallery")}</td> <td>{t("Gallery")}</td>
<td>{"Nsfw"}</td> <td>{"Nsfw"}</td>
<td>{t("Action")}</td> <td>{t("Action")}</td>
@@ -357,6 +372,15 @@ export default function EditResourcePage() {
<MdContentCopy /> <MdContentCopy />
</button> </button>
</td> </td>
<td>
<input
type="radio"
name="cover"
className="radio radio-accent"
checked={coverId === image}
onChange={() => setCoverId(image)}
/>
</td>
<td> <td>
<input <input
type="checkbox" type="checkbox"
@@ -398,6 +422,9 @@ export default function EditResourcePage() {
const newImages = [...images]; const newImages = [...images];
newImages.splice(index, 1); newImages.splice(index, 1);
setImages(newImages); setImages(newImages);
if (coverId === id) {
setCoverId(undefined);
}
network.deleteImage(id); network.deleteImage(id);
}} }}
> >

View File

@@ -5,7 +5,6 @@ import { app } from "../app.ts";
import { Resource, RSort, Statistics } from "../network/models.ts"; import { Resource, RSort, Statistics } from "../network/models.ts";
import { useTranslation } from "../utils/i18n"; import { useTranslation } from "../utils/i18n";
import { useAppContext } from "../components/AppContext.tsx"; import { useAppContext } from "../components/AppContext.tsx";
import Select from "../components/select.tsx";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useNavigator } from "../components/navigator.tsx"; import { useNavigator } from "../components/navigator.tsx";
import { import {
@@ -40,23 +39,32 @@ export default function HomePage() {
<> <>
<HomeHeader /> <HomeHeader />
<div className={"flex pt-4 px-4 items-center"}> <div className={"flex pt-4 px-4 items-center"}>
<Select <select
values={[ value={order}
className="select select-primary max-w-72"
onChange={(e) => {
const order = parseInt(e.target.value);
setOrder(order);
if (appContext) {
appContext.set("home_page_order", order);
}
}}
>
{[
t("Time Ascending"), t("Time Ascending"),
t("Time Descending"), t("Time Descending"),
t("Views Ascending"), t("Views Ascending"),
t("Views Descending"), t("Views Descending"),
t("Downloads Ascending"), t("Downloads Ascending"),
t("Downloads Descending"), t("Downloads Descending"),
]} t("Release Date Ascending"),
current={order} t("Release Date Descending"),
onSelected={(index) => { ].map((label, idx) => (
setOrder(index); <option key={idx} value={idx}>
if (appContext) { {label}
appContext.set("home_page_order", index); </option>
} ))}
}} </select>
/>
</div> </div>
<ResourcesView <ResourcesView
key={`home_page_${order}`} key={`home_page_${order}`}
@@ -70,6 +78,7 @@ export default function HomePage() {
function HomeHeader() { function HomeHeader() {
const [pinnedResources, setPinnedResources] = useState<Resource[]>([]); const [pinnedResources, setPinnedResources] = useState<Resource[]>([]);
const [statistic, setStatistic] = useState<Statistics | null>(null); const [statistic, setStatistic] = useState<Statistics | null>(null);
const [currentIndex, setCurrentIndex] = useState(0);
const navigator = useNavigator(); const navigator = useNavigator();
const appContext = useAppContext(); const appContext = useAppContext();
@@ -127,13 +136,30 @@ function HomeHeader() {
fetchStatistics(); fetchStatistics();
}, [appContext, navigator]); }, [appContext, navigator]);
// Auto-scroll carousel every 5 seconds
useEffect(() => {
if (pinnedResources.length <= 1) {
return;
}
const interval = setInterval(() => {
setCurrentIndex((prevIndex) => (prevIndex + 1) % pinnedResources.length);
}, 5000);
return () => clearInterval(interval);
}, [pinnedResources.length, currentIndex]);
if (pinnedResources.length == 0 || statistic == null) { if (pinnedResources.length == 0 || statistic == null) {
return <></>; return <></>;
} }
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 p-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 p-4 gap-4">
<PinnedResourceItem resource={pinnedResources[0]} /> <PinnedResourcesCarousel
resources={pinnedResources}
currentIndex={currentIndex}
onIndexChange={setCurrentIndex}
/>
<div className={"hidden md:flex h-52 md:h-60 flex-col"}> <div className={"hidden md:flex h-52 md:h-60 flex-col"}>
<div className={"card w-full shadow p-4 mb-4 bg-base-100-tr82 flex-1"}> <div className={"card w-full shadow p-4 mb-4 bg-base-100-tr82 flex-1"}>
<h2 className={"text-lg font-bold pb-2"}>{app.appName}</h2> <h2 className={"text-lg font-bold pb-2"}>{app.appName}</h2>
@@ -145,6 +171,49 @@ function HomeHeader() {
); );
} }
function PinnedResourcesCarousel({
resources,
currentIndex,
onIndexChange,
}: {
resources: Resource[];
currentIndex: number;
onIndexChange: (index: number) => void;
}) {
return (
<div className="relative">
<div className="overflow-hidden rounded-2xl">
<div
className="flex transition-transform duration-500 ease-in-out"
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
>
{resources.map((resource) => (
<div key={resource.id} className="w-full flex-shrink-0">
<PinnedResourceItem resource={resource} />
</div>
))}
</div>
</div>
{resources.length > 1 && (
<div className="absolute bottom-2 left-1/2 transform -translate-x-1/2 flex gap-2 z-10">
{resources.map((_, index) => (
<button
key={index}
onClick={() => onIndexChange(index)}
className={`w-2 h-2 rounded-full transition-all ${
index === currentIndex
? "bg-white w-6"
: "bg-white/50 hover:bg-white/75"
}`}
aria-label={`Go to slide ${index + 1}`}
/>
))}
</div>
)}
</div>
);
}
function PinnedResourceItem({ resource }: { resource: Resource }) { function PinnedResourceItem({ resource }: { resource: Resource }) {
const navigate = useNavigate(); const navigate = useNavigate();

View File

@@ -244,6 +244,7 @@ export default function StorageView() {
enum StorageType { enum StorageType {
local, local,
s3, s3,
ftp,
} }
function NewStorageDialog({ onAdded }: { onAdded: () => void }) { function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
@@ -259,6 +260,10 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
bucketName: "", bucketName: "",
maxSizeInMB: 0, maxSizeInMB: 0,
domain: "", domain: "",
host: "",
username: "",
password: "",
basePath: "",
}); });
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@@ -305,6 +310,28 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
params.maxSizeInMB, params.maxSizeInMB,
params.domain, params.domain,
); );
} else if (storageType === StorageType.ftp) {
if (
params.host === "" ||
params.username === "" ||
params.password === "" ||
params.domain === "" ||
params.name === "" ||
params.maxSizeInMB <= 0
) {
setError(t("All fields are required"));
setIsLoading(false);
return;
}
response = await network.createFTPStorage(
params.name,
params.host,
params.username,
params.password,
params.basePath,
params.domain,
params.maxSizeInMB,
);
} }
if (response!.success) { if (response!.success) {
@@ -368,6 +395,15 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
setStorageType(StorageType.s3); setStorageType(StorageType.s3);
}} }}
/> />
<input
className="btn"
type="radio"
name="type"
aria-label={t("FTP")}
onInput={() => {
setStorageType(StorageType.ftp);
}}
/>
</form> </form>
{storageType === StorageType.local && ( {storageType === StorageType.local && (
@@ -525,6 +561,114 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
</> </>
)} )}
{storageType === StorageType.ftp && (
<>
<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("Host")}
<input
type="text"
placeholder="ftp.example.com:21"
className="w-full"
value={params.host}
onChange={(e) => {
setParams({
...params,
host: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Username")}
<input
type="text"
className="w-full"
value={params.username}
onChange={(e) => {
setParams({
...params,
username: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Password")}
<input
type="password"
className="w-full"
value={params.password}
onChange={(e) => {
setParams({
...params,
password: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Base Path")}
<input
type="text"
placeholder="/uploads"
className="w-full"
value={params.basePath}
onChange={(e) => {
setParams({
...params,
basePath: e.target.value,
});
}}
/>
</label>
<label className="input w-full my-2">
{t("Domain")}
<input
type="text"
placeholder="files.example.com"
className="w-full"
value={params.domain}
onChange={(e) => {
setParams({
...params,
domain: 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"} />} {error !== "" && <ErrorAlert message={error} className={"my-2"} />}
<div className="modal-action"> <div className="modal-action">

View File

@@ -0,0 +1,212 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Activity, ActivityType } from "../network/models.ts";
import { network } from "../network/network.ts";
import showToast from "../components/toast.ts";
import { useTranslation } from "../utils/i18n";
import { useNavigate } from "react-router";
import Loading from "../components/loading.tsx";
import { CommentContent } from "../components/comment_tile.tsx";
import { MdOutlineArchive, MdOutlinePhotoAlbum } from "react-icons/md";
import Badge from "../components/badge.tsx";
import Markdown from "react-markdown";
import { ErrorAlert } from "../components/alert.tsx";
import { app } from "../app.ts";
import { useNavigator } from "../components/navigator.tsx";
export default function NotificationPage() {
const [activities, setActivities] = useState<Activity[]>([]);
const pageRef = useRef(0);
const maxPageRef = useRef(1);
const isLoadingRef = useRef(false);
const { t } = useTranslation();
const navigator = useNavigator();
const fetchNextPage = useCallback(async () => {
if (isLoadingRef.current || pageRef.current >= maxPageRef.current) return;
isLoadingRef.current = true;
const response = await network.getUserNotifications(pageRef.current + 1);
if (response.success) {
setActivities((prev) => [...prev, ...response.data!]);
pageRef.current += 1;
maxPageRef.current = response.totalPages!;
} else {
showToast({
type: "error",
message: response.message || "Failed to load activities",
});
}
isLoadingRef.current = false;
}, []);
useEffect(() => {
fetchNextPage();
}, [fetchNextPage]);
useEffect(() => {
network.resetUserNotificationsCount();
navigator.refresh();
}, [navigator]);
useEffect(() => {
document.title = t("Notifications");
}, [])
useEffect(() => {
const handleScroll = () => {
if (
window.innerHeight + window.scrollY >=
document.documentElement.scrollHeight - 100 &&
!isLoadingRef.current &&
pageRef.current < maxPageRef.current
) {
fetchNextPage();
}
};
window.addEventListener("scroll", handleScroll);
return () => window.removeEventListener("scroll", handleScroll);
}, [fetchNextPage]);
if (!app.user) {
return (
<ErrorAlert
className={"m-4"}
message={t("You are not logged in. Please log in to access this page.")}
/>
);
}
return (
<div className={"pb-2"}>
{activities.map((activity) => (
<ActivityCard key={activity.id} activity={activity} />
))}
{pageRef.current < maxPageRef.current && <Loading />}
</div>
);
}
function fileSizeToString(size: number) {
if (size < 1024) {
return size + "B";
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + "KB";
} else if (size < 1024 * 1024 * 1024) {
return (size / 1024 / 1024).toFixed(2) + "MB";
} else {
return (size / 1024 / 1024 / 1024).toFixed(2) + "GB";
}
}
function ActivityCard({ activity }: { activity: Activity }) {
const { t } = useTranslation();
const messages = [
"Unknown activity",
t("Published a resource"),
t("Updated a resource"),
t("Posted a comment"),
t("Added a new file"),
];
const navigate = useNavigate();
let content = <></>;
if (
activity.type === ActivityType.ResourcePublished ||
activity.type === ActivityType.ResourceUpdated
) {
content = (
<div className={"mx-1"}>
<div className={"font-bold my-4 break-all"}>
{activity.resource?.title}
</div>
{activity.resource?.image && (
<div>
<img
className={"object-contain max-h-52 mt-2 rounded-lg"}
src={network.getResampledImageUrl(activity.resource.image.id)}
alt={activity.resource.title}
/>
</div>
)}
</div>
);
} else if (activity.type === ActivityType.NewComment) {
content = (
<div className="comment_tile">
<CommentContent content={activity.comment!.content} />
</div>
);
} else if (activity.type === ActivityType.NewFile) {
content = (
<div>
<h4 className={"font-bold py-2 break-all"}>
{activity.file!.filename}
</h4>
<div className={"text-sm my-1 comment_tile"}>
<Markdown>
{activity.file!.description.replaceAll("\n", " \n")}
</Markdown>
</div>
<p className={"pt-1"}>
<Badge className={"badge-soft badge-secondary text-xs mr-2"}>
<MdOutlineArchive size={16} className={"inline-block"} />
{activity.file!.is_redirect
? t("Redirect")
: fileSizeToString(activity.file!.size)}
</Badge>
<Badge className={"badge-soft badge-accent text-xs mr-2"}>
<MdOutlinePhotoAlbum size={16} className={"inline-block"} />
{(() => {
let title = activity.resource!.title;
if (title.length > 20) {
title = title.slice(0, 20) + "...";
}
return title;
})()}
</Badge>
</p>
</div>
);
}
return (
<div
className={
"card shadow m-4 p-4 hover:shadow-md transition-shadow cursor-pointer bg-base-100-tr82"
}
onClick={() => {
if (
activity.type === ActivityType.ResourcePublished ||
activity.type === ActivityType.ResourceUpdated
) {
navigate(`/resources/${activity.resource?.id}`);
} else if (activity.type === ActivityType.NewComment) {
navigate(`/comments/${activity.comment?.id}`);
} else if (activity.type === ActivityType.NewFile) {
navigate(`/resources/${activity.resource?.id}#files`);
}
}}
>
<div className={"flex items-center"}>
<div className={"avatar w-9 h-9 rounded-full"}>
<img
className={"rounded-full"}
alt={"avatar"}
src={network.getUserAvatar(activity.user!)}
/>
</div>
<span className={"mx-2 font-bold text-sm"}>
{activity.user?.username}
</span>
<span
className={"ml-2 badge-sm sm:badge-md badge badge-primary badge-soft"}
>
{messages[activity.type]}
</span>
</div>
{content}
</div>
);
}

View File

@@ -24,9 +24,11 @@ import CharacterEditer, { FetchVndbCharactersButton } from "../components/charac
export default function PublishPage() { export default function PublishPage() {
const [title, setTitle] = useState<string>(""); const [title, setTitle] = useState<string>("");
const [altTitles, setAltTitles] = useState<string[]>([]); const [altTitles, setAltTitles] = useState<string[]>([]);
const [releaseDate, setReleaseDate] = useState<string | undefined>(undefined);
const [tags, setTags] = useState<Tag[]>([]); const [tags, setTags] = useState<Tag[]>([]);
const [article, setArticle] = useState<string>(""); const [article, setArticle] = useState<string>("");
const [images, setImages] = useState<number[]>([]); const [images, setImages] = useState<number[]>([]);
const [coverId, setCoverId] = useState<number | undefined>(undefined);
const [links, setLinks] = useState<{ label: string; url: string }[]>([]); const [links, setLinks] = useState<{ label: string; url: string }[]>([]);
const [galleryImages, setGalleryImages] = useState<number[]>([]); const [galleryImages, setGalleryImages] = useState<number[]>([]);
const [galleryNsfw, setGalleryNsfw] = useState<number[]>([]); const [galleryNsfw, setGalleryNsfw] = useState<number[]>([]);
@@ -43,9 +45,15 @@ export default function PublishPage() {
const data = JSON.parse(oldData); const data = JSON.parse(oldData);
setTitle(data.title || ""); setTitle(data.title || "");
setAltTitles(data.alternative_titles || []); setAltTitles(data.alternative_titles || []);
setReleaseDate(data.release_date || undefined);
setTags(data.tags || []); setTags(data.tags || []);
setArticle(data.article || ""); setArticle(data.article || "");
setImages(data.images || []); setImages(data.images || []);
setCoverId(data.cover_id || undefined);
setLinks(data.links || []);
setGalleryImages(data.gallery || []);
setGalleryNsfw(data.gallery_nsfw || []);
setCharacters(data.characters || []);
} catch (e) { } catch (e) {
console.error("Failed to parse publish_data from localStorage", e); console.error("Failed to parse publish_data from localStorage", e);
} }
@@ -58,11 +66,17 @@ export default function PublishPage() {
tags: tags, tags: tags,
article: article, article: article,
images: images, images: images,
cover_id: coverId,
links: links,
gallery: galleryImages,
gallery_nsfw: galleryNsfw,
characters: characters,
release_date: releaseDate,
}; };
const dataString = JSON.stringify(data); const dataString = JSON.stringify(data);
localStorage.setItem("publish_data", dataString); localStorage.setItem("publish_data", dataString);
} }
}, [altTitles, article, images, tags, title]); }, [altTitles, article, images, coverId, tags, title, links, galleryImages, galleryNsfw, characters, releaseDate]);
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -105,9 +119,11 @@ export default function PublishPage() {
const res = await network.createResource({ const res = await network.createResource({
title: title, title: title,
alternative_titles: altTitles, alternative_titles: altTitles,
release_date: releaseDate,
tags: tags.map((tag) => tag.id), tags: tags.map((tag) => tag.id),
article: article, article: article,
images: images, images: images,
cover_id: coverId,
links: links, links: links,
gallery: galleryImages, gallery: galleryImages,
gallery_nsfw: galleryNsfw, gallery_nsfw: galleryNsfw,
@@ -201,6 +217,14 @@ export default function PublishPage() {
{t("Add Alternative Title")} {t("Add Alternative Title")}
</button> </button>
<div className={"h-2"}></div> <div className={"h-2"}></div>
<p className={"my-1"}>{t("Release Date")}</p>
<input
type="date"
className="input"
value={releaseDate || ""}
onChange={(e) => setReleaseDate(e.target.value || undefined)}
/>
<div className={"h-4"}></div>
<p className={"my-1"}>{t("Links")}</p> <p className={"my-1"}>{t("Links")}</p>
<div className={"flex flex-col"}> <div className={"flex flex-col"}>
{links.map((link, index) => { {links.map((link, index) => {
@@ -324,7 +348,7 @@ export default function PublishPage() {
"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",
)} )}
</p> </p>
<p>{t("The first image will be used as the cover image")}</p> <p>{t("You can select a cover image using the radio button in the Cover column")}</p>
</div> </div>
</div> </div>
<div <div
@@ -335,7 +359,8 @@ export default function PublishPage() {
<tr> <tr>
<td>{t("Preview")}</td> <td>{t("Preview")}</td>
<td>{"Markdown"}</td> <td>{"Markdown"}</td>
<td>{"Gallery"}</td> <td>{t("Cover")}</td>
<td>{t("Gallery")}</td>
<td>{"Nsfw"}</td> <td>{"Nsfw"}</td>
<td>{t("Action")}</td> <td>{t("Action")}</td>
</tr> </tr>
@@ -364,6 +389,15 @@ export default function PublishPage() {
<MdContentCopy /> <MdContentCopy />
</button> </button>
</td> </td>
<td>
<input
type="radio"
name="cover"
className="radio radio-accent"
checked={coverId === image}
onChange={() => setCoverId(image)}
/>
</td>
<td> <td>
<input <input
type="checkbox" type="checkbox"
@@ -405,6 +439,9 @@ export default function PublishPage() {
const newImages = [...images]; const newImages = [...images];
newImages.splice(index, 1); newImages.splice(index, 1);
setImages(newImages); setImages(newImages);
if (coverId === id) {
setCoverId(undefined);
}
network.deleteImage(id); network.deleteImage(id);
}} }}
> >

View File

@@ -32,9 +32,6 @@ import {
MdOutlineAdd, MdOutlineAdd,
MdOutlineArchive, MdOutlineArchive,
MdOutlineArticle, MdOutlineArticle,
MdOutlineChevronLeft,
MdOutlineChevronRight,
MdOutlineClose,
MdOutlineCloud, MdOutlineCloud,
MdOutlineComment, MdOutlineComment,
MdOutlineContentCopy, MdOutlineContentCopy,
@@ -71,7 +68,7 @@ import KunApi, {
} from "../network/kun.ts"; } from "../network/kun.ts";
import { Debounce } from "../utils/debounce.ts"; import { Debounce } from "../utils/debounce.ts";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { AnimatePresence, motion } from "framer-motion"; import Gallery from "../components/gallery.tsx";
export default function ResourcePage() { export default function ResourcePage() {
const params = useParams(); const params = useParams();
@@ -212,6 +209,14 @@ export default function ResourcePage() {
</h2> </h2>
); );
})} })}
{
resource.releaseDate ? (
<div className={"px-4 py-1 text-sm text-gray-600 dark:text-gray-400 flex items-center"}>
<MdOutlineAccessTime size={18} className={"inline-block mr-1"} />
{t("Release Date")}: {resource.releaseDate.split("T")[0]}
</div>
) : null
}
<button <button
onClick={() => { onClick={() => {
navigate( navigate(
@@ -468,141 +473,141 @@ function DeleteResourceDialog({
); );
} }
const context = createContext<() => void>(() => {}); const context = createContext<() => void>(() => { });
function Article({ resource }: { resource: ResourceDetails }) { function Article({ resource }: { resource: ResourceDetails }) {
return ( return (
<> <>
<article> <article>
<Markdown <Markdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{
p: ({ node, ...props }) => { p: ({ node, ...props }) => {
if ( if (
typeof props.children === "object" && typeof props.children === "object" &&
(props.children as ReactElement).type === "strong" (props.children as ReactElement).type === "strong"
) { ) {
// @ts-ignore
const child = (
props.children as ReactElement
).props.children.toString() as string;
if (child.startsWith("<iframe")) {
// @ts-ignore // @ts-ignore
let html = child; const child = (
let splits = html.split(" "); props.children as ReactElement
splits = splits.filter((s: string) => { ).props.children.toString() as string;
return !( if (child.startsWith("<iframe")) {
s.startsWith("width") ||
s.startsWith("height") ||
s.startsWith("class") ||
s.startsWith("style")
);
});
html = splits.join(" ");
return (
<div
className={`w-full my-3 max-w-xl rounded-xl overflow-clip ${html.includes("youtube") ? "aspect-video" : "h-48 sm:h-64"}`}
dangerouslySetInnerHTML={{
__html: html,
}}
></div>
);
}
} else if (
typeof props.children === "object" &&
// @ts-ignore
props.children?.props &&
// @ts-ignore
props.children?.props.href
) {
const a = props.children as ReactElement;
const childProps = a.props as any;
const href = childProps.href as string;
// @ts-ignore
if (childProps.children?.length === 2) {
// @ts-ignore
const first = childProps.children[0] as ReactNode;
// @ts-ignore
const second = childProps.children[1] as ReactNode;
if (
typeof first === "object" &&
(typeof second === "string" || typeof second === "object")
) {
const img = first as ReactElement;
// @ts-ignore // @ts-ignore
if (img.type === "img") { let html = child;
return ( let splits = html.split(" ");
<a splits = splits.filter((s: string) => {
className={ return !(
"inline-block card shadow bg-base-100 no-underline hover:shadow-md transition-shadow my-2" s.startsWith("width") ||
} s.startsWith("height") ||
target={"_blank"} s.startsWith("class") ||
href={href} s.startsWith("style")
> );
<figure className={"max-h-96 min-w-48 min-h-24"}> });
{img} html = splits.join(" ");
</figure> return (
<div className={"card-body text-base-content text-lg"}> <div
<div className={"flex items-center"}> className={`w-full my-3 max-w-xl rounded-xl overflow-clip ${html.includes("youtube") ? "aspect-video" : "h-48 sm:h-64"}`}
<span className={"flex-1"}>{second}</span> dangerouslySetInnerHTML={{
<span> __html: html,
<MdOutlineOpenInNew size={24} /> }}
</span> ></div>
);
}
} else if (
typeof props.children === "object" &&
// @ts-ignore
props.children?.props &&
// @ts-ignore
props.children?.props.href
) {
const a = props.children as ReactElement;
const childProps = a.props as any;
const href = childProps.href as string;
// @ts-ignore
if (childProps.children?.length === 2) {
// @ts-ignore
const first = childProps.children[0] as ReactNode;
// @ts-ignore
const second = childProps.children[1] as ReactNode;
if (
typeof first === "object" &&
(typeof second === "string" || typeof second === "object")
) {
const img = first as ReactElement;
// @ts-ignore
if (img.type === "img") {
return (
<a
className={
"inline-block card shadow bg-base-100 no-underline hover:shadow-md transition-shadow my-2"
}
target={"_blank"}
href={href}
>
<figure className={"max-h-96 min-w-48 min-h-24"}>
{img}
</figure>
<div className={"card-body text-base-content text-lg"}>
<div className={"flex items-center"}>
<span className={"flex-1"}>{second}</span>
<span>
<MdOutlineOpenInNew size={24} />
</span>
</div>
</div> </div>
</div> </a>
</a> );
}
}
}
if (href.startsWith("https://store.steampowered.com/app/")) {
const appId = href
.substring("https://store.steampowered.com/app/".length)
.split("/")[0];
if (!Number.isNaN(Number(appId))) {
return (
<div className={"max-w-xl h-52 sm:h-48 my-2"}>
<iframe
className={"scheme-light"}
src={`https://store.steampowered.com/widget/${appId}/`}
></iframe>
</div>
); );
} }
} }
} }
if (href.startsWith("https://store.steampowered.com/app/")) { return <p {...props}>{props.children}</p>;
const appId = href },
.substring("https://store.steampowered.com/app/".length) a: ({ node, ...props }) => {
.split("/")[0]; const href = props.href as string;
if (!Number.isNaN(Number(appId))) { const origin = window.location.origin;
return (
<div className={"max-w-xl h-52 sm:h-48 my-2"}>
<iframe
className={"scheme-light"}
src={`https://store.steampowered.com/widget/${appId}/`}
></iframe>
</div>
);
}
}
}
return <p {...props}>{props.children}</p>;
},
a: ({ node, ...props }) => {
const href = props.href as string;
const origin = window.location.origin;
if (href.startsWith(origin) || href.startsWith("/")) { if (href.startsWith(origin) || href.startsWith("/")) {
let path = href; let path = href;
if (path.startsWith(origin)) { if (path.startsWith(origin)) {
path = path.substring(origin.length); path = path.substring(origin.length);
} }
const content = props.children?.toString(); const content = props.children?.toString();
if (path.startsWith("/resources/")) { if (path.startsWith("/resources/")) {
const id = path.substring("/resources/".length); const id = path.substring("/resources/".length);
for (const r of resource.related ?? []) { for (const r of resource.related ?? []) {
if (r.id.toString() === id) { if (r.id.toString() === id) {
return <RelatedResourceCard r={r} content={content} />; return <RelatedResourceCard r={r} content={content} />;
}
} }
} }
} }
}
return <a target={"_blank"} {...props}></a>; return <a target={"_blank"} {...props}></a>;
}, },
}} }}
> >
{resource.article.replaceAll("\n", " \n")} {resource.article.replaceAll("\n", " \n")}
</Markdown> </Markdown>
</article> </article>
<div className="border-b border-base-300 h-8"></div> <div className="border-b border-base-300 h-8"></div>
<Characters characters={resource.characters} /> <Characters characters={resource.characters} />
</> </>
); );
} }
@@ -881,6 +886,8 @@ function CloudflarePopup({ file }: { file: RFile }) {
const [isLoading, setLoading] = useState(true); const [isLoading, setLoading] = useState(true);
const [downloadToken, setDownloadToken] = useState<string | null>(null);
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
@@ -890,13 +897,15 @@ function CloudflarePopup({ file }: { file: RFile }) {
{isLoading ? ( {isLoading ? (
<div <div
className={ className={
"absolute top-0 bottom-0 left-0 right-0 flex items-center justify-center" "absolute top-0 bottom-8 left-0 right-0 flex items-center justify-center"
} }
> >
<span className={"loading loading-spinner loading-lg"}></span> <span className={"loading loading-spinner loading-lg"}></span>
</div> </div>
) : null} ) : null}
<h3 className={"font-bold m-2"}>{t("Verifying your request")}</h3> <h3 className={"font-bold m-2"}>
{downloadToken ? t("Verification successful") : t("Verifying your request")}
</h3>
<div className={"h-20 w-full"}> <div className={"h-20 w-full"}>
<Turnstile <Turnstile
siteKey={app.cloudflareTurnstileSiteKey!} siteKey={app.cloudflareTurnstileSiteKey!}
@@ -904,17 +913,29 @@ function CloudflarePopup({ file }: { file: RFile }) {
setLoading(false); setLoading(false);
}} }}
onSuccess={(token) => { onSuccess={(token) => {
closePopup(); setDownloadToken(token);
const link = network.getFileDownloadLink(file.id, token);
window.open(link, "_blank");
}} }}
></Turnstile> ></Turnstile>
</div> </div>
<p className={"text-xs text-base-content/80 m-2"}> {downloadToken ? (
<div className="p-2">
<a
href={network.getFileDownloadLink(file.id, downloadToken)}
target="_blank"
className="btn btn-primary btn-sm w-full"
onClick={() => {
closePopup();
}}
>
<MdOutlineDownload size={20} />
{t("Download")}
</a>
</div>
) : <p className={"text-xs text-base-content/80 m-2"}>
{t( {t(
"Please check your network if the verification takes too long or the captcha does not appear.", "Please check your network if the verification takes too long or the captcha does not appear.",
)} )}
</p> </p>}
</div> </div>
); );
} }
@@ -2050,346 +2071,6 @@ function CollectionSelector({
); );
} }
function Gallery({ images, nsfw }: { images: number[], nsfw: number[] }) {
const [currentIndex, setCurrentIndex] = useState(0);
const [direction, setDirection] = useState(0); // 方向1=向右,-1=向左
const [isHovered, setIsHovered] = useState(false);
const [width, setWidth] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const updateWidth = () => {
if (containerRef.current) {
setWidth(containerRef.current.clientWidth);
}
};
updateWidth();
window.addEventListener("resize", updateWidth);
return () => {
window.removeEventListener("resize", updateWidth);
};
}, []);
// 预加载下一张图片
useEffect(() => {
if (!images || images.length <= 1) return;
const nextIndex = (currentIndex + 1) % images.length;
const nextImageUrl = network.getImageUrl(images[nextIndex]);
const img = new Image();
img.src = nextImageUrl;
}, [currentIndex, images]);
if (!images || images.length === 0) {
return <></>;
}
const goToPrevious = () => {
setDirection(-1);
setCurrentIndex((prev) => (prev === 0 ? images.length - 1 : prev - 1));
};
const goToNext = () => {
setDirection(1);
setCurrentIndex((prev) => (prev === images.length - 1 ? 0 : prev + 1));
};
const goToIndex = (index: number) => {
setDirection(index > currentIndex ? 1 : -1);
setCurrentIndex(index);
};
if (nsfw == null) {
nsfw = [];
}
// 如果图片数量超过8张显示数字而不是圆点
const showDots = images.length <= 8;
return (
<>
<GalleryFullscreen
dialogRef={dialogRef}
images={images}
currentIndex={currentIndex}
direction={direction}
isHovered={isHovered}
setIsHovered={setIsHovered}
goToPrevious={goToPrevious}
goToNext={goToNext}
/>
<div
className="relative w-full overflow-hidden rounded-xl bg-base-100-tr82 shadow-sm"
style={{ aspectRatio: "16/9" }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* 图片区域 */}
<div ref={containerRef} className="w-full h-full relative" onClick={() => {
dialogRef.current?.showModal();
}}>
{width > 0 && (
<AnimatePresence initial={false} custom={direction} mode="sync">
<motion.div
key={currentIndex}
className="absolute w-full h-full object-contain"
variants={{
enter: (dir: number) => ({
x: dir > 0 ? width : -width,
}),
center: {
x: 0,
transition: { duration: 0.3, ease: "linear" },
},
exit: (dir: number) => ({
x: dir > 0 ? -width : width,
transition: { duration: 0.3, ease: "linear" },
}),
}}
initial="enter"
animate="center"
exit="exit"
custom={direction}
>
<GalleryImage
src={network.getImageUrl(images[currentIndex])}
nfsw={nsfw.includes(images[currentIndex])}
/>
</motion.div>
</AnimatePresence>
)}
</div>
{/* 左右按钮 */}
{images.length > 1 && (
<>
<button
className={`absolute left-2 top-1/2 -translate-y-1/2 transition-opacity hover:cursor-pointer ${
isHovered ? "opacity-100" : "opacity-0"
}`}
onClick={goToPrevious}
>
<MdOutlineChevronLeft size={28} />
</button>
<button
className={`absolute right-2 top-1/2 -translate-y-1/2 transition-opacity hover:cursor-pointer ${
isHovered ? "opacity-100" : "opacity-0"
}`}
onClick={goToNext}
>
<MdOutlineChevronRight size={28} />
</button>
</>
)}
{/* 底部指示器 */}
{images.length > 1 && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2">
{showDots ? (
/* 圆点指示器 */
<div className="flex gap-2">
{images.map((_, index) => (
<button
key={index}
className={`w-2 h-2 rounded-full transition-all ${
index === currentIndex
? "bg-primary w-4"
: "bg-base-content/30 hover:bg-base-content/50"
}`}
onClick={() => goToIndex(index)}
aria-label={`Go to image ${index + 1}`}
/>
))}
</div>
) : (
/* 数字指示器 */
<div className="bg-base-100/20 px-2 py-1 rounded-full text-xs">
{currentIndex + 1} / {images.length}
</div>
)}
</div>
)}
</div>
</>
);
}
function GalleryFullscreen({
dialogRef,
images,
currentIndex,
direction,
isHovered,
setIsHovered,
goToPrevious,
goToNext,
}: {
dialogRef: React.RefObject<HTMLDialogElement | null>;
images: number[];
currentIndex: number;
direction: number;
isHovered: boolean;
setIsHovered: (hovered: boolean) => void;
goToPrevious: () => void;
goToNext: () => void;
}) {
const [width, setWidth] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const updateWidth = () => {
if (containerRef.current) {
console.log(containerRef.current.clientWidth);
setWidth(containerRef.current.clientWidth);
}
};
updateWidth();
window.addEventListener("resize", updateWidth);
return () => {
window.removeEventListener("resize", updateWidth);
};
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (dialogRef.current?.open) {
if (e.key === "ArrowLeft") {
e.preventDefault();
goToPrevious();
} else if (e.key === "ArrowRight") {
e.preventDefault();
goToNext();
} else if (e.key === "Escape") {
dialogRef.current?.close();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [dialogRef, goToPrevious, goToNext]);
return (
<dialog
ref={dialogRef}
onClick={() => {
dialogRef.current?.close();
}}
className="modal"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div ref={containerRef} className="modal-box w-full h-full max-h-screen max-w-screen p-4 bg-transparent shadow-none relative overflow-clip">
{width > 0 && <AnimatePresence initial={false} custom={direction} mode="sync">
<motion.div
key={`fullscreen-${currentIndex}`}
className="absolute inset-0 w-full h-full"
variants={{
enter: (dir: number) => ({
x: dir > 0 ? width : -width,
}),
center: {
x: 0,
transition: { duration: 0.3, ease: "linear" },
},
exit: (dir: number) => ({
x: dir > 0 ? -width : width,
transition: { duration: 0.3, ease: "linear" },
}),
}}
initial="enter"
animate="center"
exit="exit"
custom={direction}
>
<img
src={network.getImageUrl(images[currentIndex])}
alt=""
className="w-full h-full object-contain rounded-xl p-4 sm:p-6"
/>
</motion.div>
</AnimatePresence>}
{/* 全屏模式下的左右切换按钮 */}
{images.length > 1 && (
<>
<button
className={`absolute left-4 top-1/2 -translate-y-1/2 cursor-pointer hover:bg-base-100/60 rounded-full p-2 transition-colors focus:border-none focus:outline-none`}
onClick={(e) => {
e.stopPropagation();
goToPrevious();
}}
>
<MdOutlineChevronLeft size={24} />
</button>
<button
className={`absolute right-4 top-1/2 -translate-y-1/2 cursor-pointer hover:bg-base-100/60 rounded-full p-2 transition-colors focus:border-none focus:outline-none`}
onClick={(e) => {
e.stopPropagation();
goToNext();
}}
>
<MdOutlineChevronRight size={24} />
</button>
{/* 全屏模式下的指示器 */}
<div className={`absolute bottom-4 left-1/2 -translate-x-1/2 transition-opacity ${
isHovered ? "opacity-100" : "opacity-0"
}`}>
<div className="bg-base-100/60 backdrop-blur-sm px-3 py-1.5 rounded-full text-sm font-medium select-none">
{currentIndex + 1} / {images.length}
</div>
</div>
{/* 关闭按钮 */}
<button
className={`absolute top-4 right-4 cursor-pointer hover:bg-base-100/60 rounded-full p-2 transition-colors`}
onClick={(e) => {
e.stopPropagation();
dialogRef.current?.close();
}}
>
<MdOutlineClose size={24} />
</button>
</>
)}
</div>
</dialog>
);
}
function GalleryImage({src, nfsw}: {src: string, nfsw: boolean}) {
const [show, setShow] = useState(!nfsw);
return (
<div className="relative w-full h-full">
<img
src={src}
alt=""
className={`w-full h-full object-contain transition-all duration-300 ${!show ? 'blur-xl' : ''}`}
/>
{!show && (
<>
<div className="absolute inset-0 bg-base-content/20 cursor-pointer" onClick={(event) => {
setShow(true)
event.stopPropagation();
}} />
<div className="absolute top-4 left-4">
<Badge className="badge-error shadow-lg">
NSFW
</Badge>
</div>
</>
)}
</div>
);
}
function Characters({ characters }: { characters: CharacterParams[] }) { function Characters({ characters }: { characters: CharacterParams[] }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -2431,7 +2112,7 @@ function CharacterCard({ character }: { character: CharacterParams }) {
alt={character.name} alt={character.name}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
<div className="absolute bottom-1 left-1 right-1 px-1 py-1 border border-base-100/40 rounded-lg bg-base-100/60"> <div className="absolute bottom-1 left-1 right-1 px-1 py-1 border border-base-100/40 rounded-lg bg-base-100/60">
<h4 className="font-semibold text-sm leading-tight line-clamp border border-transparent"> <h4 className="font-semibold text-sm leading-tight line-clamp border border-transparent">
{character.name} {character.name}
@@ -2445,7 +2126,7 @@ function CharacterCard({ character }: { character: CharacterParams }) {
) : null ) : null
} }
</h4> </h4>
{character.cv && ( {character.cv && (
<button <button
onClick={handleCVClick} onClick={handleCVClick}

15
go.mod
View File

@@ -14,14 +14,17 @@ require (
github.com/blevesearch/bleve v1.0.14 github.com/blevesearch/bleve v1.0.14
github.com/chai2010/webp v1.4.0 github.com/chai2010/webp v1.4.0
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/jlaffaye/ftp v0.2.0
github.com/redis/go-redis/v9 v9.17.0 github.com/redis/go-redis/v9 v9.17.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
github.com/wgh136/cloudflare-error-page v0.0.1
gorm.io/driver/mysql v1.6.0 gorm.io/driver/mysql v1.6.0
) )
require ( require (
filippo.io/edwards25519 v1.1.0 // indirect filippo.io/edwards25519 v1.1.0 // indirect
github.com/RoaringBitmap/roaring v0.4.23 // indirect github.com/RoaringBitmap/roaring v0.4.23 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
github.com/blevesearch/mmap-go v1.0.2 // indirect github.com/blevesearch/mmap-go v1.0.2 // indirect
github.com/blevesearch/segment v0.9.0 // indirect github.com/blevesearch/segment v0.9.0 // indirect
@@ -37,13 +40,22 @@ require (
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2 // indirect github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/golang/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.0 // indirect
github.com/golang/snappy v0.0.1 // indirect github.com/golang/snappy v0.0.1 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mschoch/smat v0.2.0 // indirect github.com/mschoch/smat v0.2.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/steveyen/gtreap v0.1.0 // indirect github.com/steveyen/gtreap v0.1.0 // indirect
github.com/willf/bitset v1.1.10 // indirect github.com/willf/bitset v1.1.10 // indirect
go.etcd.io/bbolt v1.3.5 // indirect go.etcd.io/bbolt v1.3.5 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
google.golang.org/protobuf v1.36.8 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
@@ -72,6 +84,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.28 // indirect github.com/mattn/go-sqlite3 v1.14.28 // indirect
github.com/philhofer/fwd v1.2.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect
github.com/prometheus/client_golang v1.23.2
github.com/tinylib/msgp v1.3.0 // indirect github.com/tinylib/msgp v1.3.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.65.0 // indirect github.com/valyala/fasthttp v1.65.0 // indirect

46
go.sum
View File

@@ -6,6 +6,8 @@ github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/blevesearch/bleve v1.0.14 h1:Q8r+fHTt35jtGXJUM0ULwM3Tzg+MRfyai4ZkWDy2xO4= github.com/blevesearch/bleve v1.0.14 h1:Q8r+fHTt35jtGXJUM0ULwM3Tzg+MRfyai4ZkWDy2xO4=
github.com/blevesearch/bleve v1.0.14/go.mod h1:e/LJTr+E7EaoVdkQZTfoz7dt4KoDNvDbLb8MSKuNTLQ= github.com/blevesearch/bleve v1.0.14/go.mod h1:e/LJTr+E7EaoVdkQZTfoz7dt4KoDNvDbLb8MSKuNTLQ=
github.com/blevesearch/blevex v1.0.0 h1:pnilj2Qi3YSEGdWgLj1Pn9Io7ukfXPoQcpAI1Bv8n/o= github.com/blevesearch/blevex v1.0.0 h1:pnilj2Qi3YSEGdWgLj1Pn9Io7ukfXPoQcpAI1Bv8n/o=
@@ -45,6 +47,7 @@ github.com/couchbase/moss v0.1.0/go.mod h1:9MaHIaRuy9pvLPUJxB8sh8OrLfyDczECVL37g
github.com/couchbase/vellum v1.0.2 h1:BrbP0NKiyDdndMPec8Jjhy0U47CZ0Lgx3xUC2r9rZqw= github.com/couchbase/vellum v1.0.2 h1:BrbP0NKiyDdndMPec8Jjhy0U47CZ0Lgx3xUC2r9rZqw=
github.com/couchbase/vellum v1.0.2/go.mod h1:FcwrEivFpNi24R3jLOs3n+fs5RnuQnQqCLBJ1uAg1W4= github.com/couchbase/vellum v1.0.2/go.mod h1:FcwrEivFpNi24R3jLOs3n+fs5RnuQnQqCLBJ1uAg1W4=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cznic/b v0.0.0-20181122101859-a26611c4d92d h1:SwD98825d6bdB+pEuTxWOXiSjBrHdOl/UVp75eI7JT8= github.com/cznic/b v0.0.0-20181122101859-a26611c4d92d h1:SwD98825d6bdB+pEuTxWOXiSjBrHdOl/UVp75eI7JT8=
github.com/cznic/b v0.0.0-20181122101859-a26611c4d92d/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8= github.com/cznic/b v0.0.0-20181122101859-a26611c4d92d/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8=
github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM= github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM=
@@ -83,18 +86,26 @@ github.com/gofiber/utils/v2 v2.0.0-rc.1/go.mod h1:Y1g08g7gvST49bbjHJ1AVqcsmg9391
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4= github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A= github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 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/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99 h1:twflg0XRTjwKpxb/jFExr4HGq6on2dEOmnL6FV+fgPw= github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99 h1:twflg0XRTjwKpxb/jFExr4HGq6on2dEOmnL6FV+fgPw=
github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ikawaha/kagome.ipadic v1.1.2/go.mod h1:DPSBbU0czaJhAb/5uKQZHMc9MTVRpDugJfX+HddPHHg= github.com/ikawaha/kagome.ipadic v1.1.2/go.mod h1:DPSBbU0czaJhAb/5uKQZHMc9MTVRpDugJfX+HddPHHg=
@@ -103,6 +114,8 @@ github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 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 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jlaffaye/ftp v0.2.0 h1:lXNvW7cBu7R/68bknOX3MrRIIqZ61zELs1P2RAiA3lg=
github.com/jlaffaye/ftp v0.2.0/go.mod h1:is2Ds5qkhceAPy2xD6RLI6hmp/qysSoymZ+Z2uTnspI=
github.com/jmhodges/levigo v1.0.0 h1:q5EC36kV79HWeTBWsod3mG11EgStG3qArTKcvlksN1U= github.com/jmhodges/levigo v1.0.0 h1:q5EC36kV79HWeTBWsod3mG11EgStG3qArTKcvlksN1U=
github.com/jmhodges/levigo v1.0.0/go.mod h1:Q6Qx+uH3RAqyK4rFQroq9RL7mdkABMcfhEI+nNuzMJQ= github.com/jmhodges/levigo v1.0.0/go.mod h1:Q6Qx+uH3RAqyK4rFQroq9RL7mdkABMcfhEI+nNuzMJQ=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
@@ -115,6 +128,12 @@ github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa02
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kljensen/snowball v0.6.0/go.mod h1:27N7E8fVU5H68RlUmnWwZCfxgt4POBJfENGMvNRhldw= github.com/kljensen/snowball v0.6.0/go.mod h1:27N7E8fVU5H68RlUmnWwZCfxgt4POBJfENGMvNRhldw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 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-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -133,6 +152,8 @@ github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh
github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg= github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
@@ -142,10 +163,20 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM= github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU= github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
@@ -181,6 +212,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8= github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8=
github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4= github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4=
github.com/wgh136/cloudflare-error-page v0.0.1 h1:OZ2JWfEF85JlwSVE71Jx0f+++HkotvZZ1Fb6YUyoFcQ=
github.com/wgh136/cloudflare-error-page v0.0.1/go.mod h1:/0dw1xavAlZLFlJla5qeLIh1/hv0irtR8oN7SBVMD8s=
github.com/willf/bitset v1.1.10 h1:NotGKqX0KwQ72NUzqrjZq5ipPNDQex9lo3WpaS8L2sc= github.com/willf/bitset v1.1.10 h1:NotGKqX0KwQ72NUzqrjZq5ipPNDQex9lo3WpaS8L2sc=
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -190,6 +223,10 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0= go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ= go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
@@ -215,8 +252,13 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@@ -6,7 +6,9 @@ import (
"nysoure/server/middleware" "nysoure/server/middleware"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/adaptor"
"github.com/gofiber/fiber/v3/middleware/logger" "github.com/gofiber/fiber/v3/middleware/logger"
prom "github.com/prometheus/client_golang/prometheus/promhttp"
) )
func main() { func main() {
@@ -19,6 +21,8 @@ func main() {
Format: "[${ip}]:${port} ${status} - ${method} ${path}\n", Format: "[${ip}]:${port} ${status} - ${method} ${path}\n",
})) }))
app.Use(middleware.UnsupportedRegionMiddleware)
app.Use(middleware.ErrorHandler) app.Use(middleware.ErrorHandler)
app.Use(middleware.RealUserMiddleware) app.Use(middleware.RealUserMiddleware)
@@ -27,6 +31,10 @@ func main() {
app.Use(middleware.FrontendMiddleware) app.Use(middleware.FrontendMiddleware)
app.Use(middleware.StatMiddleware)
app.Get("/metrics", adaptor.HTTPHandler(prom.Handler()))
apiG := app.Group("/api") apiG := app.Group("/api")
{ {
api.AddUserRoutes(apiG) api.AddUserRoutes(apiG)
@@ -40,6 +48,7 @@ func main() {
api.AddActivityRoutes(apiG) api.AddActivityRoutes(apiG)
api.AddCollectionRoutes(apiG) api.AddCollectionRoutes(apiG)
api.AddProxyRoutes(apiG) api.AddProxyRoutes(apiG)
api.AddDevAPI(apiG)
} }
log.Fatal(app.Listen(":3000")) log.Fatal(app.Listen(":3000"))

View File

@@ -1,10 +1,11 @@
package api package api
import ( import (
"github.com/gofiber/fiber/v3"
"nysoure/server/model" "nysoure/server/model"
"nysoure/server/service" "nysoure/server/service"
"strconv" "strconv"
"github.com/gofiber/fiber/v3"
) )
func handleGetActivity(c fiber.Ctx) error { func handleGetActivity(c fiber.Ctx) error {
@@ -28,6 +29,68 @@ func handleGetActivity(c fiber.Ctx) error {
}) })
} }
func handleGetUserNotifications(c fiber.Ctx) error {
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
pageStr := c.Query("page", "1")
page, err := strconv.Atoi(pageStr)
if err != nil {
return model.NewRequestError("Invalid page number")
}
notifications, totalPages, err := service.GetUserNotifications(uid, page)
if err != nil {
return err
}
if notifications == nil {
notifications = []model.ActivityView{}
}
return c.JSON(model.PageResponse[model.ActivityView]{
Success: true,
Data: notifications,
TotalPages: totalPages,
Message: "User notifications retrieved successfully",
})
}
func handleResetUserNotificationsCount(c fiber.Ctx) error {
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
err := service.ResetUserNotificationsCount(uid)
if err != nil {
return err
}
return c.JSON(model.Response[any]{
Success: true,
Message: "User notifications count reset successfully",
})
}
func handleGetUserNotificationsCount(c fiber.Ctx) error {
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("Unauthorized")
}
count, err := service.GetUserNotificationsCount(uid)
if err != nil {
return err
}
return c.JSON(model.Response[uint]{
Success: true,
Data: count,
Message: "User notifications count retrieved successfully",
})
}
func AddActivityRoutes(router fiber.Router) { func AddActivityRoutes(router fiber.Router) {
router.Get("/activity", handleGetActivity) router.Get("/activity", handleGetActivity)
notificationrouter := router.Group("/notification")
{
notificationrouter.Get("/", handleGetUserNotifications)
notificationrouter.Post("/reset", handleResetUserNotificationsCount)
notificationrouter.Get("/count", handleGetUserNotificationsCount)
}
} }

62
server/api/dev.go Normal file
View File

@@ -0,0 +1,62 @@
package api
import (
"log/slog"
"nysoure/server/dao"
"nysoure/server/middleware"
"time"
"nysoure/server/search"
"github.com/gofiber/fiber/v3"
)
func rebuildSearchIndex(c fiber.Ctx) error {
err := search.RebuildSearchIndex()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Failed to rebuild search index: " + err.Error(),
})
}
return c.JSON(fiber.Map{
"message": "Search index rebuilt successfully",
})
}
func updateResourceReleaseDate(c fiber.Ctx) error {
type Request struct {
ResourceID uint `json:"resource_id"`
ReleaseDate string `json:"release_date"`
}
var req Request
if err := c.Bind().JSON(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Invalid request body: " + err.Error(),
})
}
date, err := time.Parse("2006-01-02", req.ReleaseDate)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "Invalid date format: " + err.Error(),
})
}
err = dao.UpdateResourceReleaseDate(req.ResourceID, date)
if err != nil {
slog.Error("Failed to update release date", "error", err)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": "Failed to update release date",
})
}
return c.JSON(fiber.Map{
"message": "Release date updated successfully",
})
}
func AddDevAPI(router fiber.Router) {
devGroup := router.Group("/dev")
devGroup.Use(middleware.DevMiddleware())
{
devGroup.Post("/rebuild_search_index", rebuildSearchIndex)
devGroup.Post("/update_resource_release_date", updateResourceReleaseDate)
}
}

View File

@@ -8,6 +8,7 @@ import (
"nysoure/server/middleware" "nysoure/server/middleware"
"nysoure/server/model" "nysoure/server/model"
"nysoure/server/service" "nysoure/server/service"
"nysoure/server/stat"
"nysoure/server/utils" "nysoure/server/utils"
"strconv" "strconv"
"strings" "strings"
@@ -225,7 +226,23 @@ func downloadFile(c fiber.Ctx) error {
return err return err
} }
if strings.HasPrefix(s, "http") { if strings.HasPrefix(s, "http") {
return c.Redirect().Status(fiber.StatusFound).To(s) uri, err := url.Parse(s)
if err != nil {
return err
}
q := uri.Query()
if len(q) != 0 {
// If there are already query parameters, assume the URL is signed
return c.Redirect().Status(fiber.StatusFound).To(uri.String())
}
token, err := utils.GenerateDownloadToken(s)
if err != nil {
return err
}
q.Set("token", token)
uri.RawQuery = q.Encode()
stat.RecordDownload()
return c.Redirect().Status(fiber.StatusFound).To(uri.String())
} }
data := map[string]string{ data := map[string]string{
"path": s, "path": s,
@@ -236,6 +253,7 @@ func downloadFile(c fiber.Ctx) error {
if err != nil { if err != nil {
return model.NewInternalServerError("Failed to generate download token") return model.NewInternalServerError("Failed to generate download token")
} }
stat.RecordDownload()
return c.Redirect().Status(fiber.StatusFound).To(fmt.Sprintf("%s/api/files/download/local?token=%s", c.BaseURL(), token)) return c.Redirect().Status(fiber.StatusFound).To(fmt.Sprintf("%s/api/files/download/local?token=%s", c.BaseURL(), token))
} }

View File

@@ -109,7 +109,7 @@ func handleListResources(c fiber.Ctx) error {
if err != nil { if err != nil {
return model.NewRequestError("Invalid sort parameter") return model.NewRequestError("Invalid sort parameter")
} }
if sortInt < 0 || sortInt > 5 { if sortInt < 0 || sortInt > 7 {
return model.NewRequestError("Sort parameter out of range") return model.NewRequestError("Sort parameter out of range")
} }
sort := model.RSort(sortInt) sort := model.RSort(sortInt)
@@ -287,7 +287,11 @@ func handleGetCharactersFromVndb(c fiber.Ctx) error {
if vnID == "" { if vnID == "" {
return model.NewRequestError("VNDB ID is required") return model.NewRequestError("VNDB ID is required")
} }
characters, err := service.GetCharactersFromVndb(vnID) uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("You must be logged in to get characters from VNDB")
}
characters, err := service.GetCharactersFromVndb(vnID, uid)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -69,6 +69,37 @@ func handleCreateLocalStorage(c fiber.Ctx) error {
}) })
} }
func handleCreateFTPStorage(c fiber.Ctx) error {
var params service.CreateFTPStorageParams
if err := c.Bind().JSON(&params); err != nil {
return model.NewRequestError("Invalid request body")
}
if params.Name == "" || params.Host == "" || params.Username == "" ||
params.Password == "" || params.Domain == "" {
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.CreateFTPStorage(uid, params)
if err != nil {
return err
}
return c.Status(fiber.StatusCreated).JSON(model.Response[any]{
Success: true,
Message: "FTP storage created successfully",
})
}
func handleListStorages(c fiber.Ctx) error { func handleListStorages(c fiber.Ctx) error {
storages, err := service.ListStorages() storages, err := service.ListStorages()
if err != nil { if err != nil {
@@ -136,6 +167,7 @@ func AddStorageRoutes(r fiber.Router) {
s := r.Group("storage") s := r.Group("storage")
s.Post("/s3", handleCreateS3Storage) s.Post("/s3", handleCreateS3Storage)
s.Post("/local", handleCreateLocalStorage) s.Post("/local", handleCreateLocalStorage)
s.Post("/ftp", handleCreateFTPStorage)
s.Get("/", handleListStorages) s.Get("/", handleListStorages)
s.Delete("/:id", handleDeleteStorage) s.Delete("/:id", handleDeleteStorage)
s.Put("/:id/default", handleSetDefaultStorage) s.Put("/:id/default", handleSetDefaultStorage)

View File

@@ -7,6 +7,7 @@ import (
"nysoure/server/middleware" "nysoure/server/middleware"
"nysoure/server/model" "nysoure/server/model"
"nysoure/server/service" "nysoure/server/service"
"nysoure/server/stat"
"strconv" "strconv"
"time" "time"
@@ -24,6 +25,7 @@ func handleUserRegister(c fiber.Ctx) error {
if err != nil { if err != nil {
return err return err
} }
stat.RecordRegister()
return c.Status(fiber.StatusOK).JSON(model.Response[model.UserViewWithToken]{ return c.Status(fiber.StatusOK).JSON(model.Response[model.UserViewWithToken]{
Success: true, Success: true,
Data: user, Data: user,

View File

@@ -2,9 +2,10 @@ package dao
import ( import (
"errors" "errors"
"gorm.io/gorm"
"nysoure/server/model" "nysoure/server/model"
"time" "time"
"gorm.io/gorm"
) )
func AddNewResourceActivity(userID, resourceID uint) error { func AddNewResourceActivity(userID, resourceID uint) error {
@@ -42,13 +43,20 @@ func AddUpdateResourceActivity(userID, resourceID uint) error {
return db.Create(activity).Error return db.Create(activity).Error
} }
func AddNewCommentActivity(userID, commentID uint) error { func AddNewCommentActivity(userID, commentID, notifyTo uint) error {
activity := &model.Activity{ return db.Transaction(func(tx *gorm.DB) error {
UserID: userID, activity := &model.Activity{
Type: model.ActivityTypeNewComment, UserID: userID,
RefID: commentID, Type: model.ActivityTypeNewComment,
} RefID: commentID,
return db.Create(activity).Error NotifyTo: notifyTo,
}
err := tx.Create(activity).Error
if err != nil {
return err
}
return tx.Model(&model.User{}).Where("id = ?", notifyTo).UpdateColumn("unread_notifications_count", gorm.Expr("unread_notifications_count + ?", 1)).Error
})
} }
func AddNewFileActivity(userID, fileID uint) error { func AddNewFileActivity(userID, fileID uint) error {
@@ -82,3 +90,18 @@ func GetActivityList(offset, limit int) ([]model.Activity, int, error) {
return activities, int(total), nil return activities, int(total), nil
} }
func GetUserNotifications(userID uint, offset, limit int) ([]model.Activity, int, error) {
var activities []model.Activity
var total int64
if err := db.Model(&model.Activity{}).Where("notify_to = ?", userID).Count(&total).Error; err != nil {
return nil, 0, err
}
if err := db.Where("notify_to = ?", userID).Offset(offset).Limit(limit).Order("id DESC").Find(&activities).Error; err != nil {
return nil, 0, err
}
return activities, int(total), nil
}

View File

@@ -99,6 +99,10 @@ func GetResourceList(page, pageSize int, sort model.RSort) ([]model.Resource, in
order = "downloads ASC" order = "downloads ASC"
case model.RSortDownloadsDesc: case model.RSortDownloadsDesc:
order = "downloads DESC" order = "downloads DESC"
case model.RSortReleaseDateAsc:
order = "release_date ASC"
case model.RSortReleaseDateDesc:
order = "release_date DESC"
default: default:
order = "modified_time DESC" // Default sort order order = "modified_time DESC" // Default sort order
} }
@@ -693,3 +697,25 @@ func UpdateResourceImage(resourceID, oldImageID, newImageID uint) error {
return nil return nil
}) })
} }
func GetResourceOwnerID(resourceID uint) (uint, error) {
var uid uint
if err := db.Model(&model.Resource{}).Select("user_id").Where("id = ?", resourceID).First(&uid).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return 0, model.NewNotFoundError("Resource not found")
}
return 0, err
}
return uid, nil
}
func UpdateResourceReleaseDate(resourceID uint, releaseDate time.Time) error {
result := db.Model(&model.Resource{}).Where("id = ?", resourceID).Update("release_date", releaseDate)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return model.NewNotFoundError("Resource not found")
}
return nil
}

View File

@@ -2,8 +2,9 @@ package dao
import ( import (
"errors" "errors"
"gorm.io/gorm"
"nysoure/server/model" "nysoure/server/model"
"gorm.io/gorm"
) )
func CreateUser(username string, hashedPassword []byte) (model.User, error) { func CreateUser(username string, hashedPassword []byte) (model.User, error) {
@@ -21,9 +22,7 @@ func CreateUser(username string, hashedPassword []byte) (model.User, error) {
return user, err return user, err
} }
if exists { if exists {
return user, &model.RequestError{ return user, model.NewRequestError("User already exists")
Message: "User already exists",
}
} }
if err := db.Create(&user).Error; err != nil { if err := db.Create(&user).Error; err != nil {
return user, err return user, err
@@ -132,3 +131,15 @@ func DeleteUser(id uint) error {
} }
return db.Delete(&model.User{}, id).Error return db.Delete(&model.User{}, id).Error
} }
func ResetUserNotificationsCount(userID uint) error {
return db.Model(&model.User{}).Where("id = ?", userID).Update("unread_notifications_count", 0).Error
}
func GetUserNotificationCount(userID uint) (uint, error) {
var count uint
if err := db.Model(&model.User{}).Where("id = ?", userID).Select("unread_notifications_count").Scan(&count).Error; err != nil {
return 0, err
}
return count, nil
}

View File

@@ -0,0 +1,22 @@
package middleware
import (
"nysoure/server/model"
"os"
"github.com/gofiber/fiber/v3"
)
func DevMiddleware() func(c fiber.Ctx) error {
AccessKey := os.Getenv("DEV_ACCESS_KEY")
return func(c fiber.Ctx) error {
if AccessKey == "" {
return model.NewUnAuthorizedError("Unauthorized")
}
providedKey := c.Get("X-DEV-ACCESS-KEY")
if providedKey != AccessKey {
return model.NewUnAuthorizedError("Unauthorized")
}
return c.Next()
}
}

View File

@@ -5,7 +5,6 @@ import (
"nysoure/server/model" "nysoure/server/model"
"github.com/gofiber/fiber/v3/log" "github.com/gofiber/fiber/v3/log"
"gorm.io/gorm"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
) )
@@ -13,73 +12,22 @@ import (
func ErrorHandler(c fiber.Ctx) error { func ErrorHandler(c fiber.Ctx) error {
err := c.Next() err := c.Next()
if err != nil { if err != nil {
var requestErr *model.RequestError
var unauthorizedErr *model.UnAuthorizedError
var notFoundErr *model.NotFoundError
var fiberErr *fiber.Error var fiberErr *fiber.Error
if errors.As(err, &requestErr) { if errors.As(err, &fiberErr) {
log.Error("Request Error: ", err) if fiberErr.Code != fiber.StatusInternalServerError {
return c.Status(fiber.StatusBadRequest).JSON(model.Response[any]{ return c.Status(fiberErr.Code).JSON(model.Response[any]{
Success: false, Success: false,
Data: nil, Data: nil,
Message: requestErr.Error(), Message: fiberErr.Message,
}) })
} else if errors.As(err, &unauthorizedErr) {
log.Error("Unauthorized Error: ", err)
return c.Status(fiber.StatusUnauthorized).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: unauthorizedErr.Error(),
})
} else if errors.As(err, &notFoundErr) {
log.Error("Not Found Error: ", err)
return c.Status(fiber.StatusNotFound).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: notFoundErr.Error(),
})
} else if errors.Is(err, fiber.ErrNotFound) {
return c.Status(fiber.StatusNotFound).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: "Not found",
})
} else if errors.Is(err, fiber.ErrMethodNotAllowed) {
return c.Status(fiber.StatusMethodNotAllowed).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: "Method not allowed",
})
} else if errors.As(err, &fiberErr) && fiberErr.Message != "" {
return c.Status(fiberErr.Code).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: fiberErr.Message,
})
} else if errors.Is(err, gorm.ErrRecordNotFound) {
return c.Status(fiber.StatusNotFound).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: "Not found",
})
} 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",
})
}
} }
log.Error("Internal Server Error: ", err)
return c.Status(fiber.StatusInternalServerError).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: "Internal server error",
})
} }
log.Error("Internal Server Error: ", err)
return c.Status(fiber.StatusInternalServerError).JSON(model.Response[any]{
Success: false,
Data: nil,
Message: "Internal server error",
})
} }
return nil return nil
} }

View File

@@ -20,6 +20,10 @@ func FrontendMiddleware(c fiber.Ctx) error {
return c.Next() return c.Next()
} }
if strings.HasPrefix(c.Path(), "/metrics") {
return c.Next()
}
path := c.Path() path := c.Path()
file := "static" + path file := "static" + path
@@ -32,6 +36,7 @@ func FrontendMiddleware(c fiber.Ctx) error {
} }
if _, err := os.Stat(file); path == "/" || os.IsNotExist(err) { if _, err := os.Stat(file); path == "/" || os.IsNotExist(err) {
c.Set("Cache-Control", "no-cache")
return serveIndexHtml(c) return serveIndexHtml(c)
} else { } else {
c.Set("Cache-Control", "public, max-age=31536000, immutable") c.Set("Cache-Control", "public, max-age=31536000, immutable")

View File

@@ -0,0 +1,21 @@
package middleware
import (
"nysoure/server/stat"
"github.com/gofiber/fiber/v3"
)
func StatMiddleware(c fiber.Ctx) error {
err := c.Next()
status := "200"
if err != nil {
if e, ok := err.(*fiber.Error); ok {
status = string(rune(e.Code))
} else {
status = "500"
}
}
stat.RecordRequest(c.Method(), c.Route().Path, status)
return err
}

View File

@@ -0,0 +1,60 @@
package middleware
import (
"strings"
"github.com/gofiber/fiber/v3"
errorpage "github.com/wgh136/cloudflare-error-page"
)
func UnsupportedRegionMiddleware(c fiber.Ctx) error {
path := string(c.Request().URI().Path())
// Skip static file requests
if strings.Contains(path, ".") {
return c.Next()
}
// Skip API requests
if strings.HasPrefix(path, "/api") {
return c.Next()
}
if string(c.Request().Header.Peek("Unsupported-Region")) == "true" {
h, err := generateForbiddenPage(c)
if err != nil {
return err
}
c.Response().Header.Add("Content-Type", "text/html")
c.Status(fiber.StatusForbidden)
return c.SendString(h)
}
return c.Next()
}
func generateForbiddenPage(c fiber.Ctx) (string, error) {
params := errorpage.Params{
"error_code": 403,
"title": "Forbidden",
"browser_status": map[string]interface{}{
"status": "error",
"status_text": "Error",
},
"cloudflare_status": map[string]interface{}{
"status": "ok",
"status_text": "Working",
},
"host_status": map[string]interface{}{
"status": "ok",
"location": c.Hostname(),
},
"error_source": "cloudflare",
"what_happened": "<p>The service is not available in your region.</p>",
"what_can_i_do": "<p>Please try again in a few minutes.</p>",
"client_ip": c.IP(),
}
return errorpage.Render(params, nil)
}

View File

@@ -18,9 +18,10 @@ const (
type Activity struct { type Activity struct {
gorm.Model gorm.Model
UserID uint `gorm:"not null"` UserID uint `gorm:"not null"`
Type ActivityType `gorm:"not null;index:idx_type_refid"` Type ActivityType `gorm:"not null;index:idx_type_refid"`
RefID uint `gorm:"not null;index:idx_type_refid"` RefID uint `gorm:"not null;index:idx_type_refid"`
NotifyTo uint `gorm:"default:null;index"`
} }
type ActivityView struct { type ActivityView struct {

View File

@@ -2,78 +2,31 @@ package model
import ( import (
"errors" "errors"
"github.com/gofiber/fiber/v3"
) )
type RequestError struct { func NewRequestError(message string) error {
Message string `json:"message"` return fiber.NewError(400, message)
} }
func (e *RequestError) Error() string { func NewUnAuthorizedError(message string) error {
return e.Message return fiber.NewError(403, message)
} }
func NewRequestError(message string) *RequestError { func NewNotFoundError(message string) error {
return &RequestError{ return fiber.NewError(404, message)
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 { func IsNotFoundError(err error) bool {
var notFoundError *NotFoundError var fiberError *fiber.Error
ok := errors.As(err, &notFoundError) ok := errors.As(err, &fiberError)
return ok if !ok {
} return false
type InternalServerError struct {
Message string `json:"message"`
}
func (e *InternalServerError) Error() string {
return e.Message
}
func NewInternalServerError(message string) *InternalServerError {
return &InternalServerError{
Message: message,
} }
return fiberError.Code == 404
}
func NewInternalServerError(message string) error {
return fiber.NewError(500, message)
} }

View File

@@ -11,10 +11,12 @@ type Resource struct {
Title string Title string
AlternativeTitles []string `gorm:"serializer:json"` AlternativeTitles []string `gorm:"serializer:json"`
Links []Link `gorm:"serializer:json"` Links []Link `gorm:"serializer:json"`
ReleaseDate *time.Time
Article string Article string
Images []Image `gorm:"many2many:resource_images;"` Images []Image `gorm:"many2many:resource_images;"`
Tags []Tag `gorm:"many2many:resource_tags;"` CoverID *uint
Files []File `gorm:"foreignKey:ResourceID"` Tags []Tag `gorm:"many2many:resource_tags;"`
Files []File `gorm:"foreignKey:ResourceID"`
UserID uint UserID uint
User User User User
Views uint Views uint
@@ -32,12 +34,13 @@ type Link struct {
} }
type ResourceView struct { type ResourceView struct {
ID uint `json:"id"` ID uint `json:"id"`
Title string `json:"title"` Title string `json:"title"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
Tags []TagView `json:"tags"` ReleaseDate *time.Time `json:"release_date,omitempty"`
Image *ImageView `json:"image"` Tags []TagView `json:"tags"`
Author UserView `json:"author"` Image *ImageView `json:"image"`
Author UserView `json:"author"`
} }
type ResourceDetailView struct { type ResourceDetailView struct {
@@ -47,8 +50,10 @@ type ResourceDetailView struct {
Links []Link `json:"links"` Links []Link `json:"links"`
Article string `json:"article"` Article string `json:"article"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
ReleaseDate *time.Time `json:"releaseDate,omitempty"`
Tags []TagView `json:"tags"` Tags []TagView `json:"tags"`
Images []ImageView `json:"images"` Images []ImageView `json:"images"`
CoverID *uint `json:"coverId,omitempty"`
Files []FileView `json:"files"` Files []FileView `json:"files"`
Author UserView `json:"author"` Author UserView `json:"author"`
Views uint `json:"views"` Views uint `json:"views"`
@@ -75,18 +80,30 @@ func (r *Resource) ToView() ResourceView {
} }
var image *ImageView var image *ImageView
if len(r.Images) > 0 { if r.CoverID != nil {
// Use the cover image if specified
for _, img := range r.Images {
if img.ID == *r.CoverID {
v := img.ToView()
image = &v
break
}
}
}
// If no cover is set or cover image not found, use the first image
if image == nil && len(r.Images) > 0 {
v := r.Images[0].ToView() v := r.Images[0].ToView()
image = &v image = &v
} }
return ResourceView{ return ResourceView{
ID: r.ID, ID: r.ID,
Title: r.Title, Title: r.Title,
CreatedAt: r.CreatedAt, CreatedAt: r.CreatedAt,
Tags: tags, ReleaseDate: r.ReleaseDate,
Image: image, Tags: tags,
Author: r.User.ToView(), Image: image,
Author: r.User.ToView(),
} }
} }
@@ -115,8 +132,10 @@ func (r *Resource) ToDetailView() ResourceDetailView {
Links: r.Links, Links: r.Links,
Article: r.Article, Article: r.Article,
CreatedAt: r.CreatedAt, CreatedAt: r.CreatedAt,
ReleaseDate: r.ReleaseDate,
Tags: tags, Tags: tags,
Images: images, Images: images,
CoverID: r.CoverID,
Files: files, Files: files,
Author: r.User.ToView(), Author: r.User.ToView(),
Views: r.Views, Views: r.Views,

View File

@@ -9,4 +9,6 @@ const (
RSortViewsDesc RSortViewsDesc
RSortDownloadsAsc RSortDownloadsAsc
RSortDownloadsDesc RSortDownloadsDesc
RSortReleaseDateAsc
RSortReleaseDateDesc
) )

View File

@@ -9,16 +9,17 @@ import (
type User struct { type User struct {
gorm.Model gorm.Model
Username string `gorm:"uniqueIndex;not null"` Username string `gorm:"uniqueIndex;not null"`
PasswordHash []byte PasswordHash []byte
IsAdmin bool IsAdmin bool
CanUpload bool CanUpload bool
AvatarVersion int AvatarVersion int
ResourcesCount int ResourcesCount int
FilesCount int FilesCount int
CommentsCount int CommentsCount int
Resources []Resource `gorm:"foreignKey:UserID"` Resources []Resource `gorm:"foreignKey:UserID"`
Bio string Bio string
UnreadNotificationsCount uint `gorm:"not null;default:0"`
} }
type UserView struct { type UserView struct {

View File

@@ -3,30 +3,54 @@ package search
import ( import (
"errors" "errors"
"fmt" "fmt"
"log/slog"
"nysoure/server/dao" "nysoure/server/dao"
"nysoure/server/model" "nysoure/server/model"
"nysoure/server/utils" "nysoure/server/utils"
"os"
"strconv" "strconv"
"sync"
"time" "time"
"github.com/blevesearch/bleve" "github.com/blevesearch/bleve"
) )
var (
index bleve.Index
mu = sync.RWMutex{}
)
type ResourceParams struct { type ResourceParams struct {
Id uint Id uint
Title string Title string
Subtitles []string Subtitles []string
Time time.Time Time time.Time
Characters []ResourceCharacter
} }
var index bleve.Index type ResourceCharacter struct {
Name string
Alias []string
CV string
}
func AddResourceToIndex(r model.Resource) error { func AddResourceToIndex(r model.Resource) error {
mu.RLock()
defer mu.RUnlock()
cs := make([]ResourceCharacter, 0, len(r.Characters))
for _, c := range r.Characters {
cs = append(cs, ResourceCharacter{
Name: c.Name,
Alias: c.Alias,
CV: c.CV,
})
}
return index.Index(fmt.Sprintf("%d", r.ID), ResourceParams{ return index.Index(fmt.Sprintf("%d", r.ID), ResourceParams{
Id: r.ID, Id: r.ID,
Title: r.Title, Title: r.Title,
Subtitles: r.AlternativeTitles, Subtitles: r.AlternativeTitles,
Time: r.CreatedAt, Time: r.CreatedAt,
Characters: cs,
}) })
} }
@@ -40,16 +64,25 @@ func createIndex() error {
} }
page := 1 page := 1
total := 1 total := 1
current := 0
for page <= total { for page <= total {
res, totalPages, err := dao.GetResourceList(page, 100, model.RSortTimeAsc) res, totalPages, err := dao.GetResourceList(page, 100, model.RSortTimeAsc)
if err != nil { if err != nil {
return err return err
} }
for _, r := range res { for _, r := range res {
err := AddResourceToIndex(r) r, err := dao.GetResourceByID(r.ID)
if err != nil { if err != nil {
return err return err
} }
err = AddResourceToIndex(r)
if err != nil {
return err
}
current++
if current%20 == 0 {
slog.Info("Rebuilding search index", "current", current, "total", totalPages*100)
}
} }
page++ page++
total = totalPages total = totalPages
@@ -80,6 +113,8 @@ func init() {
} }
func SearchResource(keyword string) ([]uint, error) { func SearchResource(keyword string) ([]uint, error) {
mu.RLock()
defer mu.RUnlock()
query := bleve.NewMatchQuery(keyword) query := bleve.NewMatchQuery(keyword)
searchRequest := bleve.NewSearchRequest(query) searchRequest := bleve.NewSearchRequest(query)
searchResults, err := index.Search(searchRequest) searchResults, err := index.Search(searchRequest)
@@ -112,3 +147,24 @@ func IsStopWord(word string) bool {
tokens := analyzer.Analyze([]byte(word)) tokens := analyzer.Analyze([]byte(word))
return len(tokens) == 0 return len(tokens) == 0
} }
func RebuildSearchIndex() error {
mu.Lock()
defer mu.Unlock()
err := index.Close()
if err != nil {
return fmt.Errorf("failed to close search index: %w", err)
}
indexPath := utils.GetStoragePath() + "/resource_index.bleve"
err = os.RemoveAll(indexPath)
if err != nil {
return fmt.Errorf("failed to remove search index: %w", err)
}
mapping := bleve.NewIndexMapping()
index, err = bleve.New(indexPath, mapping)
if err != nil {
return fmt.Errorf("failed to create search index: %w", err)
}
go createIndex()
return nil
}

View File

@@ -68,3 +68,67 @@ func GetActivityList(page int) ([]model.ActivityView, int, error) {
return views, totalPages, nil return views, totalPages, nil
} }
func GetUserNotifications(userID uint, page int) ([]model.ActivityView, int, error) {
offset := (page - 1) * pageSize
limit := pageSize
activities, total, err := dao.GetUserNotifications(userID, offset, limit)
if err != nil {
return nil, 0, err
}
var views []model.ActivityView
for _, activity := range activities {
user, err := dao.GetUserByID(activity.UserID)
if err != nil {
return nil, 0, err
}
var comment *model.CommentView
var resource *model.ResourceView
var file *model.FileView
switch activity.Type {
case model.ActivityTypeNewComment:
c, err := dao.GetCommentByID(activity.RefID)
if err != nil {
return nil, 0, err
}
comment = c.ToView()
comment.Content, comment.ContentTruncated = restrictCommentLength(c.Content)
case model.ActivityTypeNewResource, model.ActivityTypeUpdateResource:
r, err := dao.GetResourceByID(activity.RefID)
if err != nil {
return nil, 0, err
}
rv := r.ToView()
resource = &rv
case model.ActivityTypeNewFile:
f, err := dao.GetFileByID(activity.RefID)
if err != nil {
return nil, 0, err
}
fv := f.ToView()
file = fv
r, err := dao.GetResourceByID(f.ResourceID)
if err != nil {
return nil, 0, err
}
rv := r.ToView()
resource = &rv
}
view := model.ActivityView{
ID: activity.ID,
User: user.ToView(),
Type: activity.Type,
Time: activity.CreatedAt,
Comment: comment,
Resource: resource,
File: file,
}
views = append(views, view)
}
totalPages := (total + pageSize - 1) / pageSize
return views, totalPages, nil
}

View File

@@ -29,6 +29,8 @@ func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType
return nil, model.NewRequestError("Comment content exceeds maximum length of 1024 characters") return nil, model.NewRequestError("Comment content exceeds maximum length of 1024 characters")
} }
var notifyTo uint
switch cType { switch cType {
case model.CommentTypeResource: case model.CommentTypeResource:
resourceExists, err := dao.ExistsResource(refID) resourceExists, err := dao.ExistsResource(refID)
@@ -39,12 +41,18 @@ func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType
if !resourceExists { if !resourceExists {
return nil, model.NewNotFoundError("Resource not found") return nil, model.NewNotFoundError("Resource not found")
} }
notifyTo, err = dao.GetResourceOwnerID(refID)
if err != nil {
log.Error("Error getting resource owner ID:", err)
return nil, model.NewInternalServerError("Error getting resource owner ID")
}
case model.CommentTypeReply: case model.CommentTypeReply:
_, err := dao.GetCommentByID(refID) comment, err := dao.GetCommentByID(refID)
if err != nil { if err != nil {
log.Error("Error getting reply comment:", err) log.Error("Error getting reply comment:", err)
return nil, model.NewNotFoundError("Reply comment not found") return nil, model.NewNotFoundError("Reply comment not found")
} }
notifyTo = comment.UserID
} }
userExists, err := dao.ExistsUserByID(userID) userExists, err := dao.ExistsUserByID(userID)
@@ -63,7 +71,7 @@ func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType
log.Error("Error creating comment:", err) log.Error("Error creating comment:", err)
return nil, model.NewInternalServerError("Error creating comment") return nil, model.NewInternalServerError("Error creating comment")
} }
err = dao.AddNewCommentActivity(userID, c.ID) err = dao.AddNewCommentActivity(userID, c.ID, notifyTo)
if err != nil { if err != nil {
log.Error("Error creating comment activity:", err) log.Error("Error creating comment activity:", err)
} }

View File

@@ -31,8 +31,78 @@ import (
const ( const (
resampledMaxPixels = 1280 * 720 resampledMaxPixels = 1280 * 720
subdirsCount = 256 // Number of subdirectories (0-255)
) )
// getImageSubdir returns the subdirectory name for an image filename
// Uses the first 2 characters of the filename to distribute across 256 subdirs
func getImageSubdir(filename string) string {
if len(filename) < 2 {
return "00"
}
// Use first 2 hex chars to determine subdir (e.g., "a1b2c3..." -> "a1")
return filename[:2]
}
// getImagePath returns the full path to an image, checking new subdirectory structure first,
// then falling back to legacy flat structure for backward compatibility
func getImagePath(filename string) string {
baseDir := utils.GetStoragePath() + "/images/"
// Try new subdirectory structure first
subdir := getImageSubdir(filename)
newPath := baseDir + subdir + "/" + filename
if _, err := os.Stat(newPath); err == nil {
return newPath
}
// Fall back to legacy flat structure
legacyPath := baseDir + filename
return legacyPath
}
// ensureImageSubdir creates the subdirectory for a filename if it doesn't exist
func ensureImageSubdir(filename string) error {
baseDir := utils.GetStoragePath() + "/images/"
subdir := getImageSubdir(filename)
subdirPath := baseDir + subdir
if _, err := os.Stat(subdirPath); os.IsNotExist(err) {
if err := os.MkdirAll(subdirPath, 0755); err != nil {
return err
}
}
return nil
}
// getResampledImagePath returns the full path to a resampled image using subdirectory structure
// Subdirectory is based on image ID modulo 256 (e.g., id=1234 -> subdir="d2" from 1234%256=210=0xd2)
func getResampledImagePath(imageID uint) string {
baseDir := utils.GetStoragePath() + "/resampled/"
subdir := strconv.FormatUint(uint64(imageID%subdirsCount), 16)
if len(subdir) == 1 {
subdir = "0" + subdir
}
return baseDir + subdir + "/" + strconv.Itoa(int(imageID)) + ".webp"
}
// ensureResampledSubdir creates the subdirectory for a resampled image if it doesn't exist
func ensureResampledSubdir(imageID uint) error {
baseDir := utils.GetStoragePath() + "/resampled/"
subdir := strconv.FormatUint(uint64(imageID%subdirsCount), 16)
if len(subdir) == 1 {
subdir = "0" + subdir
}
subdirPath := baseDir + subdir
if _, err := os.Stat(subdirPath); os.IsNotExist(err) {
if err := os.MkdirAll(subdirPath, 0755); err != nil {
return err
}
}
return nil
}
func init() { func init() {
// Start a goroutine to delete unused images every hour // Start a goroutine to delete unused images every hour
go func() { go func() {
@@ -108,13 +178,24 @@ func CreateImage(uid uint, ip string, data []byte) (uint, error) {
} }
filename := uuid.New().String() filename := uuid.New().String()
if err := os.WriteFile(imageDir+filename, data, 0644); err != nil {
// Create subdirectory for new storage structure
if err := ensureImageSubdir(filename); err != nil {
return 0, errors.New("failed to create image subdirectory")
}
// Save to new subdirectory structure
subdir := getImageSubdir(filename)
filepath := imageDir + subdir + "/" + filename
if err := os.WriteFile(filepath, data, 0644); err != nil {
return 0, errors.New("failed to save image file") return 0, errors.New("failed to save image file")
} }
i, err := dao.CreateImage(filename, img.Bounds().Dx(), img.Bounds().Dy()) i, err := dao.CreateImage(filename, img.Bounds().Dx(), img.Bounds().Dy())
if err != nil { if err != nil {
_ = os.Remove(imageDir + filename) // Clean up the file if database creation fails
subdir := getImageSubdir(filename)
_ = os.Remove(imageDir + subdir + "/" + filename)
return 0, err return 0, err
} }
@@ -127,11 +208,11 @@ func GetImage(id uint) ([]byte, error) {
return nil, err return nil, err
} }
imageDir := utils.GetStoragePath() + "/images/" filepath := getImagePath(i.FileName)
if _, err := os.Stat(imageDir); os.IsNotExist(err) { if _, err := os.Stat(filepath); os.IsNotExist(err) {
return nil, model.NewNotFoundError("Image not found") return nil, model.NewNotFoundError("Image not found")
} }
data, err := os.ReadFile(imageDir + i.FileName) data, err := os.ReadFile(filepath)
if err != nil { if err != nil {
return nil, errors.New("failed to read image file") return nil, errors.New("failed to read image file")
} }
@@ -161,11 +242,13 @@ func deleteImage(id uint) error {
return err return err
} }
imageDir := utils.GetStoragePath() + "/images/" // Delete from both potential locations (new subdir and legacy flat)
_ = os.Remove(imageDir + i.FileName) filepath := getImagePath(i.FileName)
_ = os.Remove(filepath)
resampledDir := utils.GetStoragePath() + "/resampled/" // Delete resampled image from subdirectory structure
_ = os.Remove(resampledDir + strconv.Itoa(int(i.ID)) + ".webp") resampledPath := getResampledImagePath(i.ID)
_ = os.Remove(resampledPath)
if err := dao.DeleteImage(id); err != nil { if err := dao.DeleteImage(id); err != nil {
return err return err
@@ -173,6 +256,7 @@ func deleteImage(id uint) error {
return nil return nil
} }
// GetResampledImage returns a resampled version of the image if it exceeds the maximum pixel limit, otherwise returns nil.
func GetResampledImage(id uint) ([]byte, error) { func GetResampledImage(id uint) ([]byte, error) {
i, err := dao.GetImageByID(id) i, err := dao.GetImageByID(id)
if err != nil { if err != nil {
@@ -189,14 +273,8 @@ func GetResampledImage(id uint) ([]byte, error) {
} }
func getOrCreateResampledImage(i model.Image) ([]byte, error) { func getOrCreateResampledImage(i model.Image) ([]byte, error) {
baseDir := utils.GetStoragePath() + "/resampled/" // Check if resampled image already exists
if _, err := os.Stat(baseDir); os.IsNotExist(err) { resampledFilepath := getResampledImagePath(i.ID)
if err := os.MkdirAll(baseDir, 0755); err != nil {
return nil, err
}
}
resampledFilepath := baseDir + strconv.Itoa(int(i.ID)) + ".webp"
if _, err := os.Stat(resampledFilepath); err != nil { if _, err := os.Stat(resampledFilepath); err != nil {
if !os.IsNotExist(err) { if !os.IsNotExist(err) {
return nil, err return nil, err
@@ -205,7 +283,7 @@ func getOrCreateResampledImage(i model.Image) ([]byte, error) {
return os.ReadFile(resampledFilepath) return os.ReadFile(resampledFilepath)
} }
originalFilepath := utils.GetStoragePath() + "/images/" + i.FileName originalFilepath := getImagePath(i.FileName)
if _, err := os.Stat(originalFilepath); os.IsNotExist(err) { if _, err := os.Stat(originalFilepath); os.IsNotExist(err) {
return nil, model.NewNotFoundError("Original image not found") return nil, model.NewNotFoundError("Original image not found")
} }
@@ -236,6 +314,12 @@ func getOrCreateResampledImage(i model.Image) ([]byte, error) {
if err := webp.Encode(buf, dstImg, &webp.Options{Quality: 80}); err != nil { if err := webp.Encode(buf, dstImg, &webp.Options{Quality: 80}); err != nil {
return nil, errors.New("failed to encode resampled image data to webp format") return nil, errors.New("failed to encode resampled image data to webp format")
} }
// Ensure subdirectory exists before saving
if err := ensureResampledSubdir(i.ID); err != nil {
return nil, errors.New("failed to create resampled image subdirectory")
}
if err := os.WriteFile(resampledFilepath, buf.Bytes(), 0644); err != nil { if err := os.WriteFile(resampledFilepath, buf.Bytes(), 0644); err != nil {
return nil, errors.New("failed to save resampled image file") return nil, errors.New("failed to save resampled image file")
} }

View File

@@ -30,9 +30,11 @@ type ResourceParams struct {
Title string `json:"title" binding:"required"` Title string `json:"title" binding:"required"`
AlternativeTitles []string `json:"alternative_titles"` AlternativeTitles []string `json:"alternative_titles"`
Links []model.Link `json:"links"` Links []model.Link `json:"links"`
ReleaseDate string `json:"release_date"`
Tags []uint `json:"tags"` Tags []uint `json:"tags"`
Article string `json:"article"` Article string `json:"article"`
Images []uint `json:"images"` Images []uint `json:"images"`
CoverID *uint `json:"cover_id"`
Gallery []uint `json:"gallery"` Gallery []uint `json:"gallery"`
GalleryNsfw []uint `json:"gallery_nsfw"` GalleryNsfw []uint `json:"gallery_nsfw"`
Characters []CharacterParams `json:"characters"` Characters []CharacterParams `json:"characters"`
@@ -101,12 +103,30 @@ func CreateResource(uid uint, params *ResourceParams) (uint, error) {
ImageID: imageID, ImageID: imageID,
} }
} }
var date *time.Time
if params.ReleaseDate != "" {
parsedDate, err := time.Parse("2006-01-02", params.ReleaseDate)
if err != nil {
return 0, model.NewRequestError("Invalid release date format, expected YYYY-MM-DD")
}
date = &parsedDate
}
// Validate CoverID if provided
var coverID *uint
if params.CoverID != nil && *params.CoverID != 0 {
if !slices.Contains(params.Images, *params.CoverID) {
return 0, model.NewRequestError("Cover ID must be one of the resource images")
}
coverID = params.CoverID
}
r := model.Resource{ r := model.Resource{
Title: params.Title, Title: params.Title,
AlternativeTitles: params.AlternativeTitles, AlternativeTitles: params.AlternativeTitles,
Article: params.Article, Article: params.Article,
Links: params.Links, Links: params.Links,
ReleaseDate: date,
Images: images, Images: images,
CoverID: coverID,
Tags: tags, Tags: tags,
UserID: uid, UserID: uid,
Gallery: gallery, Gallery: gallery,
@@ -529,10 +549,30 @@ func UpdateResource(uid, rid uint, params *ResourceParams) error {
} }
} }
var date *time.Time
if params.ReleaseDate != "" {
parsedDate, err := time.Parse("2006-01-02", params.ReleaseDate)
if err != nil {
return model.NewRequestError("Invalid release date format, expected YYYY-MM-DD")
}
date = &parsedDate
}
// Validate CoverID if provided
var coverID *uint
if params.CoverID != nil && *params.CoverID != 0 {
if !slices.Contains(params.Images, *params.CoverID) {
return model.NewRequestError("Cover ID must be one of the resource images")
}
coverID = params.CoverID
}
r.Title = params.Title r.Title = params.Title
r.AlternativeTitles = params.AlternativeTitles r.AlternativeTitles = params.AlternativeTitles
r.Article = params.Article r.Article = params.Article
r.Links = params.Links r.Links = params.Links
r.ReleaseDate = date
r.CoverID = coverID
r.Gallery = gallery r.Gallery = gallery
r.GalleryNsfw = nsfw r.GalleryNsfw = nsfw
r.Characters = characters r.Characters = characters
@@ -615,7 +655,15 @@ func GetPinnedResources() ([]model.ResourceView, error) {
return views, nil return views, nil
} }
func GetCharactersFromVndb(vnID string) ([]CharacterParams, error) { func GetCharactersFromVndb(vnID string, uid uint) ([]CharacterParams, error) {
canUpload, err := checkUserCanUpload(uid)
if err != nil {
return nil, err
}
if !canUpload {
return nil, model.NewUnAuthorizedError("You have not permission to fetch characters from VNDB")
}
client := http.Client{} client := http.Client{}
jsonStr := fmt.Sprintf(` jsonStr := fmt.Sprintf(`
{ {

View File

@@ -78,6 +78,42 @@ func CreateLocalStorage(uid uint, params CreateLocalStorageParams) error {
return err return err
} }
type CreateFTPStorageParams struct {
Name string `json:"name"`
Host string `json:"host"`
Username string `json:"username"`
Password string `json:"password"`
BasePath string `json:"basePath"`
Domain string `json:"domain"`
MaxSizeInMB uint `json:"maxSizeInMB"`
}
func CreateFTPStorage(uid uint, params CreateFTPStorageParams) 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 ftp storage")
}
ftp := storage.FTPStorage{
Host: params.Host,
Username: params.Username,
Password: params.Password,
BasePath: params.BasePath,
Domain: params.Domain,
}
s := model.Storage{
Name: params.Name,
Type: ftp.Type(),
Config: ftp.ToString(),
MaxSize: int64(params.MaxSizeInMB) * 1024 * 1024,
}
_, err = dao.CreateStorage(s)
return err
}
func ListStorages() ([]model.StorageView, error) { func ListStorages() ([]model.StorageView, error) {
storages, err := dao.GetStorages() storages, err := dao.GetStorages()
if err != nil { if err != nil {

View File

@@ -390,3 +390,11 @@ func validateUsername(username string) error {
} }
return nil return nil
} }
func ResetUserNotificationsCount(userID uint) error {
return dao.ResetUserNotificationsCount(userID)
}
func GetUserNotificationsCount(userID uint) (uint, error) {
return dao.GetUserNotificationCount(userID)
}

52
server/stat/stat.go Normal file
View File

@@ -0,0 +1,52 @@
package stat
import (
prom "github.com/prometheus/client_golang/prometheus"
)
var (
RequestCount = prom.NewCounterVec(
prom.CounterOpts{
Name: "http_requests_total",
Help: "Total number of HTTP requests",
},
[]string{"path", "status"},
)
RegisterCount = prom.NewCounterVec(
prom.CounterOpts{
Name: "register_requests_total",
Help: "Total number of registration requests",
},
[]string{},
)
DownloadCount = prom.NewCounterVec(
prom.CounterOpts{
Name: "download_requests_total",
Help: "Total number of download requests",
},
[]string{},
)
)
func init() {
prom.MustRegister(RequestCount)
prom.MustRegister(RegisterCount)
prom.MustRegister(DownloadCount)
}
func RecordRequest(method, path string, status string) {
if status == "404" {
// Aggregate all 404s under a single label
path = "NOT_FOUND"
}
path = method + " " + path
RequestCount.WithLabelValues(path, status).Inc()
}
func RecordRegister() {
RegisterCount.WithLabelValues().Inc()
}
func RecordDownload() {
DownloadCount.WithLabelValues().Inc()
}

164
server/storage/ftp.go Normal file
View File

@@ -0,0 +1,164 @@
package storage
import (
"encoding/json"
"errors"
"os"
"path"
"time"
"github.com/gofiber/fiber/v3/log"
"github.com/google/uuid"
"github.com/jlaffaye/ftp"
)
type FTPStorage struct {
Host string // FTP服务器地址例如: "ftp.example.com:21"
Username string // FTP用户名
Password string // FTP密码
BasePath string // FTP服务器上的基础路径例如: "/uploads"
Domain string // 文件服务器域名,用于生成下载链接,例如: "files.example.com"
}
func (f *FTPStorage) Upload(filePath string, fileName string) (string, error) {
// 连接到FTP服务器
conn, err := ftp.Dial(f.Host, ftp.DialWithTimeout(10*time.Second), ftp.DialWithExplicitTLS(nil))
if err != nil {
log.Error("Failed to connect to FTP server: ", err)
return "", errors.New("failed to connect to FTP server")
}
defer conn.Quit()
// 登录
err = conn.Login(f.Username, f.Password)
if err != nil {
log.Error("Failed to login to FTP server: ", err)
return "", errors.New("failed to login to FTP server")
}
// 生成唯一的存储键
storageKey := uuid.NewString() + "/" + fileName
remotePath := path.Join(f.BasePath, storageKey)
// 创建远程目录
remoteDir := path.Dir(remotePath)
err = f.createRemoteDir(conn, remoteDir)
if err != nil {
log.Error("Failed to create remote directory: ", err)
return "", errors.New("failed to create remote directory")
}
// 打开本地文件
file, err := os.Open(filePath)
if err != nil {
log.Error("Failed to open local file: ", err)
return "", errors.New("failed to open local file")
}
defer file.Close()
// 上传文件
err = conn.Stor(remotePath, file)
if err != nil {
log.Error("Failed to upload file to FTP server: ", err)
return "", errors.New("failed to upload file to FTP server")
}
return storageKey, nil
}
func (f *FTPStorage) Download(storageKey string, fileName string) (string, error) {
// 返回文件下载链接:域名 + 存储键
if f.Domain == "" {
return "", errors.New("domain is not configured")
}
return "https://" + f.Domain + "/" + storageKey, nil
}
func (f *FTPStorage) Delete(storageKey string) error {
// 连接到FTP服务器
conn, err := ftp.Dial(f.Host, ftp.DialWithTimeout(10*time.Second), ftp.DialWithExplicitTLS(nil))
if err != nil {
log.Error("Failed to connect to FTP server: ", err)
return errors.New("failed to connect to FTP server")
}
defer conn.Quit()
// 登录
err = conn.Login(f.Username, f.Password)
if err != nil {
log.Error("Failed to login to FTP server: ", err)
return errors.New("failed to login to FTP server")
}
// 删除文件
remotePath := path.Join(f.BasePath, storageKey)
err = conn.Delete(remotePath)
if err != nil {
log.Error("Failed to delete file from FTP server: ", err)
return errors.New("failed to delete file from FTP server")
}
return nil
}
func (f *FTPStorage) ToString() string {
data, _ := json.Marshal(f)
return string(data)
}
func (f *FTPStorage) FromString(config string) error {
var ftpConfig FTPStorage
if err := json.Unmarshal([]byte(config), &ftpConfig); err != nil {
return err
}
f.Host = ftpConfig.Host
f.Username = ftpConfig.Username
f.Password = ftpConfig.Password
f.BasePath = ftpConfig.BasePath
f.Domain = ftpConfig.Domain
if f.Host == "" || f.Username == "" || f.Password == "" || f.Domain == "" {
return errors.New("invalid FTP configuration")
}
if f.BasePath == "" {
f.BasePath = "/"
}
return nil
}
func (f *FTPStorage) Type() string {
return "ftp"
}
// createRemoteDir 递归创建远程目录
func (f *FTPStorage) createRemoteDir(conn *ftp.ServerConn, dirPath string) error {
if dirPath == "" || dirPath == "/" || dirPath == "." {
return nil
}
// 尝试进入目录,如果失败则创建
err := conn.ChangeDir(dirPath)
if err == nil {
// 目录存在,返回根目录
conn.ChangeDir("/")
return nil
}
// 递归创建父目录
parentDir := path.Dir(dirPath)
if parentDir != dirPath {
err = f.createRemoteDir(conn, parentDir)
if err != nil {
return err
}
}
// 创建当前目录
err = conn.MakeDir(dirPath)
if err != nil {
// 忽略目录已存在的错误
return nil
}
return nil
}

View File

@@ -43,6 +43,14 @@ func NewStorage(s model.Storage) IStorage {
return nil return nil
} }
return &r return &r
case "ftp":
r := FTPStorage{}
err := r.FromString(s.Config)
if err != nil {
return nil
}
return &r
} }
return nil return nil
} }

View File

@@ -3,9 +3,10 @@ package utils
import ( import (
"crypto/rand" "crypto/rand"
"errors" "errors"
"github.com/golang-jwt/jwt/v5"
"os" "os"
"time" "time"
"github.com/golang-jwt/jwt/v5"
) )
var ( var (
@@ -93,3 +94,24 @@ func ParseTemporaryToken(token string) (string, error) {
} }
return "", errors.New("invalid token") return "", errors.New("invalid token")
} }
func GenerateDownloadToken(fileKey string) (string, error) {
secretKeyStr := os.Getenv("DOWNLOAD_SECRET_KEY")
var secretKey []byte
if secretKeyStr == "" {
secretKey = key
} else {
secretKey = []byte(secretKeyStr)
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256,
jwt.MapClaims{
"fileKey": fileKey,
"exp": time.Now().Add(1 * time.Hour).Unix(),
})
s, err := t.SignedString(secretKey)
if err != nil {
return "", err
}
return s, nil
}