Compare commits

...

10 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
22 changed files with 316 additions and 163 deletions

View File

@@ -156,8 +156,8 @@ export const i18nData = {
"Cloudflare Turnstile Secret Key": "Cloudflare Turnstile 密钥",
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.":
"如果设置了 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": "请输入搜索关键词",
"Searching...": "搜索中...",
"Create Tag": "创建标签",
@@ -179,6 +179,8 @@ export const i18nData = {
"Views Descending": "浏览量降序",
"Downloads Ascending": "下载量升序",
"Downloads Descending": "下载量降序",
"Release Date Ascending": "发布日期升序",
"Release Date Descending": "发布日期降序",
"File Url": "文件链接",
"Provide a file url for the server to download, and the file will be moved to the selected storage.":
"提供一个文件链接供服务器下载,文件将被移动到选定的存储中。",
@@ -423,8 +425,8 @@ export const i18nData = {
"Cloudflare Turnstile Secret Key": "Cloudflare Turnstile 密鑰",
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.":
"如果設置了 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": "請輸入搜尋關鍵字",
"Searching...": "搜尋中...",
"Create Tag": "創建標籤",
@@ -446,6 +448,8 @@ export const i18nData = {
"Views Descending": "瀏覽量降序",
"Downloads Ascending": "下載量升序",
"Downloads Descending": "下載量降序",
"Release Date Ascending": "發布日期升序",
"Release Date Descending": "發布日期降序",
"File Url": "檔案連結",
"Provide a file url for the server to download, and the file will be moved to the selected storage.":
"提供一個檔案連結供伺服器下載,檔案將被移動到選定的儲存中。",

View File

@@ -48,6 +48,7 @@ export interface CreateResourceParams {
tags: number[];
article: string;
images: number[];
cover_id?: number;
gallery: number[];
gallery_nsfw: number[];
characters: CharacterParams[];
@@ -94,6 +95,7 @@ export interface ResourceDetails {
releaseDate?: string;
tags: Tag[];
images: Image[];
coverId?: number;
files: RFile[];
author: User;
views: number;
@@ -197,6 +199,8 @@ export enum RSort {
ViewsDesc = 3,
DownloadsAsc = 4,
DownloadsDesc = 5,
ReleaseDateAsc = 6,
ReleaseDateDesc = 7,
}
export enum ActivityType {

View File

@@ -29,6 +29,7 @@ export default function EditResourcePage() {
const [tags, setTags] = useState<Tag[]>([]);
const [article, setArticle] = useState<string>("");
const [images, setImages] = useState<number[]>([]);
const [coverId, setCoverId] = useState<number | undefined>(undefined);
const [links, setLinks] = useState<{ label: string; url: string }[]>([]);
const [galleryImages, setGalleryImages] = useState<number[]>([]);
const [galleryNsfw, setGalleryNsfw] = useState<number[]>([]);
@@ -59,10 +60,11 @@ export default function EditResourcePage() {
setTags(data.tags);
setArticle(data.article);
setImages(data.images.map((i) => i.id));
setCoverId(data.coverId);
setLinks(data.links ?? []);
setGalleryImages(data.gallery ?? []);
setGalleryNsfw(data.galleryNsfw ?? []);
setReleaseDate(data.releaseDate ?? undefined);
setReleaseDate(data.releaseDate?.split("T")[0] ?? undefined);
setCharacters(data.characters ?? []);
setLoading(false);
} else {
@@ -106,6 +108,7 @@ export default function EditResourcePage() {
tags: tags.map((tag) => tag.id),
article: article,
images: images,
cover_id: coverId,
links: links,
gallery: galleryImages,
gallery_nsfw: galleryNsfw,
@@ -328,7 +331,7 @@ export default function EditResourcePage() {
"Images will not be displayed automatically, you need to reference them in the description",
)}
</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
@@ -339,6 +342,7 @@ export default function EditResourcePage() {
<tr>
<td>{t("Preview")}</td>
<td>{"Markdown"}</td>
<td>{t("Cover")}</td>
<td>{t("Gallery")}</td>
<td>{"Nsfw"}</td>
<td>{t("Action")}</td>
@@ -368,6 +372,15 @@ export default function EditResourcePage() {
<MdContentCopy />
</button>
</td>
<td>
<input
type="radio"
name="cover"
className="radio radio-accent"
checked={coverId === image}
onChange={() => setCoverId(image)}
/>
</td>
<td>
<input
type="checkbox"
@@ -409,6 +422,9 @@ export default function EditResourcePage() {
const newImages = [...images];
newImages.splice(index, 1);
setImages(newImages);
if (coverId === id) {
setCoverId(undefined);
}
network.deleteImage(id);
}}
>

View File

@@ -5,7 +5,6 @@ import { app } from "../app.ts";
import { Resource, RSort, Statistics } from "../network/models.ts";
import { useTranslation } from "../utils/i18n";
import { useAppContext } from "../components/AppContext.tsx";
import Select from "../components/select.tsx";
import { useNavigate } from "react-router";
import { useNavigator } from "../components/navigator.tsx";
import {
@@ -40,23 +39,32 @@ export default function HomePage() {
<>
<HomeHeader />
<div className={"flex pt-4 px-4 items-center"}>
<Select
values={[
<select
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 Descending"),
t("Views Ascending"),
t("Views Descending"),
t("Downloads Ascending"),
t("Downloads Descending"),
]}
current={order}
onSelected={(index) => {
setOrder(index);
if (appContext) {
appContext.set("home_page_order", index);
}
}}
/>
t("Release Date Ascending"),
t("Release Date Descending"),
].map((label, idx) => (
<option key={idx} value={idx}>
{label}
</option>
))}
</select>
</div>
<ResourcesView
key={`home_page_${order}`}

View File

@@ -28,6 +28,7 @@ export default function PublishPage() {
const [tags, setTags] = useState<Tag[]>([]);
const [article, setArticle] = useState<string>("");
const [images, setImages] = useState<number[]>([]);
const [coverId, setCoverId] = useState<number | undefined>(undefined);
const [links, setLinks] = useState<{ label: string; url: string }[]>([]);
const [galleryImages, setGalleryImages] = useState<number[]>([]);
const [galleryNsfw, setGalleryNsfw] = useState<number[]>([]);
@@ -48,6 +49,7 @@ export default function PublishPage() {
setTags(data.tags || []);
setArticle(data.article || "");
setImages(data.images || []);
setCoverId(data.cover_id || undefined);
setLinks(data.links || []);
setGalleryImages(data.gallery || []);
setGalleryNsfw(data.gallery_nsfw || []);
@@ -64,6 +66,7 @@ export default function PublishPage() {
tags: tags,
article: article,
images: images,
cover_id: coverId,
links: links,
gallery: galleryImages,
gallery_nsfw: galleryNsfw,
@@ -73,7 +76,7 @@ export default function PublishPage() {
const dataString = JSON.stringify(data);
localStorage.setItem("publish_data", dataString);
}
}, [altTitles, article, images, tags, title, links, galleryImages, galleryNsfw, characters, releaseDate]);
}, [altTitles, article, images, coverId, tags, title, links, galleryImages, galleryNsfw, characters, releaseDate]);
const navigate = useNavigate();
const { t } = useTranslation();
@@ -120,6 +123,7 @@ export default function PublishPage() {
tags: tags.map((tag) => tag.id),
article: article,
images: images,
cover_id: coverId,
links: links,
gallery: galleryImages,
gallery_nsfw: galleryNsfw,
@@ -344,7 +348,7 @@ export default function PublishPage() {
"Images will not be displayed automatically, you need to reference them in the description",
)}
</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
@@ -355,7 +359,8 @@ export default function PublishPage() {
<tr>
<td>{t("Preview")}</td>
<td>{"Markdown"}</td>
<td>{"Gallery"}</td>
<td>{t("Cover")}</td>
<td>{t("Gallery")}</td>
<td>{"Nsfw"}</td>
<td>{t("Action")}</td>
</tr>
@@ -384,6 +389,15 @@ export default function PublishPage() {
<MdContentCopy />
</button>
</td>
<td>
<input
type="radio"
name="cover"
className="radio radio-accent"
checked={coverId === image}
onChange={() => setCoverId(image)}
/>
</td>
<td>
<input
type="checkbox"
@@ -425,6 +439,9 @@ export default function PublishPage() {
const newImages = [...images];
newImages.splice(index, 1);
setImages(newImages);
if (coverId === id) {
setCoverId(undefined);
}
network.deleteImage(id);
}}
>

14
go.mod
View File

@@ -14,14 +14,17 @@ require (
github.com/blevesearch/bleve v1.0.14
github.com/chai2010/webp v1.4.0
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/stretchr/testify v1.11.1
github.com/wgh136/cloudflare-error-page v0.0.1
gorm.io/driver/mysql v1.6.0
)
require (
filippo.io/edwards25519 v1.1.0 // 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/mmap-go v1.0.2 // indirect
github.com/blevesearch/segment v0.9.0 // indirect
@@ -37,16 +40,22 @@ require (
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/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/hashicorp/errwrap v1.0.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jlaffaye/ftp v0.2.0 // indirect
github.com/kr/text 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/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/willf/bitset v1.1.10 // 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
)
@@ -75,6 +84,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.28 // 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/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.65.0 // indirect

40
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/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
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/go.mod h1:e/LJTr+E7EaoVdkQZTfoz7dt4KoDNvDbLb8MSKuNTLQ=
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/go.mod h1:FcwrEivFpNi24R3jLOs3n+fs5RnuQnQqCLBJ1uAg1W4=
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/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8=
github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM=
@@ -83,13 +86,17 @@ 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/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
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.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.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
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/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/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
@@ -121,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/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
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/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
@@ -139,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.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
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.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
@@ -148,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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/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/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/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
@@ -187,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/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8=
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/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -196,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=
go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
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-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
@@ -221,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/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
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 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/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=

View File

@@ -6,7 +6,9 @@ import (
"nysoure/server/middleware"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/adaptor"
"github.com/gofiber/fiber/v3/middleware/logger"
prom "github.com/prometheus/client_golang/prometheus/promhttp"
)
func main() {
@@ -29,6 +31,10 @@ func main() {
app.Use(middleware.FrontendMiddleware)
app.Use(middleware.StatMiddleware)
app.Get("/metrics", adaptor.HTTPHandler(prom.Handler()))
apiG := app.Group("/api")
{
api.AddUserRoutes(apiG)

View File

@@ -8,6 +8,7 @@ import (
"nysoure/server/middleware"
"nysoure/server/model"
"nysoure/server/service"
"nysoure/server/stat"
"nysoure/server/utils"
"strconv"
"strings"
@@ -240,6 +241,7 @@ func downloadFile(c fiber.Ctx) error {
}
q.Set("token", token)
uri.RawQuery = q.Encode()
stat.RecordDownload()
return c.Redirect().Status(fiber.StatusFound).To(uri.String())
}
data := map[string]string{
@@ -251,6 +253,7 @@ func downloadFile(c fiber.Ctx) error {
if err != nil {
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))
}

View File

@@ -109,7 +109,7 @@ func handleListResources(c fiber.Ctx) error {
if err != nil {
return model.NewRequestError("Invalid sort parameter")
}
if sortInt < 0 || sortInt > 5 {
if sortInt < 0 || sortInt > 7 {
return model.NewRequestError("Sort parameter out of range")
}
sort := model.RSort(sortInt)

View File

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

View File

@@ -99,6 +99,10 @@ func GetResourceList(page, pageSize int, sort model.RSort) ([]model.Resource, in
order = "downloads ASC"
case model.RSortDownloadsDesc:
order = "downloads DESC"
case model.RSortReleaseDateAsc:
order = "release_date ASC"
case model.RSortReleaseDateDesc:
order = "release_date DESC"
default:
order = "modified_time DESC" // Default sort order
}

View File

@@ -22,9 +22,7 @@ func CreateUser(username string, hashedPassword []byte) (model.User, error) {
return user, err
}
if exists {
return user, &model.RequestError{
Message: "User already exists",
}
return user, model.NewRequestError("User already exists")
}
if err := db.Create(&user).Error; err != nil {
return user, err

View File

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

View File

@@ -20,6 +20,10 @@ func FrontendMiddleware(c fiber.Ctx) error {
return c.Next()
}
if strings.HasPrefix(c.Path(), "/metrics") {
return c.Next()
}
path := c.Path()
file := "static" + path

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

@@ -4,6 +4,8 @@ import (
"strings"
"github.com/gofiber/fiber/v3"
errorpage "github.com/wgh136/cloudflare-error-page"
)
func UnsupportedRegionMiddleware(c fiber.Ctx) error {
@@ -20,10 +22,39 @@ func UnsupportedRegionMiddleware(c fiber.Ctx) error {
}
if string(c.Request().Header.Peek("Unsupported-Region")) == "true" {
// Return a 403 Forbidden response with an empty html for unsupported regions
h, err := generateForbiddenPage(c)
if err != nil {
return err
}
c.Response().Header.Add("Content-Type", "text/html")
c.Status(fiber.StatusForbidden)
return c.SendString("<html></html>")
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

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

View File

@@ -14,6 +14,7 @@ type Resource struct {
ReleaseDate *time.Time
Article string
Images []Image `gorm:"many2many:resource_images;"`
CoverID *uint
Tags []Tag `gorm:"many2many:resource_tags;"`
Files []File `gorm:"foreignKey:ResourceID"`
UserID uint
@@ -52,6 +53,7 @@ type ResourceDetailView struct {
ReleaseDate *time.Time `json:"releaseDate,omitempty"`
Tags []TagView `json:"tags"`
Images []ImageView `json:"images"`
CoverID *uint `json:"coverId,omitempty"`
Files []FileView `json:"files"`
Author UserView `json:"author"`
Views uint `json:"views"`
@@ -78,7 +80,18 @@ func (r *Resource) ToView() ResourceView {
}
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()
image = &v
}
@@ -122,6 +135,7 @@ func (r *Resource) ToDetailView() ResourceDetailView {
ReleaseDate: r.ReleaseDate,
Tags: tags,
Images: images,
CoverID: r.CoverID,
Files: files,
Author: r.User.ToView(),
Views: r.Views,

View File

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

View File

@@ -34,6 +34,7 @@ type ResourceParams struct {
Tags []uint `json:"tags"`
Article string `json:"article"`
Images []uint `json:"images"`
CoverID *uint `json:"cover_id"`
Gallery []uint `json:"gallery"`
GalleryNsfw []uint `json:"gallery_nsfw"`
Characters []CharacterParams `json:"characters"`
@@ -110,6 +111,14 @@ func CreateResource(uid uint, params *ResourceParams) (uint, error) {
}
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{
Title: params.Title,
AlternativeTitles: params.AlternativeTitles,
@@ -117,6 +126,7 @@ func CreateResource(uid uint, params *ResourceParams) (uint, error) {
Links: params.Links,
ReleaseDate: date,
Images: images,
CoverID: coverID,
Tags: tags,
UserID: uid,
Gallery: gallery,
@@ -548,11 +558,21 @@ func UpdateResource(uid, rid uint, params *ResourceParams) error {
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.AlternativeTitles = params.AlternativeTitles
r.Article = params.Article
r.Links = params.Links
r.ReleaseDate = date
r.CoverID = coverID
r.Gallery = gallery
r.GalleryNsfw = nsfw
r.Characters = characters

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()
}