Compare commits

...

112 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
762ca44873 feat: fetching VNDB characters 2025-11-24 21:24:19 +08:00
79441a7226 fix: handle case where images array is undefined in Gallery component 2025-11-23 23:58:57 +08:00
4b1639e789 fix: remove initial backup execution from entrypoint script 2025-11-23 20:18:19 +08:00
53684db11c fix: update crontab entry to use bash for backup script execution 2025-11-23 20:05:43 +08:00
ee83eb9589 fix: update ENTRYPOINT to use bash for backup script execution 2025-11-23 20:00:32 +08:00
070fe1b416 feat: backup 2025-11-23 19:32:22 +08:00
c41ef094ea feat: implement fullscreen gallery with image preloading and navigation 2025-11-23 16:41:47 +08:00
6f711823ac fix: add 'main' role to character filtering logic 2025-11-23 12:09:40 +08:00
85eff4ecac fix: null check 2025-11-23 10:15:45 +08:00
0395bc4686 fix: remove limit 2025-11-22 22:20:49 +08:00
b1c01431fc fix: null check 2025-11-22 22:04:18 +08:00
c55a6612bd fix: null check 2025-11-22 21:52:52 +08:00
9f5f2c6e47 feat: use proxy 2025-11-22 21:42:38 +08:00
c554a05b60 feat: include status code in proxy response 2025-11-22 20:54:49 +08:00
4a60ad6133 feat: redis 2025-11-22 20:50:45 +08:00
67070fee4d feat: redis 2025-11-22 20:49:12 +08:00
b794d4cc96 feat: Add tag parameter to file upload and resource creation functions 2025-11-22 20:42:50 +08:00
e04fd8ceb1 fix: update image 2025-11-22 20:39:32 +08:00
c9a5f096bd feat: Remove unused port mappings 2025-11-22 20:30:39 +08:00
8b340ab175 feat: Implement proxy functionality 2025-11-22 20:27:54 +08:00
327fd72be0 feat: Add tag field to file models, and enhance file upload functionality with optional fields 2025-11-22 19:46:42 +08:00
1c23bf1d6e feat: Add tag field to file and uploading file models, and update related functions 2025-11-22 17:42:00 +08:00
070b9c7656 feat: Update resource image references in article content during image update 2025-11-17 21:14:30 +08:00
d118ad7d14 feat: Add admin permission check for resource image updates 2025-11-17 21:01:06 +08:00
b811ca25c4 feat: Add low resolution resource images retrieval and update functionality 2025-11-17 20:54:04 +08:00
43273fece2 feat: Enhance low resolution character retrieval with customizable page size 2025-11-17 20:21:34 +08:00
5d1e43f88d feat: Add low resolution character retrieval functionality 2025-11-17 20:20:43 +08:00
27bda316df fix: UpdateResource 2025-11-17 19:57:42 +08:00
cacd936ab3 fix unused variable 2025-11-16 22:37:21 +08:00
e2c30e5d77 improve characters UI 2025-11-16 21:23:12 +08:00
5a253b60d0 fix empty character image 2025-11-16 20:56:14 +08:00
03bf9ec97b update character image 2025-11-16 20:28:23 +08:00
92e4e05d7d fix 2025-11-16 18:49:28 +08:00
46a19c12fa typo 2025-11-16 18:46:22 +08:00
9d9a2545f9 Role for character 2025-11-16 18:37:33 +08:00
0a3e255dfe characters 2025-11-15 20:21:29 +08:00
ec85ee3e82 Get characters from vndb 2025-11-15 19:57:47 +08:00
3fec078ba6 charactor 2025-11-15 18:54:47 +08:00
3e953e22b0 fix 2025-11-15 16:06:45 +08:00
c9c8eac734 Add charactor api. 2025-11-15 16:00:26 +08:00
5cd708454a fix 2025-11-13 18:43:21 +08:00
05777ddb7f improve UI 2025-11-12 21:51:58 +08:00
8e2f326532 Validate gallery images. 2025-11-07 22:35:07 +08:00
853647edbc nsfw images 2025-11-02 19:51:33 +08:00
a7f1bcb365 improve gallery 2025-11-02 10:54:38 +08:00
2499962815 improve gallery 2025-11-01 17:28:24 +08:00
a4de7a1d54 fix 2025-11-01 17:16:58 +08:00
3e7ce7b4cd Add gallery 2025-11-01 17:12:06 +08:00
f84bcbdadc Fix created time of files. 2025-10-04 21:16:52 +08:00
af52e7b764 Display created time of files. 2025-10-04 21:10:47 +08:00
ff30125518 Improve file tile 2025-10-04 18:42:45 +08:00
02a33e00b3 Fix home page 2025-10-04 09:28:30 +08:00
9bf5149cff Add redirect url validation 2025-10-03 09:32:04 +08:00
4f564da7b3 Fix comment deleting 2025-10-02 23:01:34 +08:00
6ed0b45b41 Fix comment deleting 2025-10-02 22:48:11 +08:00
43070cdce3 Fix description 2025-10-02 22:34:51 +08:00
da8d03ec8f Fix home page 2025-10-02 22:33:42 +08:00
64116dddfd Update translation 2025-10-02 22:27:25 +08:00
97ee74899c Update home page 2025-10-02 22:25:37 +08:00
3a99d03427 fix 2025-10-02 21:38:16 +08:00
aac1992dba Add statistic api 2025-10-02 21:34:35 +08:00
17026a74c5 Update comments UI 2025-10-02 21:18:37 +08:00
1e01e04f7b Add replies field to CommentView and update logic to fetch comment replies 2025-10-02 20:16:29 +08:00
77 changed files with 4883 additions and 658 deletions

38
.env.example Normal file
View File

@@ -0,0 +1,38 @@
# Database Configuration
DB_HOST=db
DB_PORT=3306
DB_USER=nysoure
DB_PASSWORD=your_secure_password_here
DB_NAME=nysoure
MYSQL_ROOT_PASSWORD=your_secure_root_password_here
MYSQL_DATABASE=nysoure
MYSQL_USER=nysoure
MYSQL_PASSWORD=your_secure_password_here
# Redis Configuration
REDIS_HOST=redis
REDIS_PORT=6379
# Application Configuration
BANNED_REDIRECT_DOMAINS=example.com,example.org
ALLOWED_URL_REGEXPS=
# Backup Configuration
# Object storage configuration (S3-compatible)
S3_ENDPOINT=https://s3.amazonaws.com
S3_BUCKET=nysoure-backups
S3_ACCESS_KEY=your_access_key_here
S3_SECRET_KEY=your_secret_key_here
S3_REGION=us-east-1
# Backup schedule (cron format) - default: daily at 2 AM
BACKUP_SCHEDULE=0 2 * * *
# Retention policy (days)
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

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@
test.db test.db
.idea/ .idea/
build/ build/
temp/
.env

28
Dockerfile.backup Normal file
View File

@@ -0,0 +1,28 @@
FROM alpine:latest
# Install required packages
RUN apk add --no-cache \
bash \
mysql-client \
rclone \
tzdata \
supercronic \
&& rm -rf /var/cache/apk/*
# Set timezone (optional, adjust as needed)
ENV TZ=UTC
# Create backup directory
RUN mkdir -p /backup/local
# Copy backup script
COPY backup.sh /usr/local/bin/backup.sh
RUN chmod +x /usr/local/bin/backup.sh
# Copy entrypoint script
COPY backup-entrypoint.sh /usr/local/bin/backup-entrypoint.sh
RUN chmod +x /usr/local/bin/backup-entrypoint.sh
WORKDIR /backup
ENTRYPOINT ["/bin/bash", "/usr/local/bin/backup-entrypoint.sh"]

40
backup-entrypoint.sh Normal file
View File

@@ -0,0 +1,40 @@
#!/bin/bash
set -e
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}
# Default schedule: daily at 2 AM
BACKUP_SCHEDULE="${BACKUP_SCHEDULE:-0 2 * * *}"
log "Backup container starting..."
log "Backup schedule: ${BACKUP_SCHEDULE}"
# Wait for database to be ready
log "Waiting for database to be ready..."
for i in {1..30}; do
if mysql -h ${DB_HOST} -P ${DB_PORT} -u ${DB_USER} -p${DB_PASSWORD} -e "SELECT 1" > /dev/null 2>&1; then
log "Database is ready!"
break
fi
log "Waiting for database... (${i}/30)"
sleep 2
done
# Validate S3 configuration
if [ -z "${S3_BUCKET}" ] || [ -z "${S3_ACCESS_KEY}" ] || [ -z "${S3_SECRET_KEY}" ]; then
log "ERROR: S3 configuration is incomplete!"
log "Please set S3_BUCKET, S3_ACCESS_KEY, and S3_SECRET_KEY environment variables."
exit 1
fi
# Create crontab
echo "${BACKUP_SCHEDULE} /bin/bash /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1" > /tmp/crontab
log "Starting scheduled backups with supercronic..."
log "Logs will be written to /var/log/backup.log"
# Run supercronic with the crontab
supercronic /tmp/crontab

190
backup.sh Normal file
View File

@@ -0,0 +1,190 @@
#!/bin/bash
set -e
# Configuration
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/backup/local"
APP_DATA_DIR="/backup/app_data"
BACKUP_DATE=$(date +%Y-%m-%d)
# S3 configuration
S3_ENDPOINT="${S3_ENDPOINT}"
S3_BUCKET="${S3_BUCKET}"
S3_ACCESS_KEY="${S3_ACCESS_KEY}"
S3_SECRET_KEY="${S3_SECRET_KEY}"
S3_REGION="${S3_REGION:-us-east-1}"
# Retention
RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}"
# State file for incremental backups
STATE_FILE="${BACKUP_DIR}/last_backup_state.txt"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}
# Configure rclone for S3
configure_rclone() {
mkdir -p ~/.config/rclone
cat > ~/.config/rclone/rclone.conf <<EOF
[s3]
type = s3
provider = AWS
env_auth = false
access_key_id = ${S3_ACCESS_KEY}
secret_access_key = ${S3_SECRET_KEY}
region = ${S3_REGION}
endpoint = ${S3_ENDPOINT}
acl = private
EOF
}
# Backup database
backup_database() {
log "Starting database backup..."
DB_BACKUP_FILE="${BACKUP_DIR}/db_${TIMESTAMP}.sql.gz"
mysqldump -h ${DB_HOST} -P ${DB_PORT} -u ${DB_USER} -p${DB_PASSWORD} \
--single-transaction \
--quick \
--lock-tables=false \
--databases ${DB_NAME} | gzip > ${DB_BACKUP_FILE}
log "Database backup completed: ${DB_BACKUP_FILE}"
# Upload to S3
rclone copy ${DB_BACKUP_FILE} s3:${S3_BUCKET}/database/ --progress
log "Database backup uploaded to S3"
# Clean up local backup file after successful upload
rm -f ${DB_BACKUP_FILE}
}
# Backup config.json
backup_config() {
log "Backing up config.json..."
if [ -f "${APP_DATA_DIR}/config.json" ]; then
CONFIG_BACKUP="${BACKUP_DIR}/config_${TIMESTAMP}.json"
cp "${APP_DATA_DIR}/config.json" ${CONFIG_BACKUP}
# Upload to S3
rclone copy ${CONFIG_BACKUP} s3:${S3_BUCKET}/config/ --progress
log "Config backup uploaded to S3"
rm -f ${CONFIG_BACKUP}
else
log "Warning: config.json not found"
fi
}
# Incremental backup for images using rclone sync with checksums
backup_images() {
log "Starting incremental image backup..."
# Backup images directory
if [ -d "${APP_DATA_DIR}/images" ]; then
log "Syncing images directory (incremental)..."
# Using rclone sync with --checksum for efficient incremental backup
# Only uploads new or modified files
rclone sync "${APP_DATA_DIR}/images" s3:${S3_BUCKET}/images \
--checksum \
--transfers 8 \
--checkers 16 \
--fast-list \
--progress \
--log-file="${BACKUP_DIR}/images_sync_${TIMESTAMP}.log"
log "Images backup completed"
else
log "Warning: images directory not found"
fi
}
# Backup avatars
backup_avatars() {
log "Starting avatar backup..."
if [ -d "${APP_DATA_DIR}/avatar" ]; then
log "Syncing avatar directory..."
# Avatar directory is usually smaller, but still use incremental sync
rclone sync "${APP_DATA_DIR}/avatar" s3:${S3_BUCKET}/avatar \
--checksum \
--transfers 4 \
--progress \
--log-file="${BACKUP_DIR}/avatar_sync_${TIMESTAMP}.log"
log "Avatar backup completed"
else
log "Warning: avatar directory not found"
fi
}
# Clean up old database backups from S3
cleanup_old_backups() {
log "Cleaning up backups older than ${RETENTION_DAYS} days..."
# Delete old database backups
rclone delete s3:${S3_BUCKET}/database \
--min-age ${RETENTION_DAYS}d \
--progress || true
# Delete old config backups
rclone delete s3:${S3_BUCKET}/config \
--min-age ${RETENTION_DAYS}d \
--progress || true
# Clean up local logs
find ${BACKUP_DIR} -name "*.log" -mtime +7 -delete || true
log "Cleanup completed"
}
# Create backup state file
create_state_file() {
echo "LAST_BACKUP_DATE=${BACKUP_DATE}" > ${STATE_FILE}
echo "LAST_BACKUP_TIMESTAMP=${TIMESTAMP}" >> ${STATE_FILE}
}
# Main backup function
run_backup() {
log "=========================================="
log "Starting backup process..."
log "=========================================="
# Create backup directory
mkdir -p ${BACKUP_DIR}
# Configure rclone
configure_rclone
# Test S3 connection
log "Testing S3 connection..."
if ! rclone lsd s3:${S3_BUCKET} > /dev/null 2>&1; then
log "Error: Cannot connect to S3 bucket. Please check your credentials."
exit 1
fi
# Perform backups
backup_database
backup_config
backup_images
backup_avatars
# Cleanup old backups
cleanup_old_backups
# Update state file
create_state_file
log "=========================================="
log "Backup process completed successfully!"
log "=========================================="
}
# Run backup
run_backup

View File

@@ -9,27 +9,59 @@ services:
- app_data:/var/lib/nysoure - app_data:/var/lib/nysoure
depends_on: depends_on:
- db - db
environment: - redis
- DB_HOST=db env_file:
- DB_PORT=3306 - .env
- DB_USER=nysoure
- DB_PASSWORD=nysoure_password
- DB_NAME=nysoure
restart: unless-stopped restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
db: db:
image: mariadb:latest image: mariadb:latest
volumes: volumes:
- db_data:/var/lib/mysql - db_data:/var/lib/mysql
environment: env_file:
- MYSQL_ROOT_PASSWORD=root_password - .env
- MYSQL_DATABASE=nysoure
- MYSQL_USER=nysoure
- MYSQL_PASSWORD=nysoure_password
ports:
- "3306"
restart: unless-stopped restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
redis:
image: redis:latest
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
backup:
build:
context: .
dockerfile: Dockerfile.backup
volumes:
- app_data:/backup/app_data:ro
- db_data:/backup/db_data:ro
- backup_data:/backup/local
depends_on:
- db
- app
env_file:
- .env
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
volumes: volumes:
app_data: app_data:
db_data: db_data:
backup_data:

View File

@@ -38,6 +38,7 @@
window.siteInfo = `{{SiteInfo}}`; window.siteInfo = `{{SiteInfo}}`;
window.uploadPrompt = `{{UploadPrompt}}`; window.uploadPrompt = `{{UploadPrompt}}`;
window.allowNormalUserUpload = `{{AllowNormalUserUpload}}`; window.allowNormalUserUpload = `{{AllowNormalUserUpload}}`;
window.siteDescription = `{{SiteDescription}}`;
</script> </script>
<script id="pre_fetch_data"></script> <script id="pre_fetch_data"></script>
<div id="root"></div> <div id="root"></div>

View File

@@ -15,7 +15,6 @@
"masonic": "^4.1.0", "masonic": "^4.1.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-i18next": "^15.5.1",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^7.5.3", "react-router": "^7.5.3",
@@ -29,7 +28,7 @@
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@types/spark-md5": "^3.0.5", "@types/spark-md5": "^3.0.5",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"daisyui": "^5.0.35", "daisyui": "^5.5.5",
"eslint": "^9.22.0", "eslint": "^9.22.0",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.4.1", "eslint-plugin-prettier": "^5.4.1",
@@ -281,15 +280,6 @@
"@babel/core": "^7.0.0-0" "@babel/core": "^7.0.0-0"
} }
}, },
"node_modules/@babel/runtime": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz",
"integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.1.tgz",
@@ -2558,9 +2548,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/daisyui": { "node_modules/daisyui": {
"version": "5.0.35", "version": "5.5.5",
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.0.35.tgz", "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.5.tgz",
"integrity": "sha512-AWi11n/x5++mps55jcwrBf0Lmip1euWY0FYcH/05SFGmoqrU7S7/aIUWaiaeqlJ5EcmEZ/7zEY73aOxMv6hcIg==", "integrity": "sha512-ekvI93ZkWIJoCOtDl0D2QMxnWvTejk9V5nWBqRv+7t0xjiBXqAK5U6o6JE2RPvlIC3EqwNyUoIZSdHX9MZK3nw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"funding": { "funding": {
@@ -3618,15 +3608,6 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/html-url-attributes": { "node_modules/html-url-attributes": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
@@ -3670,38 +3651,6 @@
"url": "https://github.com/sponsors/typicode" "url": "https://github.com/sponsors/typicode"
} }
}, },
"node_modules/i18next": {
"version": "25.1.1",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.1.1.tgz",
"integrity": "sha512-FZcp3vk3PXc8onasbsWYahfeDIWX4LkKr4vd01xeXrmqyNXlVNtVecEIw2K1o8z3xYrHMcd1bwYQub+3g7zqCw==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.26.10"
},
"peerDependencies": {
"typescript": "^5"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -5681,32 +5630,6 @@
"react": "^19.1.0" "react": "^19.1.0"
} }
}, },
"node_modules/react-i18next": {
"version": "15.5.1",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.5.1.tgz",
"integrity": "sha512-C8RZ7N7H0L+flitiX6ASjq9p5puVJU1Z8VyL3OgM/QOMRf40BMZX+5TkpxzZVcTmOLPX5zlti4InEX5pFyiVeA==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.25.0",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.2.3",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-icons": { "node_modules/react-icons": {
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
@@ -6418,7 +6341,7 @@
"version": "5.7.3", "version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"devOptional": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
@@ -6727,15 +6650,6 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -22,7 +22,6 @@
"masonic": "^4.1.0", "masonic": "^4.1.0",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-i18next": "^15.5.1",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router": "^7.5.3", "react-router": "^7.5.3",
@@ -36,7 +35,7 @@
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@types/spark-md5": "^3.0.5", "@types/spark-md5": "^3.0.5",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"daisyui": "^5.0.35", "daisyui": "^5.5.5",
"eslint": "^9.22.0", "eslint": "^9.22.0",
"eslint-config-prettier": "^10.1.5", "eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.4.1", "eslint-plugin-prettier": "^5.4.1",

BIN
frontend/public/cp.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View File

@@ -6,6 +6,7 @@ interface MyWindow extends Window {
siteInfo?: string; siteInfo?: string;
uploadPrompt?: string; uploadPrompt?: string;
allowNormalUserUpload?: string; allowNormalUserUpload?: string;
siteDescription?: string;
} }
class App { class App {
@@ -21,6 +22,8 @@ class App {
uploadPrompt = ""; uploadPrompt = "";
siteDescription = "";
allowNormalUserUpload = true; allowNormalUserUpload = true;
constructor() { constructor() {
@@ -44,7 +47,9 @@ class App {
} }
this.siteInfo = (window as MyWindow).siteInfo || ""; this.siteInfo = (window as MyWindow).siteInfo || "";
this.uploadPrompt = (window as MyWindow).uploadPrompt || ""; this.uploadPrompt = (window as MyWindow).uploadPrompt || "";
// this.allowNormalUserUpload = (window as MyWindow).allowNormalUserUpload === "true"; this.siteDescription = (window as MyWindow).siteDescription || "";
this.allowNormalUserUpload =
(window as MyWindow).allowNormalUserUpload === "true";
} }
saveData() { saveData() {

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,140 @@
import { useState } from "react";
import { CharacterParams, CharacterRole } from "../network/models";
import { network } from "../network/network";
import showToast from "./toast";
import { useTranslation } from "../utils/i18n";
import Button from "./button";
export default function CharacterEditer({character, setCharacter, onDelete}: {
character: CharacterParams;
setCharacter: (character: CharacterParams) => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const [isUploading, setUploading] = useState(false);
const uploadImage = async () => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = async () => {
if (!input.files || input.files.length === 0) {
return;
}
setUploading(true);
const file = input.files[0];
const result = await network.uploadImage(file);
setUploading(false);
if (result.success) {
setCharacter({
...character,
image: result.data!,
});
} else {
showToast({
type: "error",
message: `Failed to upload image`,
})
}
};
input.click();
}
return <div className="h-52 shadow rounded-2xl overflow-clip flex bg-base-100">
<div className="w-36 h-full cursor-pointer relative" onClick={uploadImage}>
{
isUploading ?
<div className="absolute inset-0 bg-black/50 flex items-center justify-center z-10">
<span className="loading loading-spinner loading-lg text-white"></span>
</div>
: null
}
<img
className="w-full h-full object-cover bg-base-200/80 hover:bg-base-200 transition-colors"
src={character.image === 0 ? "/cp.webp" : network.getImageUrl(character.image)} alt={character.name}
/>
</div>
<div className="flex-1 p-4 flex flex-col gap-2">
<div className="flex gap-2 items-center">
<input
type="text"
className="input input-sm input-bordered flex-1"
placeholder={t("Name")}
value={character.name}
onChange={(e) => setCharacter({ ...character, name: e.target.value })}
/>
<button
className="btn btn-sm btn-error btn-square"
onClick={onDelete}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<input
type="text"
className="input input-sm input-bordered"
placeholder="CV"
value={character.cv}
onChange={(e) => setCharacter({ ...character, cv: e.target.value })}
/>
<select
className="select select-sm select-bordered"
value={character.role}
onChange={(e) => setCharacter({ ...character, role: e.target.value as CharacterRole })}
>
<option value="primary">{t("Primary Role")}</option>
<option value="side">{t("Side Role")}</option>
</select>
<div className="flex-1">
<textarea
className="textarea textarea-bordered w-full h-full resize-none text-xs"
placeholder={t("Aliases (one per line)")}
value={character.alias.join('\n')}
onChange={(e) => setCharacter({
...character,
alias: e.target.value.split('\n').filter(line => line.trim() !== '')
})}
/>
</div>
</div>
</div>;
}
export function FetchVndbCharactersButton({vnID, onFetch}: {
vnID: string;
onFetch: (characters: CharacterParams[]) => void;
}) {
const { t } = useTranslation();
const [isFetching, setFetching] = useState(false);
const fetchCharacters = async () => {
// validate vnID (v123456)
if (!/^v\d+$/.test(vnID)) {
showToast({
type: "error",
message: t("Invalid VNDB ID format"),
});
return;
}
setFetching(true);
const res = await network.getCharactersFromVNDB(vnID);
setFetching(false);
if (res.success && res.data) {
onFetch(res.data);
} else {
showToast({
type: "error",
message: t("Failed to fetch characters from VNDB"),
});
}
};
return <Button isLoading={isFetching} onClick={fetchCharacters}>
{t("Fetch from VNDB")}
</Button>;
}

View File

@@ -1,6 +1,5 @@
import { useTranslation } from "../utils/i18n"; import { useTranslation } from "../utils/i18n";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { MdOutlineComment } from "react-icons/md";
import { Comment } from "../network/models"; import { Comment } from "../network/models";
import { network } from "../network/network"; import { network } from "../network/network";
import Badge from "./badge"; import Badge from "./badge";
@@ -55,29 +54,66 @@ export function CommentTile({
{new Date(comment.created_at).toLocaleDateString()} {new Date(comment.created_at).toLocaleDateString()}
</Badge> </Badge>
</div> </div>
<div className={"p-2 comment_tile"}> <div className={"px-2 pt-2 comment_tile"}>
<CommentContent content={comment.content} /> <CommentContent content={comment.content} />
</div> </div>
<div className={"flex items-center"}> {comment.content_truncated ? (
{comment.content_truncated && ( <div className={"pl-2 pb-2"}>
<Badge className="badge-ghost">{t("Click to view more")}</Badge> <Badge className={"badge-soft badge-info badge-sm"}>
)} {t("Click to view more")}
<span className={"grow"}></span>
{comment.reply_count > 0 && (
<Badge className={"badge-soft badge-primary mr-2"}>
<MdOutlineComment size={16} className={"inline-block"} />
{comment.reply_count}
</Badge> </Badge>
)}
</div> </div>
) : (
<div className={"h-2"} />
)}
<CommentReplies comment={comment} />
</a> </a>
); );
} }
function CommentReplies({ comment }: { comment: Comment }) {
const { t } = useTranslation();
if (!comment.replies) {
return null;
}
return (
<div className={"bg-base-200 mx-2 p-2 rounded-lg"}>
{comment.replies.map((e) => {
return (
<p className={"text-xs mb-1"}>
<span className={"font-bold"}>{e.user.username}: </span>
{CommentToPlainText(e.content)}
</p>
);
})}
{comment.reply_count > comment.replies.length ? (
<p className={"text-xs text-primary mt-1"}>
{t("View {count} more replies").replace(
"{count}",
(comment.reply_count - comment.replies.length).toString(),
)}
</p>
) : null}
</div>
);
}
function CommentToPlainText(content: string) {
// Remove Markdown syntax to convert to plain text
return content
.replace(/!\[.*?]\(.*?\)/g, "") // Remove images
.replace(/\[([^\]]+)]\((.*?)\)/g, "$1") // Convert links to just the text
.replace(/[#>*_`~-]/g, "") // Remove other Markdown characters
.replace(/\n+/g, " ") // Replace newlines with spaces
.trim();
}
export function CommentContent({ content }: { content: string }) { export function CommentContent({ content }: { content: string }) {
const lines = content.split("\n"); const lines = content.split("\n");
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
let line = lines[i]; const line = lines[i];
if (!line.endsWith(" ")) { if (!line.endsWith(" ")) {
// Ensure that each line ends with two spaces for Markdown to recognize it as a line break // Ensure that each line ends with two spaces for Markdown to recognize it as a line break
lines[i] = line + " "; lines[i] = line + " ";

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.":
"提供一个文件链接供服务器下载,文件将被移动到选定的存储中。", "提供一个文件链接供服务器下载,文件将被移动到选定的存储中。",
@@ -254,6 +256,16 @@ export const i18nData = {
"You do not have permission to upload files, please contact the administrator.": "You do not have permission to upload files, please contact the administrator.":
"您没有上传文件的权限,请联系管理员。", "您没有上传文件的权限,请联系管理员。",
"Private": "私有", "Private": "私有",
"View {count} more replies": "查看另外 {count} 条回复",
"Survival time": "存活时间",
"Characters": "角色",
"Aliases (one per line)": "别名(每行一个)",
"File Size": "文件大小",
"Tag": "标签",
"Optional": "可选",
"Download": "下载",
"Notifications": "通知",
"Release Date": "发售日期",
}, },
}, },
"zh-TW": { "zh-TW": {
@@ -413,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": "創建標籤",
@@ -436,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.":
"提供一個檔案連結供伺服器下載,檔案將被移動到選定的儲存中。", "提供一個檔案連結供伺服器下載,檔案將被移動到選定的儲存中。",
@@ -511,6 +525,16 @@ export const i18nData = {
"You do not have permission to upload files, please contact the administrator.": "You do not have permission to upload files, please contact the administrator.":
"您沒有上傳檔案的權限,請聯繫管理員。", "您沒有上傳檔案的權限,請聯繫管理員。",
"Private": "私有", "Private": "私有",
"View {count} more replies": "查看另外 {count} 條回覆",
"Survival time": "存活時間",
"Characters": "角色",
"Aliases (one per line)": "別名(每行一個)",
"File Size": "檔案大小",
"Tag": "標籤",
"Optional": "可選",
"Download": "下載",
"Notifications": "通知",
"Release Date": "發售日期",
}, },
}, },
}; };

View File

@@ -3,10 +3,7 @@ import { Response } from "./models.ts";
const KunApi = { const KunApi = {
isAvailable(): boolean { isAvailable(): boolean {
return ( return true;
window.location.hostname === "res.nyne.dev" ||
window.location.hostname.startsWith("localhost")
);
}, },
async getPatch(id: string): Promise<Response<KunPatchResponse>> { async getPatch(id: string): Promise<Response<KunPatchResponse>> {
@@ -16,8 +13,10 @@ const KunApi = {
return status === 200 || status === 404; // Accept only 200 and 404 responses return status === 200 || status === 404; // Accept only 200 and 404 responses
}, },
}); });
const uri = `https://www.moyu.moe/api/hikari?vndb_id=${id}`;
const uriBase64 = btoa(uri);
const res = await client.get( const res = await client.get(
`https://www.moyu.moe/api/hikari?vndb_id=${id}`, `/api/proxy?uri=${uriBase64}`,
); );
if (res.status === 404) { if (res.status === 404) {
return { return {

View File

@@ -44,9 +44,24 @@ 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_nsfw: number[];
characters: CharacterParams[];
}
export type CharacterRole = 'primary' | 'side';
export interface CharacterParams {
name: string;
alias: string[];
cv: string;
image: number;
role: CharacterRole;
} }
export interface Image { export interface Image {
@@ -64,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;
@@ -76,14 +92,19 @@ 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;
downloads: number; downloads: number;
comments: number; comments: number;
related: Resource[]; related: Resource[];
gallery: number[];
galleryNsfw: number[];
characters: CharacterParams[];
} }
export interface Storage { export interface Storage {
@@ -105,6 +126,9 @@ export interface RFile {
user: User; user: User;
resource?: Resource; resource?: Resource;
hash?: string; hash?: string;
storage_name?: string;
created_at: number; // unix timestamp
tag?: string;
} }
export interface UploadingFile { export interface UploadingFile {
@@ -126,6 +150,7 @@ export interface Comment {
images: Image[]; images: Image[];
content_truncated: boolean; content_truncated: boolean;
reply_count: number; reply_count: number;
replies: Comment[];
} }
export interface CommentWithResource { export interface CommentWithResource {
@@ -137,6 +162,7 @@ export interface CommentWithResource {
resource: Resource; resource: Resource;
content_truncated: boolean; content_truncated: boolean;
reply_count: number; reply_count: number;
replies: Comment[];
} }
export interface CommentWithRef { export interface CommentWithRef {
@@ -173,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 {
@@ -203,3 +231,9 @@ export interface Collection {
images: Image[]; images: Image[];
isPublic: boolean; isPublic: boolean;
} }
export interface Statistics {
total_resources: number;
total_files: number;
start_time: number;
}

View File

@@ -20,6 +20,8 @@ import {
Activity, Activity,
CommentWithRef, CommentWithRef,
Collection, Collection,
Statistics,
CharacterParams,
} from "./models.ts"; } from "./models.ts";
class Network { class Network {
@@ -455,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`));
} }
@@ -477,6 +501,7 @@ class Network {
fileSize: number, fileSize: number,
resourceId: number, resourceId: number,
storageId: number, storageId: number,
tag: string,
): Promise<Response<UploadingFile>> { ): Promise<Response<UploadingFile>> {
return this._callApi(() => return this._callApi(() =>
axios.post(`${this.apiBaseUrl}/files/upload/init`, { axios.post(`${this.apiBaseUrl}/files/upload/init`, {
@@ -485,6 +510,7 @@ class Network {
file_size: fileSize, file_size: fileSize,
resource_id: resourceId, resource_id: resourceId,
storage_id: storageId, storage_id: storageId,
tag,
}), }),
); );
} }
@@ -527,6 +553,9 @@ class Network {
description: string, description: string,
resourceId: number, resourceId: number,
redirectUrl: string, redirectUrl: string,
fileSize: number,
md5: string,
tag: string,
): Promise<Response<RFile>> { ): Promise<Response<RFile>> {
return this._callApi(() => return this._callApi(() =>
axios.post(`${this.apiBaseUrl}/files/redirect`, { axios.post(`${this.apiBaseUrl}/files/redirect`, {
@@ -534,6 +563,9 @@ class Network {
description, description,
resource_id: resourceId, resource_id: resourceId,
redirect_url: redirectUrl, redirect_url: redirectUrl,
file_size: fileSize,
md5,
tag,
}), }),
); );
} }
@@ -544,6 +576,7 @@ class Network {
description: string, description: string,
resourceId: number, resourceId: number,
storageId: number, storageId: number,
tag: string,
): Promise<Response<RFile>> { ): Promise<Response<RFile>> {
return this._callApi(() => return this._callApi(() =>
axios.post(`${this.apiBaseUrl}/files/upload/url`, { axios.post(`${this.apiBaseUrl}/files/upload/url`, {
@@ -552,6 +585,7 @@ class Network {
description, description,
resource_id: resourceId, resource_id: resourceId,
storage_id: storageId, storage_id: storageId,
tag,
}), }),
); );
} }
@@ -564,11 +598,13 @@ class Network {
fileId: string, fileId: string,
filename: string, filename: string,
description: string, description: string,
tag: string,
): Promise<Response<RFile>> { ): Promise<Response<RFile>> {
return this._callApi(() => return this._callApi(() =>
axios.put(`${this.apiBaseUrl}/files/${fileId}`, { axios.put(`${this.apiBaseUrl}/files/${fileId}`, {
filename, filename,
description, description,
tag,
}), }),
); );
} }
@@ -694,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,
@@ -795,6 +851,20 @@ class Network {
}), }),
); );
} }
async getStatistic(): Promise<Response<Statistics>> {
return this._callApi(() =>
axios.get(`${this.apiBaseUrl}/config/statistics`),
);
}
async getCharactersFromVNDB(vnID: string): Promise<Response<CharacterParams[]>> {
return this._callApi(() =>
axios.get(`${this.apiBaseUrl}/resource/vndb/characters`, {
params: { vnid: vnID },
}),
);
}
} }
export const network = new Network(); export const network = new Network();

View File

@@ -201,6 +201,7 @@ class UploadingManager extends Listenable {
resourceID: number, resourceID: number,
storageID: number, storageID: number,
description: string, description: string,
tag: string,
onFinished: () => void, onFinished: () => void,
): Promise<Response<void>> { ): Promise<Response<void>> {
const res = await network.initFileUpload( const res = await network.initFileUpload(
@@ -209,6 +210,7 @@ class UploadingManager extends Listenable {
file.size, file.size,
resourceID, resourceID,
storageID, storageID,
tag,
); );
if (!res.success) { if (!res.success) {
return { return {

View File

@@ -26,13 +26,13 @@ export default function AboutPage() {
return ( return (
<a <a
className={ className={
"inline-block card card-border border-base-300 no-underline bg-base-200 hover:shadow transition-shadow" "inline-block card card-border border-base-200 no-underline bg-base-100 shadow-xs hover:shadow-sm transition-shadow mr-1 sm:mr-2 mb-2 w-52 sm:w-64"
} }
target={"_blank"} target={"_blank"}
href={href} href={href}
> >
<figure className={"max-h-60 max-w-80"}>{img}</figure> <figure className={"max-h-60 w-full"}>{img}</figure>
<div className={"card-body text-base-content text-lg"}> <div className={"text-base-content text-lg p-4"}>
{second} {second}
</div> </div>
</a> </a>

View File

@@ -6,7 +6,7 @@ import {
MdDelete, MdDelete,
MdOutlineInfo, MdOutlineInfo,
} from "react-icons/md"; } from "react-icons/md";
import { Tag } from "../network/models.ts"; import { CharacterParams, Tag } from "../network/models.ts";
import { network } from "../network/network.ts"; import { network } from "../network/network.ts";
import { useNavigate, useParams } from "react-router"; import { useNavigate, useParams } from "react-router";
import showToast from "../components/toast.ts"; import showToast from "../components/toast.ts";
@@ -20,14 +20,20 @@ import {
SelectAndUploadImageButton, SelectAndUploadImageButton,
UploadClipboardImageButton, UploadClipboardImageButton,
} from "../components/image_selector.tsx"; } from "../components/image_selector.tsx";
import CharacterEditer, { FetchVndbCharactersButton } from "../components/character_edit.tsx";
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 [galleryNsfw, setGalleryNsfw] = useState<number[]>([]);
const [characters, setCharacters] = useState<CharacterParams[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isSubmitting, setSubmitting] = useState(false); const [isSubmitting, setSubmitting] = useState(false);
const [isLoading, setLoading] = useState(true); const [isLoading, setLoading] = useState(true);
@@ -50,11 +56,16 @@ export default function EditResourcePage() {
if (res.success) { if (res.success) {
const data = res.data!; const data = res.data!;
setTitle(data.title); setTitle(data.title);
setAltTitles(data.alternativeTitles); setAltTitles(data.alternativeTitles ?? []);
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 ?? []);
setGalleryNsfw(data.galleryNsfw ?? []);
setReleaseDate(data.releaseDate?.split("T")[0] ?? undefined);
setCharacters(data.characters ?? []);
setLoading(false); setLoading(false);
} else { } else {
showToast({ message: t("Failed to load resource"), type: "error" }); showToast({ message: t("Failed to load resource"), type: "error" });
@@ -97,7 +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_nsfw: galleryNsfw,
characters: characters,
release_date: releaseDate,
}); });
if (res.success) { if (res.success) {
setSubmitting(false); setSubmitting(false);
@@ -146,7 +162,7 @@ export default function EditResourcePage() {
/> />
<div className={"h-4"}></div> <div className={"h-4"}></div>
<p className={"my-1"}>{t("Alternative Titles")}</p> <p className={"my-1"}>{t("Alternative Titles")}</p>
{altTitles.map((title, index) => { {altTitles && altTitles.map((title, index) => {
return ( return (
<div key={index} className={"flex items-center my-2"}> <div key={index} className={"flex items-center my-2"}>
<input <input
@@ -184,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) => {
@@ -307,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
@@ -318,6 +342,9 @@ 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>{"Nsfw"}</td>
<td>{t("Action")}</td> <td>{t("Action")}</td>
</tr> </tr>
</thead> </thead>
@@ -345,6 +372,47 @@ 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>
<input
type="checkbox"
className="checkbox checkbox-accent"
checked={galleryImages.includes(image)}
onChange={(e) => {
if (e.target.checked) {
setGalleryImages((prev) => [...prev, image]);
} else {
setGalleryImages((prev) =>
prev.filter((id) => id !== image),
);
}
}}
/>
</td>
<td>
<input
type="checkbox"
className="checkbox checkbox-accent"
checked={galleryNsfw.includes(image)}
onChange={(e) => {
if (e.target.checked) {
setGalleryNsfw((prev) => [...prev, image]);
} else {
setGalleryNsfw((prev) =>
prev.filter((id) => id !== image),
);
}
}}
/>
</td>
<td> <td>
<button <button
className={"btn btn-square"} className={"btn btn-square"}
@@ -354,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);
}} }}
> >
@@ -380,6 +451,50 @@ export default function EditResourcePage() {
/> />
</div> </div>
<div className={"h-4"}></div> <div className={"h-4"}></div>
<div>
<p className={"my-1"}>{t("Characters")}</p>
<div className="grid grid-cols-1 md:grid-cols-2 my-2 gap-4">
{
characters.map((character, index) => {
return <CharacterEditer
character={character}
setCharacter={(newCharacter) => {
const newCharacters = [...characters];
newCharacters[index] = newCharacter;
setCharacters(newCharacters);
}}
onDelete={() => {
const newCharacters = [...characters];
newCharacters.splice(index, 1);
setCharacters(newCharacters);
}} />;
})
}
</div>
<div className="flex my-2">
<button
className={"btn h-9"}
type={"button"}
onClick={() => {
setCharacters([...characters, { name: "", alias: [], cv: "", image: 0, role: "primary" }]);
}}
>
<MdAdd />
{t("Add Character")}
</button>
{
links.find(link => link.label.toLowerCase() === "vndb") &&
<div className="ml-4">
<FetchVndbCharactersButton
vnID={links.find(link => link.label.toLowerCase() === "vndb")?.url.split("/").pop() ?? ""}
onFetch={(fetchedCharacters) => {
setCharacters(fetchedCharacters);
}}
/>
</div>
}
</div>
</div>
{error && ( {error && (
<div role="alert" className="alert alert-error my-2 shadow"> <div role="alert" className="alert alert-error my-2 shadow">
<svg <svg

View File

@@ -2,12 +2,16 @@ import { useEffect, useState } from "react";
import ResourcesView from "../components/resources_view.tsx"; import ResourcesView from "../components/resources_view.tsx";
import { network } from "../network/network.ts"; import { network } from "../network/network.ts";
import { app } from "../app.ts"; import { app } from "../app.ts";
import { Resource, RSort } 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 {
MdOutlineAccessTime,
MdOutlineArchive,
MdOutlineClass,
} from "react-icons/md";
export default function HomePage() { export default function HomePage() {
useEffect(() => { useEffect(() => {
@@ -33,25 +37,34 @@ export default function HomePage() {
return ( return (
<> <>
<PinnedResources /> <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}`}
@@ -62,48 +75,142 @@ export default function HomePage() {
); );
} }
let cachedPinnedResources: Resource[] | null = null; function HomeHeader() {
function PinnedResources() {
const [pinnedResources, setPinnedResources] = useState<Resource[]>([]); const [pinnedResources, setPinnedResources] = useState<Resource[]>([]);
const [statistic, setStatistic] = useState<Statistics | null>(null);
const [currentIndex, setCurrentIndex] = useState(0);
const navigator = useNavigator(); const navigator = useNavigator();
const appContext = useAppContext();
useEffect(() => { useEffect(() => {
if (cachedPinnedResources != null) { const pinned = appContext.get("pinned_resources");
setPinnedResources(cachedPinnedResources); const stats = appContext.get("site_statistics");
if (pinned) {
setPinnedResources(pinned);
}
if (stats) {
setStatistic(stats);
}
if (pinned && stats) {
return; return;
} }
const prefetchData = app.getPreFetchData(); const prefetchData = app.getPreFetchData();
if (prefetchData && prefetchData.background) { if (prefetchData && prefetchData.background) {
navigator.setBackground( navigator.setBackground(
network.getResampledImageUrl(prefetchData.background), network.getResampledImageUrl(prefetchData.background),
); );
} }
let ok1 = false;
let ok2 = false;
if (prefetchData && prefetchData.statistics) {
setStatistic(prefetchData.statistics);
appContext.set("site_statistics", prefetchData.statistics);
ok1 = true;
}
if (prefetchData && prefetchData.pinned) { if (prefetchData && prefetchData.pinned) {
cachedPinnedResources = prefetchData.pinned; const r = prefetchData.pinned;
setPinnedResources(cachedPinnedResources!); appContext.set("pinned_resources", r);
setPinnedResources(r!);
ok2 = true;
}
if (ok1 && ok2) {
return; return;
} }
const fetchPinnedResources = async () => { const fetchPinnedResources = async () => {
const res = await network.getPinnedResources(); const res = await network.getPinnedResources();
if (res.success) { if (res.success) {
cachedPinnedResources = res.data ?? []; appContext.set("pinned_resources", res.data);
setPinnedResources(res.data ?? []); setPinnedResources(res.data ?? []);
} }
}; };
const fetchStatistics = async () => {
const res = await network.getStatistic();
if (res.success) {
appContext.set("site_statistics", res.data);
setStatistic(res.data!);
}
};
fetchPinnedResources(); fetchPinnedResources();
}, []); fetchStatistics();
}, [appContext, navigator]);
if (pinnedResources.length == 0) { // 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) {
return <></>; return <></>;
} }
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4"> <div className="grid grid-cols-1 md:grid-cols-2 p-4 gap-4">
{pinnedResources.map((resource) => ( <PinnedResourcesCarousel
<PinnedResourceItem key={resource.id} resource={resource} /> resources={pinnedResources}
currentIndex={currentIndex}
onIndexChange={setCurrentIndex}
/>
<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"}>
<h2 className={"text-lg font-bold pb-2"}>{app.appName}</h2>
<p className={"text-xs"}>{app.siteDescription}</p>
</div>
<StatisticCard statistic={statistic} />
</div>
</div>
);
}
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>
</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>
); );
} }
@@ -129,7 +236,7 @@ function PinnedResourceItem({ resource }: { resource: Resource }) {
<img <img
src={network.getResampledImageUrl(resource.image.id)} src={network.getResampledImageUrl(resource.image.id)}
alt="cover" alt="cover"
className="w-full aspect-[7/3] object-cover" className="w-full h-52 md:h-60 object-cover"
/> />
</figure> </figure>
)} )}
@@ -140,3 +247,40 @@ function PinnedResourceItem({ resource }: { resource: Resource }) {
</a> </a>
); );
} }
function StatisticCard({ statistic }: { statistic: Statistics }) {
const { t } = useTranslation();
const now = new Date();
const createdAt = new Date(statistic.start_time * 1000);
const diffTime = Math.abs(now.getTime() - createdAt.getTime());
const survivalTime = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return (
<div className="stats shadow w-full bg-base-100-tr82">
<div className="stat">
<div className="stat-figure text-secondary pt-2">
<MdOutlineClass size={28} />
</div>
<div className="stat-title">{t("Resources")}</div>
<div className="stat-value">{statistic.total_resources}</div>
</div>
<div className="stat">
<div className="stat-figure text-secondary pt-2">
<MdOutlineArchive size={28} />
</div>
<div className="stat-title">{t("Files")}</div>
<div className="stat-value">{statistic.total_files}</div>
</div>
<div className="stat">
<div className="stat-figure text-accent pt-2">
<MdOutlineAccessTime size={28} />
</div>
<div className="stat-title">{t("Survival time")}</div>
<div className="stat-value">{survivalTime}</div>
</div>
</div>
);
}

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

@@ -6,7 +6,7 @@ import {
MdDelete, MdDelete,
MdOutlineInfo, MdOutlineInfo,
} from "react-icons/md"; } from "react-icons/md";
import { Tag } from "../network/models.ts"; import { CharacterParams, Tag } from "../network/models.ts";
import { network } from "../network/network.ts"; import { network } from "../network/network.ts";
import { useNavigate } from "react-router"; import { useNavigate } from "react-router";
import { useTranslation } from "../utils/i18n"; import { useTranslation } from "../utils/i18n";
@@ -19,17 +19,22 @@ import {
SelectAndUploadImageButton, SelectAndUploadImageButton,
UploadClipboardImageButton, UploadClipboardImageButton,
} from "../components/image_selector.tsx"; } from "../components/image_selector.tsx";
import CharacterEditer, { FetchVndbCharactersButton } from "../components/character_edit.tsx";
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 [galleryNsfw, setGalleryNsfw] = useState<number[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isSubmitting, setSubmitting] = useState(false); const [isSubmitting, setSubmitting] = useState(false);
const [characters, setCharacters] = useState<CharacterParams[]>([]);
const isFirstLoad = useRef(true); const isFirstLoad = useRef(true);
useEffect(() => { useEffect(() => {
@@ -40,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);
} }
@@ -55,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();
@@ -102,10 +119,15 @@ 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_nsfw: galleryNsfw,
characters: characters,
}); });
if (res.success) { if (res.success) {
localStorage.removeItem("publish_data"); localStorage.removeItem("publish_data");
@@ -195,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) => {
@@ -318,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
@@ -329,6 +359,9 @@ export default function PublishPage() {
<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>{"Nsfw"}</td>
<td>{t("Action")}</td> <td>{t("Action")}</td>
</tr> </tr>
</thead> </thead>
@@ -356,6 +389,47 @@ 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>
<input
type="checkbox"
className="checkbox checkbox-accent"
checked={galleryImages.includes(image)}
onChange={(e) => {
if (e.target.checked) {
setGalleryImages((prev) => [...prev, image]);
} else {
setGalleryImages((prev) =>
prev.filter((id) => id !== image),
);
}
}}
/>
</td>
<td>
<input
type="checkbox"
className="checkbox checkbox-accent"
checked={galleryNsfw.includes(image)}
onChange={(e) => {
if (e.target.checked) {
setGalleryNsfw((prev) => [...prev, image]);
} else {
setGalleryNsfw((prev) =>
prev.filter((id) => id !== image),
);
}
}}
/>
</td>
<td> <td>
<button <button
className={"btn btn-square"} className={"btn btn-square"}
@@ -365,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);
}} }}
> >
@@ -391,6 +468,50 @@ export default function PublishPage() {
/> />
</div> </div>
<div className={"h-4"}></div> <div className={"h-4"}></div>
<div>
<p className={"my-1"}>{t("Characters")}</p>
<div className="grid grid-cols-1 md:grid-cols-2 my-2 gap-4">
{
characters.map((character, index) => {
return <CharacterEditer
character={character}
setCharacter={(newCharacter) => {
const newCharacters = [...characters];
newCharacters[index] = newCharacter;
setCharacters(newCharacters);
}}
onDelete={() => {
const newCharacters = [...characters];
newCharacters.splice(index, 1);
setCharacters(newCharacters);
}} />;
})
}
</div>
<div className="flex">
<button
className={"btn my-2"}
type={"button"}
onClick={() => {
setCharacters([...characters, { name: "", alias: [], cv: "", image: 0, role: "primary" }]);
}}
>
<MdAdd />
{t("Add Character")}
</button>
{
links.find(link => link.label.toLowerCase() === "vndb") &&
<div className="ml-4">
<FetchVndbCharactersButton
vnID={links.find(link => link.label.toLowerCase() === "vndb")?.url.split("/").pop() ?? ""}
onFetch={(fetchedCharacters) => {
setCharacters(fetchedCharacters);
}}
/>
</div>
}
</div>
</div>
{error && ( {error && (
<div role="alert" className="alert alert-error my-2 shadow"> <div role="alert" className="alert alert-error my-2 shadow">
<svg <svg

View File

@@ -7,6 +7,7 @@ import {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useMemo,
useRef, useRef,
useState, useState,
} from "react"; } from "react";
@@ -18,6 +19,7 @@ import {
Tag, Tag,
Resource, Resource,
Collection, Collection,
CharacterParams,
} from "../network/models.ts"; } from "../network/models.ts";
import { network } from "../network/network.ts"; import { network } from "../network/network.ts";
import showToast from "../components/toast.ts"; import showToast from "../components/toast.ts";
@@ -26,10 +28,13 @@ import "../markdown.css";
import Loading from "../components/loading.tsx"; import Loading from "../components/loading.tsx";
import { import {
MdAdd, MdAdd,
MdOutlineAccessTime,
MdOutlineAdd, MdOutlineAdd,
MdOutlineArchive, MdOutlineArchive,
MdOutlineArticle, MdOutlineArticle,
MdOutlineCloud,
MdOutlineComment, MdOutlineComment,
MdOutlineContentCopy,
MdOutlineDataset, MdOutlineDataset,
MdOutlineDelete, MdOutlineDelete,
MdOutlineDownload, MdOutlineDownload,
@@ -63,6 +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 Gallery from "../components/gallery.tsx";
export default function ResourcePage() { export default function ResourcePage() {
const params = useParams(); const params = useParams();
@@ -188,20 +194,34 @@ export default function ResourcePage() {
return ( return (
<context.Provider value={reload}> <context.Provider value={reload}>
<div className={"pt-2"}> <div className={"pt-2"}>
<div className="flex">
<div className="flex-1">
<h1 className={"text-2xl font-bold px-4 py-2"}>{resource.title}</h1> <h1 className={"text-2xl font-bold px-4 py-2"}>{resource.title}</h1>
{resource.alternativeTitles.map((e, i) => { {resource.alternativeTitles && resource.alternativeTitles.map((e, i) => {
return ( return (
<h2 <h2
key={i} key={i}
className={"text-lg px-4 py-1 text-gray-700 dark:text-gray-300"} className={
"text-lg px-4 py-1 text-gray-700 dark:text-gray-300"
}
> >
{e} {e}
</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(`/user/${encodeURIComponent(resource.author.username)}`); navigate(
`/user/${encodeURIComponent(resource.author.username)}`,
);
}} }}
className="border-b-2 mx-4 py-1 cursor-pointer border-transparent hover:border-primary transition-colors duration-200 ease-in-out" className="border-b-2 mx-4 py-1 cursor-pointer border-transparent hover:border-primary transition-colors duration-200 ease-in-out"
> >
@@ -219,7 +239,6 @@ export default function ResourcePage() {
</div> </div>
</button> </button>
<Tags tags={resource.tags} /> <Tags tags={resource.tags} />
<div className={"px-3 mt-2 flex flex-wrap"}> <div className={"px-3 mt-2 flex flex-wrap"}>
{resource.links && {resource.links &&
resource.links.map((l) => { resource.links.map((l) => {
@@ -242,6 +261,15 @@ export default function ResourcePage() {
})} })}
<CollectionDialog rid={resource.id} /> <CollectionDialog rid={resource.id} />
</div> </div>
</div>
<div className="w-96 md:w-md lg:w-lg xl:w-xl p-4 hidden sm:flex items-center justify-center">
<Gallery images={resource.gallery} nsfw={resource.galleryNsfw} />
</div>
</div>
<div className="w-full p-4 flex sm:hidden items-center justify-center">
<Gallery images={resource.gallery} nsfw={resource.galleryNsfw} />
</div>
<div <div
className="tabs tabs-box my-4 mx-2 p-4 shadow" className="tabs tabs-box my-4 mx-2 p-4 shadow"
@@ -337,18 +365,20 @@ function Tags({ tags }: { tags: Tag[] }) {
tagsMap.get(type)?.push(tag); tagsMap.get(type)?.push(tag);
} }
const compactMode = tags.length > 10;
return ( return (
<> <>
{Array.from(tagsMap.entries()).map(([type, tags]) => ( {Array.from(tagsMap.entries()).map(([type, tags]) => (
<p key={type} className={"px-4"}> <p key={type} className={"px-4"}>
<Badge className="shadow-xs" key={type}> <Badge className="shadow-xs mr-0.5" key={type}>
{type == "" ? t("Other") : type} {type == "" ? t("Other") : type}
</Badge> </Badge>
{tags.map((tag) => ( {tags.map((tag) => (
<Badge <Badge
key={tag.name} key={tag.name}
className={ className={
"m-1 cursor-pointer badge-soft badge-primary shadow-xs" `${compactMode ? "m-0.5" : "m-1"} cursor-pointer badge-soft badge-primary shadow-xs`
} }
onClick={() => { onClick={() => {
navigate(`/tag/${encodeURIComponent(tag.name)}`); navigate(`/tag/${encodeURIComponent(tag.name)}`);
@@ -443,10 +473,11 @@ 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]}
@@ -575,6 +606,9 @@ function Article({ resource }: { resource: ResourceDetails }) {
{resource.article.replaceAll("\n", " \n")} {resource.article.replaceAll("\n", " \n")}
</Markdown> </Markdown>
</article> </article>
<div className="border-b border-base-300 h-8"></div>
<Characters characters={resource.characters} />
</>
); );
} }
@@ -730,16 +764,59 @@ function FileTile({ file }: { file: RFile }) {
{file.is_redirect ? t("Redirect") : fileSizeToString(file.size)} {file.is_redirect ? t("Redirect") : fileSizeToString(file.size)}
</Badge> </Badge>
{file.hash && ( {file.hash && (
<>
<Badge <Badge
className={ className={
"badge-soft badge-accent text-xs mr-2 break-all hidden sm:inline-flex" "badge-soft badge-accent text-xs mr-2 break-all hover:shadow-xs cursor-pointer transition-shadow"
} }
selectable={true} onClick={() => {
const dialog = document.getElementById(
`file_md5_${file.id}`,
) as HTMLDialogElement;
dialog.showModal();
}}
> >
<MdOutlineVerifiedUser size={16} className={"inline-block"} /> <MdOutlineVerifiedUser size={16} className={"inline-block"} />
Md5: {file.hash} Md5
</Badge>
<dialog id={`file_md5_${file.id}`} className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg mb-4">Md5</h3>
<label className="input input-primary w-full">
<input type="text" readOnly value={file.hash} />
<button
className="btn btn-square btn-ghost btn-sm"
onClick={() => {
navigator.clipboard.writeText(file.hash!);
}}
>
<MdOutlineContentCopy size={18} />
</button>
</label>
<div className="modal-action">
<form method="dialog">
<button className="btn">Close</button>
</form>
</div>
</div>
</dialog>
</>
)}
{file.storage_name && (
<Badge className={"badge-soft badge-info text-xs mr-2"}>
<MdOutlineCloud size={16} className={"inline-block"} />
{file.storage_name}
</Badge> </Badge>
)} )}
{file.tag && (
<Badge className={"badge-soft badge-warning text-xs mr-2"}>
{file.tag}
</Badge>
)}
<Badge className={"badge-soft badge-info text-xs mr-2"}>
<MdOutlineAccessTime size={16} className={"inline-block"} />
{new Date(file.created_at * 1000).toISOString().substring(0, 10)}
</Badge>
<DeleteFileDialog fileId={file.id} uploaderId={file.user.id} /> <DeleteFileDialog fileId={file.id} uploaderId={file.user.id} />
<UpdateFileInfoDialog file={file} /> <UpdateFileInfoDialog file={file} />
</p> </p>
@@ -809,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 (
@@ -818,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!}
@@ -832,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>
); );
} }
@@ -854,11 +947,72 @@ function Files({
files: RFile[]; files: RFile[];
resource: ResourceDetails; resource: ResourceDetails;
}) { }) {
const { t } = useTranslation();
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set());
// Extract unique tags from all files
const allTags = useMemo(() => {
const tags = new Set<string>();
files.forEach((file) => {
if (file.tag) {
tags.add(file.tag);
}
});
return Array.from(tags).sort();
}, [files]);
// Filter files based on selected tags
const filteredFiles = useMemo(() => {
if (selectedTags.size === 0) {
return files;
}
return files.filter((file) => file.tag && selectedTags.has(file.tag));
}, [files, selectedTags]);
const toggleTag = (tag: string) => {
setSelectedTags((prev) => {
const newSet = new Set(prev);
if (newSet.has(tag)) {
newSet.delete(tag);
} else {
newSet.add(tag);
}
return newSet;
});
};
return ( return (
<div className={"pt-3"}> <div className={"pt-3"}>
{files.map((file) => { {allTags.length > 0 && (
<form className="filter mb-4">
{allTags.map((tag) => (
<input
key={tag}
className="btn"
type="checkbox"
aria-label={tag}
checked={selectedTags.has(tag)}
onChange={() => toggleTag(tag)}
/>
))}
{selectedTags.size > 0 && (
<input
className="btn btn-square"
type="reset"
value="×"
onClick={() => setSelectedTags(new Set())}
/>
)}
</form>
)}
{filteredFiles.map((file) => {
return <FileTile file={file} key={file.id}></FileTile>; return <FileTile file={file} key={file.id}></FileTile>;
})} })}
{filteredFiles.length === 0 && selectedTags.size > 0 && (
<div className="text-center text-base-content/60 py-8">
{t("No files match the selected tags")}
</div>
)}
<div className={"h-2"}></div> <div className={"h-2"}></div>
{(app.canUpload() || (app.allowNormalUserUpload && app.isLoggedIn())) && ( {(app.canUpload() || (app.allowNormalUserUpload && app.isLoggedIn())) && (
<div className={"flex flex-row-reverse"}> <div className={"flex flex-row-reverse"}>
@@ -889,6 +1043,10 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
const [storage, setStorage] = useState<Storage | null>(null); const [storage, setStorage] = useState<Storage | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [description, setDescription] = useState<string>(""); const [description, setDescription] = useState<string>("");
const [tag, setTag] = useState<string>("");
const [fileSize, setFileSize] = useState<string>("");
const [fileSizeUnit, setFileSizeUnit] = useState<string>("MB");
const [md5, setMd5] = useState<string>("");
const [fileUrl, setFileUrl] = useState<string>(""); const [fileUrl, setFileUrl] = useState<string>("");
@@ -920,11 +1078,38 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
setSubmitting(false); setSubmitting(false);
return; return;
} }
let fileSizeNum = 0;
if (fileSize) {
const size = parseFloat(fileSize);
if (isNaN(size)) {
setError(t("File size must be a number"));
setSubmitting(false);
return;
}
// Convert to bytes based on unit
switch (fileSizeUnit) {
case "B":
fileSizeNum = size;
break;
case "KB":
fileSizeNum = size * 1024;
break;
case "MB":
fileSizeNum = size * 1024 * 1024;
break;
case "GB":
fileSizeNum = size * 1024 * 1024 * 1024;
break;
}
}
const res = await network.createRedirectFile( const res = await network.createRedirectFile(
filename, filename,
description, description,
resourceId, resourceId,
redirectUrl, redirectUrl,
fileSizeNum,
md5,
tag,
); );
if (res.success) { if (res.success) {
setSubmitting(false); setSubmitting(false);
@@ -949,6 +1134,7 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
resourceId, resourceId,
storage.id, storage.id,
description, description,
tag,
() => { () => {
if (mounted.current) { if (mounted.current) {
reload(); reload();
@@ -981,6 +1167,7 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
description, description,
resourceId, resourceId,
storage.id, storage.id,
tag,
); );
if (res.success) { if (res.success) {
setSubmitting(false); setSubmitting(false);
@@ -1054,15 +1241,7 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
<p className={"text-sm font-bold p-2"}>{t("Type")}</p> <p className={"text-sm font-bold p-2"}>{t("Type")}</p>
<form className="filter mb-2"> <form className="filter mb-2">
<input <input
className="btn btn-square" className="btn"
type="reset"
value="×"
onClick={() => {
setFileType(null);
}}
/>
<input
className="btn text-sm"
type="radio" type="radio"
name="type" name="type"
aria-label={t("Redirect")} aria-label={t("Redirect")}
@@ -1071,7 +1250,7 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
}} }}
/> />
<input <input
className="btn text-sm" className="btn"
type="radio" type="radio"
name="type" name="type"
aria-label={t("Upload")} aria-label={t("Upload")}
@@ -1080,7 +1259,7 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
}} }}
/> />
<input <input
className="btn text-sm" className="btn"
type="radio" type="radio"
name="type" name="type"
aria-label={t("File Url")} aria-label={t("File Url")}
@@ -1088,6 +1267,14 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
setFileType(FileType.serverTask); setFileType(FileType.serverTask);
}} }}
/> />
<input
className="btn btn-square"
type="reset"
value="×"
onClick={() => {
setFileType(null);
}}
/>
</form> </form>
{fileType === FileType.redirect && ( {fileType === FileType.redirect && (
@@ -1118,6 +1305,45 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
setDescription(e.target.value); setDescription(e.target.value);
}} }}
/> />
<input
type="text"
className="input w-full my-2"
placeholder={t("Tag") + " (" + t("Optional") + ")"}
onChange={(e) => {
setTag(e.target.value);
}}
/>
<div className="join w-full">
<input
type="number"
className="input flex-1 join-item"
placeholder={t("File Size") + " (" + t("Optional") + ")"}
value={fileSize}
onChange={(e) => {
setFileSize(e.target.value);
}}
/>
<select
className="select w-24 join-item"
value={fileSizeUnit}
onChange={(e) => {
setFileSizeUnit(e.target.value);
}}
>
<option value="B">B</option>
<option value="KB">KB</option>
<option value="MB">MB</option>
<option value="GB">GB</option>
</select>
</div>
<input
type="text"
className="input w-full my-2"
placeholder={"MD5" + " (" + t("Optional") + ")"}
onChange={(e) => {
setMd5(e.target.value);
}}
/>
</> </>
)} )}
@@ -1174,6 +1400,14 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
setDescription(e.target.value); setDescription(e.target.value);
}} }}
/> />
<input
type="text"
className="input w-full my-2"
placeholder={t("Tag") + " (" + t("Optional") + ")"}
onChange={(e) => {
setTag(e.target.value);
}}
/>
</> </>
)} )}
@@ -1246,6 +1480,14 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
setDescription(e.target.value); setDescription(e.target.value);
}} }}
/> />
<input
type="text"
className="input w-full my-2"
placeholder={t("Tag") + " (" + t("Optional") + ")"}
onChange={(e) => {
setTag(e.target.value);
}}
/>
</> </>
)} )}
@@ -1275,6 +1517,8 @@ function UpdateFileInfoDialog({ file }: { file: RFile }) {
const [description, setDescription] = useState(file.description); const [description, setDescription] = useState(file.description);
const [tag, setTag] = useState(file.tag || "");
const { t } = useTranslation(); const { t } = useTranslation();
const reload = useContext(context); const reload = useContext(context);
@@ -1284,7 +1528,7 @@ function UpdateFileInfoDialog({ file }: { file: RFile }) {
return; return;
} }
setLoading(true); setLoading(true);
const res = await network.updateFile(file.id, filename, description); const res = await network.updateFile(file.id, filename, description, tag);
const dialog = document.getElementById( const dialog = document.getElementById(
`update_file_info_dialog_${file.id}`, `update_file_info_dialog_${file.id}`,
) as HTMLDialogElement; ) as HTMLDialogElement;
@@ -1332,6 +1576,12 @@ function UpdateFileInfoDialog({ file }: { file: RFile }) {
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
/> />
<Input
type={"text"}
label={t("Tag") + " (" + t("Optional") + ")"}
value={tag}
onChange={(e) => setTag(e.target.value)}
/>
<div className="modal-action"> <div className="modal-action">
<form method="dialog"> <form method="dialog">
<button className="btn btn-ghost">{t("Close")}</button> <button className="btn btn-ghost">{t("Close")}</button>
@@ -1820,3 +2070,72 @@ function CollectionSelector({
</div> </div>
); );
} }
function Characters({ characters }: { characters: CharacterParams[] }) {
const { t } = useTranslation();
let main = characters.filter((c) => c.role === "primary");
let other1 = characters.filter((c) => c.role !== "primary" && c.image);
let other2 = characters.filter((c) => c.role !== "primary" && !c.image);
characters = [...main, ...other1, ...other2];
if (!characters || characters.length === 0) {
return <></>;
}
return (
<div className="mt-8">
<h3 className="text-xl font-bold mb-4">{t("Characters")}</h3>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
{characters.map((character, index) => (
<CharacterCard key={index} character={character} />
))}
</div>
</div>
);
}
function CharacterCard({ character }: { character: CharacterParams }) {
const navigate = useNavigate();
const handleCVClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (character.cv) {
navigate(`/search?keyword=${encodeURIComponent(character.cv)}`);
}
};
return (
<div className="group relative aspect-[3/4] overflow-hidden rounded-lg bg-base-200 shadow-sm">
<img
src={character.image ? network.getImageUrl(character.image) : "/cp.webp"}
alt={character.name}
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">
<h4 className="font-semibold text-sm leading-tight line-clamp border border-transparent">
{character.name}
{
character.role === "primary" ? (
<span className="bg-primary/80 rounded-lg px-2 py-0.5 text-primary-content ml-1" style={{
fontSize: "10px",
}}>
Main
</span>
) : null
}
</h4>
{character.cv && (
<button
onClick={handleCVClick}
className="hover:bg-base-200/80 border border-transparent hover:border-base-300/50 rounded-sm text-xs transition-colors cursor-pointer"
>
CV: {character.cv}
</button>
)}
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { createContext, useContext, useMemo } from "react"; import { createContext, useContext, useMemo } from "react";
function t(data: any, language: string) { function t(data: any, language: string) {
return (key: string) => { return (key: string): string => {
return data[language]?.["translation"]?.[key] || key; return data[language]?.["translation"]?.[key] || key;
}; };
} }

View File

@@ -8,8 +8,8 @@ export default defineConfig({
server: { server: {
proxy: { proxy: {
"/api": { "/api": {
target: "http://localhost:3000", // target: "http://localhost:3000",
// target: "https://res.nyne.dev", target: "https://nysoure.com",
changeOrigin: true, changeOrigin: true,
}, },
"https://www.moyu.moe": { "https://www.moyu.moe": {

18
go.mod
View File

@@ -14,13 +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/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
@@ -30,17 +34,28 @@ require (
github.com/blevesearch/zap/v13 v13.0.6 // indirect github.com/blevesearch/zap/v13 v13.0.6 // indirect
github.com/blevesearch/zap/v14 v14.0.5 // indirect github.com/blevesearch/zap/v14 v14.0.5 // indirect
github.com/blevesearch/zap/v15 v15.0.3 // indirect github.com/blevesearch/zap/v15 v15.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/couchbase/vellum v1.0.2 // indirect github.com/couchbase/vellum v1.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // 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
) )
@@ -69,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

56
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=
@@ -29,6 +31,12 @@ github.com/blevesearch/zap/v14 v14.0.5 h1:NdcT+81Nvmp2zL+NhwSvGSLh7xNgGL8QRVZ67n
github.com/blevesearch/zap/v14 v14.0.5/go.mod h1:bWe8S7tRrSBTIaZ6cLRbgNH4TUDaC9LZSpRGs85AsGY= github.com/blevesearch/zap/v14 v14.0.5/go.mod h1:bWe8S7tRrSBTIaZ6cLRbgNH4TUDaC9LZSpRGs85AsGY=
github.com/blevesearch/zap/v15 v15.0.3 h1:Ylj8Oe+mo0P25tr9iLPp33lN6d4qcztGjaIsP51UxaY= github.com/blevesearch/zap/v15 v15.0.3 h1:Ylj8Oe+mo0P25tr9iLPp33lN6d4qcztGjaIsP51UxaY=
github.com/blevesearch/zap/v15 v15.0.3/go.mod h1:iuwQrImsh1WjWJ0Ue2kBqY83a0rFtJTqfa9fp1rbVVU= github.com/blevesearch/zap/v15 v15.0.3/go.mod h1:iuwQrImsh1WjWJ0Ue2kBqY83a0rFtJTqfa9fp1rbVVU=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko= github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU= github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
@@ -39,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=
@@ -46,6 +55,8 @@ github.com/cznic/strutil v0.0.0-20181122101858-275e90344537/go.mod h1:AHHPPPXTw0
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -75,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=
@@ -95,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=
@@ -107,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=
@@ -125,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=
@@ -134,8 +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/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=
@@ -171,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=
@@ -180,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=
@@ -205,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=

12
main.go
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)
@@ -38,7 +46,9 @@ func main() {
api.AddCommentRoutes(apiG) api.AddCommentRoutes(apiG)
api.AddConfigRoutes(apiG) api.AddConfigRoutes(apiG)
api.AddActivityRoutes(apiG) api.AddActivityRoutes(apiG)
api.AddCollectionRoutes(apiG) // 新增 api.AddCollectionRoutes(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)
}
} }

View File

@@ -61,10 +61,22 @@ func setServerConfig(c fiber.Ctx) error {
}) })
} }
func getStatistics(c fiber.Ctx) error {
s, err := service.GetStatistic()
if err != nil {
return model.NewInternalServerError("Failed to get statistics")
}
return c.JSON(model.Response[*service.Statistic]{
Success: true,
Data: s,
})
}
func AddConfigRoutes(r fiber.Router) { func AddConfigRoutes(r fiber.Router) {
configGroup := r.Group("/config") configGroup := r.Group("/config")
{ {
configGroup.Get("/", getServerConfig) configGroup.Get("/", getServerConfig)
configGroup.Post("/", setServerConfig) configGroup.Post("/", setServerConfig)
configGroup.Get("/statistics", getStatistics)
} }
} }

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"
@@ -43,6 +44,7 @@ func initUpload(c fiber.Ctx) error {
FileSize int64 `json:"file_size"` FileSize int64 `json:"file_size"`
ResourceID uint `json:"resource_id"` ResourceID uint `json:"resource_id"`
StorageID uint `json:"storage_id"` StorageID uint `json:"storage_id"`
Tag string `json:"tag"`
} }
var req InitUploadRequest var req InitUploadRequest
@@ -50,7 +52,10 @@ func initUpload(c fiber.Ctx) error {
return model.NewRequestError("Invalid request parameters") return model.NewRequestError("Invalid request parameters")
} }
result, err := service.CreateUploadingFile(uid, req.Filename, req.Description, req.FileSize, req.ResourceID, req.StorageID) req.Filename = strings.TrimSpace(req.Filename)
req.Tag = strings.TrimSpace(req.Tag)
result, err := service.CreateUploadingFile(uid, req.Filename, req.Description, req.FileSize, req.ResourceID, req.StorageID, req.Tag)
if err != nil { if err != nil {
return err return err
} }
@@ -136,6 +141,9 @@ func createRedirectFile(c fiber.Ctx) error {
Description string `json:"description"` Description string `json:"description"`
ResourceID uint `json:"resource_id"` ResourceID uint `json:"resource_id"`
RedirectURL string `json:"redirect_url"` RedirectURL string `json:"redirect_url"`
FileSize int64 `json:"file_size"`
Md5 string `json:"md5"`
Tag string `json:"tag"`
} }
var req CreateRedirectFileRequest var req CreateRedirectFileRequest
@@ -143,7 +151,11 @@ func createRedirectFile(c fiber.Ctx) error {
return model.NewRequestError("Invalid request parameters") return model.NewRequestError("Invalid request parameters")
} }
result, err := service.CreateRedirectFile(uid, req.Filename, req.Description, req.ResourceID, req.RedirectURL) req.Filename = strings.TrimSpace(req.Filename)
req.Md5 = strings.TrimSpace(req.Md5)
req.Tag = strings.TrimSpace(req.Tag)
result, err := service.CreateRedirectFile(uid, req.Filename, req.Description, req.ResourceID, req.RedirectURL, req.FileSize, req.Md5, req.Tag)
if err != nil { if err != nil {
return err return err
} }
@@ -172,6 +184,7 @@ func updateFile(c fiber.Ctx) error {
type UpdateFileRequest struct { type UpdateFileRequest struct {
Filename string `json:"filename"` Filename string `json:"filename"`
Description string `json:"description"` Description string `json:"description"`
Tag string `json:"tag"`
} }
var req UpdateFileRequest var req UpdateFileRequest
@@ -179,7 +192,10 @@ func updateFile(c fiber.Ctx) error {
return model.NewRequestError("Invalid request parameters") return model.NewRequestError("Invalid request parameters")
} }
result, err := service.UpdateFile(uid, c.Params("id"), req.Filename, req.Description) req.Filename = strings.TrimSpace(req.Filename)
req.Tag = strings.TrimSpace(req.Tag)
result, err := service.UpdateFile(uid, c.Params("id"), req.Filename, req.Description, req.Tag)
if err != nil { if err != nil {
return err return err
} }
@@ -210,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,
@@ -221,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))
} }
@@ -259,13 +292,18 @@ func createServerDownloadTask(c fiber.Ctx) error {
Description string `json:"description"` Description string `json:"description"`
ResourceID uint `json:"resource_id"` ResourceID uint `json:"resource_id"`
StorageID uint `json:"storage_id"` StorageID uint `json:"storage_id"`
Tag string `json:"tag"`
} }
var req InitUploadRequest var req InitUploadRequest
if err := c.Bind().Body(&req); err != nil { if err := c.Bind().Body(&req); err != nil {
return model.NewRequestError("Invalid request parameters") return model.NewRequestError("Invalid request parameters")
} }
result, err := service.CreateServerDownloadTask(uid, req.Url, req.Filename, req.Description, req.ResourceID, req.StorageID)
req.Filename = strings.TrimSpace(req.Filename)
req.Tag = strings.TrimSpace(req.Tag)
result, err := service.CreateServerDownloadTask(uid, req.Url, req.Filename, req.Description, req.ResourceID, req.StorageID, req.Tag)
if err != nil { if err != nil {
return err return err
} }

127
server/api/proxy.go Normal file
View File

@@ -0,0 +1,127 @@
package api
import (
"encoding/base64"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/url"
"nysoure/server/cache"
"nysoure/server/model"
"os"
"regexp"
"strings"
"time"
"github.com/gofiber/fiber/v3"
)
var (
allowedUrlRegexps []*regexp.Regexp
)
type proxyResponse struct {
Content string `json:"content"`
ContentType string `json:"content_type"`
StatusCode int `json:"status_code"`
}
func init() {
regexps := os.Getenv("ALLOWED_URL_REGEXPS")
for _, expr := range strings.Split(regexps, ",") {
if expr == "" {
continue
}
re, err := regexp.Compile(expr)
if err != nil {
panic("Invalid regex in ALLOWED_URL_REGEXPS: " + expr)
}
allowedUrlRegexps = append(allowedUrlRegexps, re)
}
}
func handleProxyCall(c fiber.Ctx) error {
uriBase64 := c.Query("uri")
if uriBase64 == "" {
return model.NewRequestError("Missing uri parameter")
}
uriStr, err := base64.URLEncoding.DecodeString(uriBase64)
if err != nil {
return model.NewRequestError("Invalid base64 encoding")
}
uri, err := url.Parse(string(uriStr))
if err != nil {
return model.NewRequestError("Invalid URL")
}
allowed := false
for _, re := range allowedUrlRegexps {
if re.MatchString(uri.String()) {
allowed = true
break
}
}
if !allowed {
return model.NewRequestError("URL not allowed")
}
var resp *proxyResponse
rawVal, err := cache.Get("proxy:" + uri.String())
if err == nil {
var r proxyResponse
err = json.Unmarshal([]byte(rawVal), &r)
if err != nil {
slog.ErrorContext(c, "Failed to unmarshal cached proxy response", "error", err)
return model.NewInternalServerError("Error")
}
resp = &r
} else {
resp, err = proxy(uri)
if err != nil {
slog.ErrorContext(c, "Proxy request failed", "error", err)
return model.NewInternalServerError("Error")
}
}
c.Status(resp.StatusCode)
c.Response().Header.SetContentType(resp.ContentType)
return c.SendString(resp.Content)
}
func proxy(uri *url.URL) (*proxyResponse, error) {
client := http.Client{}
req, err := http.NewRequest("GET", uri.String(), nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
contentType := resp.Header.Get("Content-Type")
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
proxyResp := &proxyResponse{
Content: string(data),
ContentType: contentType,
StatusCode: resp.StatusCode,
}
j, err := json.Marshal(proxyResp)
if err != nil {
return nil, err
}
err = cache.Set("proxy:"+uri.String(), string(j), 24*time.Hour)
if err != nil {
slog.Error("Failed to cache proxy response", "error", err)
}
return proxyResp, nil
}
func AddProxyRoutes(router fiber.Router) {
router.Get("/proxy", handleProxyCall)
}

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)
@@ -239,7 +239,7 @@ func handleUpdateResource(c fiber.Ctx) error {
if !ok { if !ok {
return model.NewUnAuthorizedError("You must be logged in to update a resource") return model.NewUnAuthorizedError("You must be logged in to update a resource")
} }
err = service.EditResource(uid, uint(id), &params) err = service.UpdateResource(uid, uint(id), &params)
if err != nil { if err != nil {
return err return err
} }
@@ -282,6 +282,230 @@ func handleGetPinnedResources(c fiber.Ctx) error {
}) })
} }
func handleGetCharactersFromVndb(c fiber.Ctx) error {
vnID := c.Query("vnid")
if vnID == "" {
return model.NewRequestError("VNDB ID is required")
}
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 {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[[]service.CharacterParams]{
Success: true,
Data: characters,
Message: "Characters retrieved successfully",
})
}
func handleUpdateCharacterImage(c fiber.Ctx) error {
resourceIdStr := c.Params("resourceId")
characterIdStr := c.Params("characterId")
if resourceIdStr == "" || characterIdStr == "" {
return model.NewRequestError("Resource ID and Character ID are required")
}
resourceId, err := strconv.Atoi(resourceIdStr)
if err != nil {
return model.NewRequestError("Invalid resource ID")
}
characterId, err := strconv.Atoi(characterIdStr)
if err != nil {
return model.NewRequestError("Invalid character ID")
}
var params struct {
ImageID uint `json:"image_id"`
}
body := c.Body()
err = json.Unmarshal(body, &params)
if err != nil {
return model.NewRequestError("Invalid request body")
}
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("You must be logged in to update a character")
}
err = service.UpdateCharacterImage(uid, uint(resourceId), uint(characterId), params.ImageID)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[any]{
Success: true,
Data: nil,
Message: "Character image updated successfully",
})
}
func handleGetLowResolutionCharacters(c fiber.Ctx) error {
pageStr := c.Query("page")
if pageStr == "" {
pageStr = "1"
}
page, err := strconv.Atoi(pageStr)
if err != nil {
return model.NewRequestError("Invalid page number")
}
// 支持自定义页面大小默认50最大1000
pageSizeStr := c.Query("page_size")
if pageSizeStr == "" {
pageSizeStr = "50"
}
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil {
return model.NewRequestError("Invalid page_size parameter")
}
if pageSize > 1000 {
pageSize = 1000 // 限制最大页面大小
}
if pageSize < 1 {
pageSize = 1
}
maxWidthStr := c.Query("max_width")
if maxWidthStr == "" {
maxWidthStr = "800" // 默认最大宽度800px
}
maxWidth, err := strconv.Atoi(maxWidthStr)
if err != nil {
return model.NewRequestError("Invalid max_width parameter")
}
maxHeightStr := c.Query("max_height")
if maxHeightStr == "" {
maxHeightStr = "800" // 默认最大高度800px
}
maxHeight, err := strconv.Atoi(maxHeightStr)
if err != nil {
return model.NewRequestError("Invalid max_height parameter")
}
characters, totalPages, err := service.GetLowResolutionCharacters(page, pageSize, maxWidth, maxHeight)
if err != nil {
return err
}
if characters == nil {
characters = []model.LowResCharacterView{}
}
return c.Status(fiber.StatusOK).JSON(model.PageResponse[model.LowResCharacterView]{
Success: true,
Data: characters,
TotalPages: totalPages,
Message: "Low resolution characters retrieved successfully",
})
}
func handleGetLowResolutionResourceImages(c fiber.Ctx) error {
pageStr := c.Query("page")
if pageStr == "" {
pageStr = "1"
}
page, err := strconv.Atoi(pageStr)
if err != nil {
return model.NewRequestError("Invalid page number")
}
// 支持自定义页面大小默认50最大1000
pageSizeStr := c.Query("page_size")
if pageSizeStr == "" {
pageSizeStr = "50"
}
pageSize, err := strconv.Atoi(pageSizeStr)
if err != nil {
return model.NewRequestError("Invalid page_size parameter")
}
if pageSize > 1000 {
pageSize = 1000 // 限制最大页面大小
}
if pageSize < 1 {
pageSize = 1
}
maxWidthStr := c.Query("max_width")
if maxWidthStr == "" {
maxWidthStr = "800" // 默认最大宽度800px
}
maxWidth, err := strconv.Atoi(maxWidthStr)
if err != nil {
return model.NewRequestError("Invalid max_width parameter")
}
maxHeightStr := c.Query("max_height")
if maxHeightStr == "" {
maxHeightStr = "800" // 默认最大高度800px
}
maxHeight, err := strconv.Atoi(maxHeightStr)
if err != nil {
return model.NewRequestError("Invalid max_height parameter")
}
images, totalPages, err := service.GetLowResolutionResourceImages(page, pageSize, maxWidth, maxHeight)
if err != nil {
return err
}
if images == nil {
images = []model.LowResResourceImageView{}
}
return c.Status(fiber.StatusOK).JSON(model.PageResponse[model.LowResResourceImageView]{
Success: true,
Data: images,
TotalPages: totalPages,
Message: "Low resolution resource images retrieved successfully",
})
}
func handleUpdateResourceImage(c fiber.Ctx) error {
resourceIdStr := c.Params("resourceId")
oldImageIdStr := c.Params("oldImageId")
if resourceIdStr == "" || oldImageIdStr == "" {
return model.NewRequestError("Resource ID and Old Image ID are required")
}
resourceId, err := strconv.Atoi(resourceIdStr)
if err != nil {
return model.NewRequestError("Invalid resource ID")
}
oldImageId, err := strconv.Atoi(oldImageIdStr)
if err != nil {
return model.NewRequestError("Invalid old image ID")
}
var params struct {
NewImageID uint `json:"new_image_id"`
}
body := c.Body()
err = json.Unmarshal(body, &params)
if err != nil {
return model.NewRequestError("Invalid request body")
}
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("You must be logged in to update a resource image")
}
err = service.UpdateResourceImage(uid, uint(resourceId), uint(oldImageId), params.NewImageID)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[any]{
Success: true,
Data: nil,
Message: "Resource image updated successfully",
})
}
func AddResourceRoutes(api fiber.Router) { func AddResourceRoutes(api fiber.Router) {
resource := api.Group("/resource") resource := api.Group("/resource")
{ {
@@ -290,10 +514,15 @@ func AddResourceRoutes(api fiber.Router) {
resource.Get("/", handleListResources) resource.Get("/", handleListResources)
resource.Get("/random", handleGetRandomResource) resource.Get("/random", handleGetRandomResource)
resource.Get("/pinned", handleGetPinnedResources) resource.Get("/pinned", handleGetPinnedResources)
resource.Get("/vndb/characters", handleGetCharactersFromVndb)
resource.Get("/characters/low-resolution", handleGetLowResolutionCharacters)
resource.Get("/images/low-resolution", handleGetLowResolutionResourceImages)
resource.Get("/:id", handleGetResource) resource.Get("/:id", handleGetResource)
resource.Delete("/:id", handleDeleteResource) resource.Delete("/:id", handleDeleteResource)
resource.Get("/tag/:tag", handleListResourcesWithTag) resource.Get("/tag/:tag", handleListResourcesWithTag)
resource.Get("/user/:username", handleGetResourcesWithUser) resource.Get("/user/:username", handleGetResourcesWithUser)
resource.Post("/:id", handleUpdateResource) resource.Post("/:id", handleUpdateResource)
resource.Put("/:resourceId/character/:characterId/image", handleUpdateCharacterImage)
resource.Put("/:resourceId/image/:oldImageId", handleUpdateResourceImage)
} }
} }

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,

42
server/cache/cache.go vendored Normal file
View File

@@ -0,0 +1,42 @@
package cache
import (
"context"
"os"
"time"
"github.com/redis/go-redis/v9"
)
var (
client *redis.Client
ctx = context.Background()
)
func init() {
host := os.Getenv("REDIS_HOST")
port := os.Getenv("REDIS_PORT")
if host == "" {
host = "localhost"
}
if port == "" {
port = "6379"
}
client = redis.NewClient(&redis.Options{
Addr: host + ":" + port,
})
}
func Get(key string) (string, error) {
val, err := client.Get(ctx, key).Result()
if err != nil {
return "", err
}
return val, nil
}
func Set(key, value string, expiration time.Duration) error {
return client.Set(ctx, key, value, expiration).Err()
}

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 {
return db.Transaction(func(tx *gorm.DB) error {
activity := &model.Activity{ activity := &model.Activity{
UserID: userID, UserID: userID,
Type: model.ActivityTypeNewComment, Type: model.ActivityTypeNewComment,
RefID: commentID, RefID: commentID,
NotifyTo: notifyTo,
} }
return db.Create(activity).Error 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

@@ -179,9 +179,15 @@ func DeleteCommentByID(commentID uint) error {
if err := tx.Model(&model.User{}).Where("id = ?", comment.UserID).Update("comments_count", gorm.Expr("comments_count - 1")).Error; err != nil { if err := tx.Model(&model.User{}).Where("id = ?", comment.UserID).Update("comments_count", gorm.Expr("comments_count - 1")).Error; err != nil {
return err return err
} }
if comment.Type == model.CommentTypeResource {
if err := tx.Model(&model.Resource{}).Where("id = ?", comment.RefID).Update("comments", gorm.Expr("comments - 1")).Error; err != nil { if err := tx.Model(&model.Resource{}).Where("id = ?", comment.RefID).Update("comments", gorm.Expr("comments - 1")).Error; err != nil {
return err return err
} }
} else if comment.Type == model.CommentTypeReply {
if err := tx.Model(&model.Comment{}).Where("id = ?", comment.RefID).Update("reply_count", gorm.Expr("reply_count - 1")).Error; err != nil {
return err
}
}
if err := tx. if err := tx.
Where("type = ? and ref_id = ?", model.ActivityTypeNewComment, commentID). Where("type = ? and ref_id = ?", model.ActivityTypeNewComment, commentID).
Delete(&model.Activity{}). Delete(&model.Activity{}).

View File

@@ -60,6 +60,7 @@ func init() {
&model.Activity{}, &model.Activity{},
&model.Collection{}, &model.Collection{},
&model.CollectionResource{}, &model.CollectionResource{},
&model.Character{},
) )
} }

View File

@@ -10,7 +10,7 @@ import (
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
func CreateUploadingFile(filename string, description string, fileSize int64, blockSize int64, tempPath string, resourceID, storageID, userID uint) (*model.UploadingFile, error) { func CreateUploadingFile(filename string, description string, fileSize int64, blockSize int64, tempPath string, resourceID, storageID, userID uint, tag string) (*model.UploadingFile, error) {
blocksCount := (fileSize + blockSize - 1) / blockSize blocksCount := (fileSize + blockSize - 1) / blockSize
uf := &model.UploadingFile{ uf := &model.UploadingFile{
Filename: filename, Filename: filename,
@@ -22,6 +22,7 @@ func CreateUploadingFile(filename string, description string, fileSize int64, bl
TargetResourceID: resourceID, TargetResourceID: resourceID,
TargetStorageID: storageID, TargetStorageID: storageID,
UserID: userID, UserID: userID,
Tag: tag,
} }
if err := db.Create(uf).Error; err != nil { if err := db.Create(uf).Error; err != nil {
return nil, err return nil, err
@@ -73,7 +74,7 @@ func GetUploadingFilesOlderThan(time time.Time) ([]model.UploadingFile, error) {
return files, nil return files, nil
} }
func CreateFile(filename string, description string, resourceID uint, storageID *uint, storageKey string, redirectUrl string, size int64, userID uint, hash string) (*model.File, error) { func CreateFile(filename string, description string, resourceID uint, storageID *uint, storageKey string, redirectUrl string, size int64, userID uint, hash string, tag string) (*model.File, error) {
if storageID == nil && redirectUrl == "" { if storageID == nil && redirectUrl == "" {
return nil, errors.New("storageID and redirectUrl cannot be both empty") return nil, errors.New("storageID and redirectUrl cannot be both empty")
} }
@@ -89,6 +90,7 @@ func CreateFile(filename string, description string, resourceID uint, storageID
Size: size, Size: size,
UserID: userID, UserID: userID,
Hash: hash, Hash: hash,
Tag: tag,
} }
err := db.Transaction(func(tx *gorm.DB) error { err := db.Transaction(func(tx *gorm.DB) error {
@@ -171,7 +173,7 @@ func DeleteFile(id string) error {
return nil return nil
} }
func UpdateFile(id string, filename string, description string) (*model.File, error) { func UpdateFile(id string, filename string, description string, tag string) (*model.File, error) {
f := &model.File{} f := &model.File{}
if err := db.Where("uuid = ?", id).First(f).Error; err != nil { if err := db.Where("uuid = ?", id).First(f).Error; err != nil {
return nil, err return nil, err
@@ -182,6 +184,9 @@ func UpdateFile(id string, filename string, description string) (*model.File, er
if description != "" { if description != "" {
f.Description = description f.Description = description
} }
if tag != "" {
f.Tag = tag
}
if err := db.Save(f).Error; err != nil { if err := db.Save(f).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, model.NewNotFoundError("file not found") return nil, model.NewNotFoundError("file not found")
@@ -239,3 +244,11 @@ func ListUserFiles(userID uint, page, pageSize int) ([]*model.File, int64, error
} }
return files, count, nil return files, count, nil
} }
func CountFiles() (int64, error) {
var count int64
if err := db.Model(&model.File{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

View File

@@ -47,6 +47,7 @@ func GetUnusedImages() ([]model.Image, error) {
Where("NOT EXISTS (SELECT 1 FROM resource_images WHERE image_id = images.id)"). Where("NOT EXISTS (SELECT 1 FROM resource_images WHERE image_id = images.id)").
Where("NOT EXISTS (SELECT 1 FROM comment_images WHERE image_id = images.id)"). Where("NOT EXISTS (SELECT 1 FROM comment_images WHERE image_id = images.id)").
Where("NOT EXISTS (SELECT 1 FROM collection_images WHERE image_id = images.id)"). Where("NOT EXISTS (SELECT 1 FROM collection_images WHERE image_id = images.id)").
Where("NOT EXISTS (SELECT 1 FROM characters WHERE image_id = images.id)").
Where("created_at < ?", oneDayAgo). Where("created_at < ?", oneDayAgo).
Find(&images).Error; err != nil { Find(&images).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {

View File

@@ -2,8 +2,10 @@ package dao
import ( import (
"errors" "errors"
"fmt"
"math/rand" "math/rand"
"nysoure/server/model" "nysoure/server/model"
"strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -16,10 +18,22 @@ import (
func CreateResource(r model.Resource) (model.Resource, error) { func CreateResource(r model.Resource) (model.Resource, error) {
err := db.Transaction(func(tx *gorm.DB) error { err := db.Transaction(func(tx *gorm.DB) error {
r.ModifiedTime = time.Now() r.ModifiedTime = time.Now()
characters := r.Characters
r.Characters = nil
err := tx.Create(&r).Error err := tx.Create(&r).Error
if err != nil { if err != nil {
return err return err
} }
for _, c := range characters {
c.ResourceID = r.ID
// If ImageID is 0, set it to nil to avoid foreign key constraint error
if c.ImageID != nil && *c.ImageID == 0 {
c.ImageID = nil
}
if err := tx.Create(&c).Error; err != nil {
return err
}
}
if err := tx.Model(&model.User{}).Where("id = ?", r.UserID).Update("resources_count", gorm.Expr("resources_count + ?", 1)).Error; err != nil { if err := tx.Model(&model.User{}).Where("id = ?", r.UserID).Update("resources_count", gorm.Expr("resources_count + ?", 1)).Error; err != nil {
return err return err
} }
@@ -41,6 +55,8 @@ func GetResourceByID(id uint) (model.Resource, error) {
}). }).
Preload("Files"). Preload("Files").
Preload("Files.User"). Preload("Files.User").
Preload("Files.Storage").
Preload("Characters").
First(&r, id).Error; err != nil { First(&r, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
return model.Resource{}, model.NewNotFoundError("Resource not found") return model.Resource{}, model.NewNotFoundError("Resource not found")
@@ -83,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
} }
@@ -98,12 +118,19 @@ func GetResourceList(page, pageSize int, sort model.RSort) ([]model.Resource, in
func UpdateResource(r model.Resource) error { func UpdateResource(r model.Resource) error {
// Update a resource in the database // Update a resource in the database
return db.Transaction(func(tx *gorm.DB) error {
images := r.Images images := r.Images
tags := r.Tags tags := r.Tags
characters := r.Characters
r.Characters = nil
r.Images = nil r.Images = nil
r.Tags = nil r.Tags = nil
r.Files = nil r.Files = nil
r.ModifiedTime = time.Now() r.ModifiedTime = time.Now()
oldCharacters := []model.Character{}
if err := db.Model(&model.Character{}).Where("resource_id = ?", r.ID).Find(&oldCharacters).Error; err != nil {
return err
}
if err := db.Save(&r).Error; err != nil { if err := db.Save(&r).Error; err != nil {
return err return err
} }
@@ -113,7 +140,42 @@ func UpdateResource(r model.Resource) error {
if err := db.Model(&r).Association("Tags").Replace(tags); err != nil { if err := db.Model(&r).Association("Tags").Replace(tags); err != nil {
return err return err
} }
for _, c := range oldCharacters {
shouldDelete := true
for _, nc := range characters {
if c.Equal(&nc) {
shouldDelete = false
break
}
}
if shouldDelete {
if err := tx.Delete(&c).Error; err != nil {
return err
}
}
}
for _, c := range characters {
shouldAdd := true
for _, oc := range oldCharacters {
if c.Equal(&oc) {
shouldAdd = false
break
}
}
if shouldAdd {
c.ID = 0
c.ResourceID = r.ID
// If ImageID is 0, set it to nil to avoid foreign key constraint error
if c.ImageID != nil && *c.ImageID == 0 {
c.ImageID = nil
}
if err := tx.Create(&c).Error; err != nil {
return err
}
}
}
return nil return nil
})
} }
func DeleteResource(id uint) error { func DeleteResource(id uint) error {
@@ -461,3 +523,199 @@ func BatchGetResources(ids []uint) ([]model.Resource, error) {
return resources, nil return resources, nil
} }
func CountResources() (int64, error) {
var count int64
if err := db.Model(&model.Resource{}).Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// UpdateCharacterImage 更新角色的图片ID
func UpdateCharacterImage(characterID, imageID uint) error {
var updateValue interface{}
if imageID == 0 {
updateValue = nil
} else {
updateValue = imageID
}
result := db.Model(&model.Character{}).Where("id = ?", characterID).Update("image_id", updateValue)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return model.NewNotFoundError("Character not found")
}
return nil
}
// GetLowResolutionCharacters 获取低清晰度的角色图片
// maxWidth和maxHeight定义了低清晰度的阈值
func GetLowResolutionCharacters(maxWidth, maxHeight int, limit int, offset int) ([]model.LowResCharacterView, error) {
var results []model.LowResCharacterView
query := `
SELECT
c.id as character_id,
c.resource_id as resource_id,
c.name as name,
i.id as image_id,
i.width as image_width,
i.height as image_height
FROM characters c
INNER JOIN images i ON c.image_id = i.id
WHERE (i.width <= ? OR i.height <= ?)
AND c.image_id IS NOT NULL
ORDER BY c.id
LIMIT ? OFFSET ?
`
err := db.Raw(query, maxWidth, maxHeight, limit, offset).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
// GetLowResolutionCharactersCount 获取低清晰度角色的总数
func GetLowResolutionCharactersCount(maxWidth, maxHeight int) (int64, error) {
var count int64
query := `
SELECT COUNT(*)
FROM characters c
INNER JOIN images i ON c.image_id = i.id
WHERE (i.width <= ? OR i.height <= ?)
AND c.image_id IS NOT NULL
`
err := db.Raw(query, maxWidth, maxHeight).Scan(&count).Error
if err != nil {
return 0, err
}
return count, nil
}
// GetLowResolutionResourceImages 获取低清晰度的资源图片
// maxWidth和maxHeight定义了低清晰度的阈值
func GetLowResolutionResourceImages(maxWidth, maxHeight int, limit int, offset int) ([]model.LowResResourceImageView, error) {
var results []model.LowResResourceImageView
query := `
SELECT DISTINCT
r.id as resource_id,
r.title as title,
i.id as image_id,
i.width as image_width,
i.height as image_height
FROM resources r
INNER JOIN resource_images ri ON r.id = ri.resource_id
INNER JOIN images i ON ri.image_id = i.id
WHERE (i.width <= ? OR i.height <= ?)
ORDER BY r.id, i.id
LIMIT ? OFFSET ?
`
err := db.Raw(query, maxWidth, maxHeight, limit, offset).Scan(&results).Error
if err != nil {
return nil, err
}
return results, nil
}
// GetLowResolutionResourceImagesCount 获取低清晰度资源图片的总数
func GetLowResolutionResourceImagesCount(maxWidth, maxHeight int) (int64, error) {
var count int64
query := `
SELECT COUNT(DISTINCT ri.resource_id, ri.image_id)
FROM resources r
INNER JOIN resource_images ri ON r.id = ri.resource_id
INNER JOIN images i ON ri.image_id = i.id
WHERE (i.width <= ? OR i.height <= ?)
`
err := db.Raw(query, maxWidth, maxHeight).Scan(&count).Error
if err != nil {
return 0, err
}
return count, nil
}
// UpdateResourceImage 更新资源中特定的图片ID
func UpdateResourceImage(resourceID, oldImageID, newImageID uint) error {
return db.Transaction(func(tx *gorm.DB) error {
// 首先检查关联是否存在
var exists bool
err := tx.Raw("SELECT EXISTS(SELECT 1 FROM resource_images WHERE resource_id = ? AND image_id = ?)",
resourceID, oldImageID).Scan(&exists).Error
if err != nil {
return err
}
if !exists {
return fmt.Errorf("resource %d does not have image %d", resourceID, oldImageID)
}
// 更新resource_images表中的image_id
result := tx.Exec("UPDATE resource_images SET image_id = ? WHERE resource_id = ? AND image_id = ?",
newImageID, resourceID, oldImageID)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("no resource image association updated")
}
// 更新资源描述中的图片引用
var resource model.Resource
if err := tx.Select("article").Where("id = ?", resourceID).First(&resource).Error; err != nil {
return err
}
// 替换描述中的图片引用
oldImageRef := fmt.Sprintf("/api/image/%d)", oldImageID)
newImageRef := fmt.Sprintf("/api/image/%d)", newImageID)
// 使用字符串替换更新文章内容
updatedArticle := strings.ReplaceAll(resource.Article, oldImageRef, newImageRef)
// 如果文章内容有变化,更新数据库
if updatedArticle != resource.Article {
if err := tx.Model(&model.Resource{}).Where("id = ?", resourceID).Update("article", updatedArticle).Error; err != nil {
return err
}
}
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,64 +12,14 @@ 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]{
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 != "" {
return c.Status(fiberErr.Code).JSON(model.Response[any]{ return c.Status(fiberErr.Code).JSON(model.Response[any]{
Success: false, Success: false,
Data: nil, Data: nil,
Message: fiberErr.Message, 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) log.Error("Internal Server Error: ", err)
@@ -80,6 +29,5 @@ func ErrorHandler(c fiber.Ctx) error {
Message: "Internal server error", 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")
@@ -158,10 +163,12 @@ func serveIndexHtml(c fiber.Ctx) error {
} else if path == "/" || path == "" { } else if path == "/" || path == "" {
pinned, err := service.GetPinnedResources() pinned, err := service.GetPinnedResources()
random, err1 := service.RandomCover() random, err1 := service.RandomCover()
if err == nil && err1 == nil { statistic, err2 := service.GetStatistic()
if err == nil && err1 == nil && err2 == nil {
preFetchDataJson, _ := json.Marshal(map[string]interface{}{ preFetchDataJson, _ := json.Marshal(map[string]interface{}{
"pinned": pinned, "pinned": pinned,
"background": random, "background": random,
"statistic": statistic,
}) })
preFetchData = url.PathEscape(string(preFetchDataJson)) preFetchData = url.PathEscape(string(preFetchDataJson))
} }
@@ -169,6 +176,7 @@ func serveIndexHtml(c fiber.Ctx) error {
content = strings.ReplaceAll(content, "{{SiteName}}", siteName) content = strings.ReplaceAll(content, "{{SiteName}}", siteName)
content = strings.ReplaceAll(content, "{{Description}}", description) content = strings.ReplaceAll(content, "{{Description}}", description)
content = strings.ReplaceAll(content, "{{SiteDescription}}", config.ServerDescription())
content = strings.ReplaceAll(content, "{{Preview}}", preview) content = strings.ReplaceAll(content, "{{Preview}}", preview)
content = strings.ReplaceAll(content, "{{Title}}", title) content = strings.ReplaceAll(content, "{{Title}}", title)
content = strings.ReplaceAll(content, "{{Url}}", htmlUrl) content = strings.ReplaceAll(content, "{{Url}}", htmlUrl)

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

@@ -21,6 +21,7 @@ type Activity struct {
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 {

67
server/model/character.go Normal file
View File

@@ -0,0 +1,67 @@
package model
type Character struct {
ID uint `gorm:"primaryKey;autoIncrement"`
Name string `gorm:"type:varchar(100);not null"`
Alias []string `gorm:"serializer:json"`
CV string `gorm:"type:varchar(100)"`
Role string `gorm:"type:varchar(20);default:primary"`
ImageID *uint
ResourceID uint
Image *Image `gorm:"foreignKey:ImageID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
}
type CharacterView struct {
Id uint `json:"id"`
Name string `json:"name"`
Alias []string `json:"alias"`
CV string `json:"cv"`
Role string `json:"role"`
Image uint `json:"image"`
}
type LowResCharacterView struct {
CharacterID uint `json:"character_id"`
ResourceID uint `json:"resource_id"`
Name string `json:"name"`
ImageID uint `json:"image_id"`
ImageWidth int `json:"image_width"`
ImageHeight int `json:"image_height"`
}
func (c *Character) ToView() *CharacterView {
var imageID uint
if c.ImageID != nil {
imageID = *c.ImageID
}
return &CharacterView{
Id: c.ID,
Name: c.Name,
Alias: c.Alias,
CV: c.CV,
Role: c.Role,
Image: imageID,
}
}
func (c *Character) Equal(other *Character) bool {
if c.Name != other.Name || c.CV != other.CV || c.Role != other.Role {
return false
}
// Compare ImageID pointers
if (c.ImageID == nil) != (other.ImageID == nil) {
return false
}
if c.ImageID != nil && other.ImageID != nil && *c.ImageID != *other.ImageID {
return false
}
if len(c.Alias) != len(other.Alias) {
return false
}
for i := range c.Alias {
if c.Alias[i] != other.Alias[i] {
return false
}
}
return true
}

View File

@@ -32,6 +32,7 @@ type CommentView struct {
Images []ImageView `json:"images"` Images []ImageView `json:"images"`
ReplyCount uint `json:"reply_count"` ReplyCount uint `json:"reply_count"`
ContentTruncated bool `json:"content_truncated"` ContentTruncated bool `json:"content_truncated"`
Replies []CommentView `json:"replies,omitempty"`
} }
func (c *Comment) ToView() *CommentView { func (c *Comment) ToView() *CommentView {

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

@@ -19,6 +19,7 @@ type File struct {
User User `gorm:"foreignKey:UserID"` User User `gorm:"foreignKey:UserID"`
Size int64 Size int64
Hash string `gorm:"default:null"` Hash string `gorm:"default:null"`
Tag string `gorm:"type:text;default:null"`
} }
type FileView struct { type FileView struct {
@@ -30,6 +31,9 @@ type FileView struct {
User UserView `json:"user"` User UserView `json:"user"`
Resource *ResourceView `json:"resource,omitempty"` Resource *ResourceView `json:"resource,omitempty"`
Hash string `json:"hash,omitempty"` Hash string `json:"hash,omitempty"`
StorageName string `json:"storage_name,omitempty"`
CreatedAt int64 `json:"created_at,omitempty"`
Tag string `json:"tag,omitempty"`
} }
func (f *File) ToView() *FileView { func (f *File) ToView() *FileView {
@@ -41,6 +45,9 @@ func (f *File) ToView() *FileView {
IsRedirect: f.RedirectUrl != "", IsRedirect: f.RedirectUrl != "",
User: f.User.ToView(), User: f.User.ToView(),
Hash: f.Hash, Hash: f.Hash,
StorageName: f.Storage.Name,
CreatedAt: f.CreatedAt.Unix(),
Tag: f.Tag,
} }
} }
@@ -60,5 +67,6 @@ func (f *File) ToViewWithResource() *FileView {
User: f.User.ToView(), User: f.User.ToView(),
Resource: resource, Resource: resource,
Hash: f.Hash, Hash: f.Hash,
Tag: f.Tag,
} }
} }

View File

@@ -11,8 +11,10 @@ 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;"`
CoverID *uint
Tags []Tag `gorm:"many2many:resource_tags;"` Tags []Tag `gorm:"many2many:resource_tags;"`
Files []File `gorm:"foreignKey:ResourceID"` Files []File `gorm:"foreignKey:ResourceID"`
UserID uint UserID uint
@@ -21,6 +23,9 @@ type Resource struct {
Downloads uint Downloads uint
Comments uint Comments uint
ModifiedTime time.Time ModifiedTime time.Time
Gallery []uint `gorm:"serializer:json"`
GalleryNsfw []uint `gorm:"serializer:json"`
Characters []Character `gorm:"foreignKey:ResourceID"`
} }
type Link struct { type Link struct {
@@ -32,6 +37,7 @@ 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"`
ReleaseDate *time.Time `json:"release_date,omitempty"`
Tags []TagView `json:"tags"` Tags []TagView `json:"tags"`
Image *ImageView `json:"image"` Image *ImageView `json:"image"`
Author UserView `json:"author"` Author UserView `json:"author"`
@@ -44,14 +50,27 @@ 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"`
Downloads uint `json:"downloads"` Downloads uint `json:"downloads"`
Comments uint `json:"comments"` Comments uint `json:"comments"`
Related []ResourceView `json:"related"` Related []ResourceView `json:"related"`
Gallery []uint `json:"gallery"`
GalleryNsfw []uint `json:"galleryNsfw"`
Characters []CharacterView `json:"characters"`
}
type LowResResourceImageView struct {
ResourceID uint `json:"resource_id"`
Title string `json:"title"`
ImageID uint `json:"image_id"`
ImageWidth int `json:"image_width"`
ImageHeight int `json:"image_height"`
} }
func (r *Resource) ToView() ResourceView { func (r *Resource) ToView() ResourceView {
@@ -61,7 +80,18 @@ 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
} }
@@ -70,6 +100,7 @@ func (r *Resource) ToView() ResourceView {
ID: r.ID, ID: r.ID,
Title: r.Title, Title: r.Title,
CreatedAt: r.CreatedAt, CreatedAt: r.CreatedAt,
ReleaseDate: r.ReleaseDate,
Tags: tags, Tags: tags,
Image: image, Image: image,
Author: r.User.ToView(), Author: r.User.ToView(),
@@ -90,6 +121,10 @@ func (r *Resource) ToDetailView() ResourceDetailView {
for i, file := range r.Files { for i, file := range r.Files {
files[i] = *file.ToView() files[i] = *file.ToView()
} }
characters := make([]CharacterView, len(r.Characters))
for i, character := range r.Characters {
characters[i] = *character.ToView()
}
return ResourceDetailView{ return ResourceDetailView{
ID: r.ID, ID: r.ID,
Title: r.Title, Title: r.Title,
@@ -97,12 +132,17 @@ 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,
Downloads: r.Downloads, Downloads: r.Downloads,
Comments: r.Comments, Comments: r.Comments,
Gallery: r.Gallery,
GalleryNsfw: r.GalleryNsfw,
Characters: characters,
} }
} }

View File

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

View File

@@ -21,6 +21,7 @@ type UploadingFile struct {
TempPath string TempPath string
Resource Resource `gorm:"foreignKey:TargetResourceID"` Resource Resource `gorm:"foreignKey:TargetResourceID"`
Storage Storage `gorm:"foreignKey:TargetStorageID"` Storage Storage `gorm:"foreignKey:TargetStorageID"`
Tag string `gorm:"type:text;default:null"`
} }
func (uf *UploadingFile) BlocksCount() int { func (uf *UploadingFile) BlocksCount() int {

View File

@@ -19,6 +19,7 @@ type User struct {
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)
} }
@@ -122,9 +130,18 @@ func ListResourceComments(resourceID uint, page int) ([]model.CommentView, int,
res := make([]model.CommentView, 0, len(comments)) res := make([]model.CommentView, 0, len(comments))
for _, c := range comments { for _, c := range comments {
v := *c.ToView() v := *c.ToView()
var truncated bool v.Content, v.ContentTruncated = restrictCommentLength(v.Content)
v.Content, truncated = restrictCommentLength(v.Content) replies, _, err := dao.GetCommentReplies(c.ID, 1, 3)
v.ContentTruncated = truncated if err != nil {
log.Error("Error getting replies for comment:", err)
return nil, 0, model.NewInternalServerError("Error getting replies for comment")
}
v.Replies = make([]model.CommentView, 0, len(replies))
for _, r := range replies {
rv := *r.ToView()
rv.Content, rv.ContentTruncated = restrictCommentLength(rv.Content)
v.Replies = append(v.Replies, rv)
}
res = append(res, v) res = append(res, v)
} }
return res, totalPages, nil return res, totalPages, nil

View File

@@ -5,8 +5,10 @@ import (
"context" "context"
"crypto/md5" "crypto/md5"
"encoding/hex" "encoding/hex"
"fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"nysoure/server/config" "nysoure/server/config"
"nysoure/server/dao" "nysoure/server/dao"
"nysoure/server/model" "nysoure/server/model"
@@ -15,6 +17,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -28,6 +31,8 @@ const (
MinUnrequireVerifyFileSize = 10 * 1024 * 1024 // 10MB MinUnrequireVerifyFileSize = 10 * 1024 * 1024 // 10MB
) )
var bannedRedirectDomains []string
func getUploadingSize() int64 { func getUploadingSize() int64 {
return dao.GetStatistic("uploading_size") return dao.GetStatistic("uploading_size")
} }
@@ -46,6 +51,10 @@ func getTempDir() (string, error) {
} }
func init() { func init() {
domains := os.Getenv("BANNED_REDIRECT_DOMAINS")
if domains != "" {
bannedRedirectDomains = strings.Split(domains, ",")
}
go func() { go func() {
// Wait for 1 minute to ensure the database is ready // Wait for 1 minute to ensure the database is ready
time.Sleep(time.Minute) time.Sleep(time.Minute)
@@ -71,7 +80,7 @@ func init() {
}() }()
} }
func CreateUploadingFile(uid uint, filename string, description string, fileSize int64, resourceID, storageID uint) (*model.UploadingFileView, error) { func CreateUploadingFile(uid uint, filename string, description string, fileSize int64, resourceID, storageID uint, tag string) (*model.UploadingFileView, error) {
if filename == "" { if filename == "" {
return nil, model.NewRequestError("filename is empty") return nil, model.NewRequestError("filename is empty")
} }
@@ -104,7 +113,7 @@ func CreateUploadingFile(uid uint, filename string, description string, fileSize
log.Error("failed to create temp dir: ", err) log.Error("failed to create temp dir: ", err)
return nil, model.NewInternalServerError("failed to create temp dir") return nil, model.NewInternalServerError("failed to create temp dir")
} }
uploadingFile, err := dao.CreateUploadingFile(filename, description, fileSize, blockSize, tempPath, resourceID, storageID, uid) uploadingFile, err := dao.CreateUploadingFile(filename, description, fileSize, blockSize, tempPath, resourceID, storageID, uid, tag)
if err != nil { if err != nil {
log.Error("failed to create uploading file: ", err) log.Error("failed to create uploading file: ", err)
_ = os.Remove(tempPath) _ = os.Remove(tempPath)
@@ -236,7 +245,7 @@ func FinishUploadingFile(uid uint, fid uint, md5Str string) (*model.FileView, er
return nil, model.NewInternalServerError("failed to finish uploading file. please re-upload") return nil, model.NewInternalServerError("failed to finish uploading file. please re-upload")
} }
dbFile, err := dao.CreateFile(uploadingFile.Filename, uploadingFile.Description, uploadingFile.TargetResourceID, &uploadingFile.TargetStorageID, storageKeyUnavailable, "", uploadingFile.TotalSize, uid, sumStr) dbFile, err := dao.CreateFile(uploadingFile.Filename, uploadingFile.Description, uploadingFile.TargetResourceID, &uploadingFile.TargetStorageID, storageKeyUnavailable, "", uploadingFile.TotalSize, uid, sumStr, uploadingFile.Tag)
if err != nil { if err != nil {
log.Error("failed to create file in db: ", err) log.Error("failed to create file in db: ", err)
_ = os.Remove(resultFilePath) _ = os.Remove(resultFilePath)
@@ -300,7 +309,17 @@ func CancelUploadingFile(uid uint, fid uint) error {
return nil return nil
} }
func CreateRedirectFile(uid uint, filename string, description string, resourceID uint, redirectUrl string) (*model.FileView, error) { func CreateRedirectFile(uid uint, filename string, description string, resourceID uint, redirectUrl string, fileSize int64, md5 string, tag string) (*model.FileView, error) {
u, err := url.Parse(redirectUrl)
if err != nil {
return nil, model.NewRequestError("URL is not valid")
}
for _, domain := range bannedRedirectDomains {
if u.Host == domain {
return nil, model.NewRequestError(fmt.Sprintf("Domain '%s' is not allowed", domain))
}
}
canUpload, err := checkUserCanUpload(uid) canUpload, err := checkUserCanUpload(uid)
if err != nil { if err != nil {
log.Error("failed to check user permission: ", err) log.Error("failed to check user permission: ", err)
@@ -310,7 +329,7 @@ func CreateRedirectFile(uid uint, filename string, description string, resourceI
return nil, model.NewUnAuthorizedError("user cannot upload file") return nil, model.NewUnAuthorizedError("user cannot upload file")
} }
file, err := dao.CreateFile(filename, description, resourceID, nil, "", redirectUrl, 0, uid, "") file, err := dao.CreateFile(filename, description, resourceID, nil, "", redirectUrl, fileSize, uid, md5, tag)
if err != nil { if err != nil {
log.Error("failed to create file in db: ", err) log.Error("failed to create file in db: ", err)
return nil, model.NewInternalServerError("failed to create file in db") return nil, model.NewInternalServerError("failed to create file in db")
@@ -354,7 +373,7 @@ func DeleteFile(uid uint, fid string) error {
return nil return nil
} }
func UpdateFile(uid uint, fid string, filename string, description string) (*model.FileView, error) { func UpdateFile(uid uint, fid string, filename string, description string, tag string) (*model.FileView, error) {
file, err := dao.GetFile(fid) file, err := dao.GetFile(fid)
if err != nil { if err != nil {
log.Error("failed to get file: ", err) log.Error("failed to get file: ", err)
@@ -371,7 +390,7 @@ func UpdateFile(uid uint, fid string, filename string, description string) (*mod
return nil, model.NewUnAuthorizedError("user cannot update file") return nil, model.NewUnAuthorizedError("user cannot update file")
} }
file, err = dao.UpdateFile(fid, filename, description) file, err = dao.UpdateFile(fid, filename, description, tag)
if err != nil { if err != nil {
log.Error("failed to update file in db: ", err) log.Error("failed to update file in db: ", err)
return nil, model.NewInternalServerError("failed to update file in db") return nil, model.NewInternalServerError("failed to update file in db")
@@ -556,7 +575,7 @@ func downloadFile(ctx context.Context, url string, path string) (string, error)
} }
} }
func CreateServerDownloadTask(uid uint, url, filename, description string, resourceID, storageID uint) (*model.FileView, error) { func CreateServerDownloadTask(uid uint, url, filename, description string, resourceID, storageID uint, tag string) (*model.FileView, error) {
canUpload, err := checkUserCanUpload(uid) canUpload, err := checkUserCanUpload(uid)
if err != nil { if err != nil {
log.Error("failed to check user permission: ", err) log.Error("failed to check user permission: ", err)
@@ -577,7 +596,7 @@ func CreateServerDownloadTask(uid uint, url, filename, description string, resou
return nil, model.NewRequestError("server is busy, please try again later") return nil, model.NewRequestError("server is busy, please try again later")
} }
file, err := dao.CreateFile(filename, description, resourceID, &storageID, storageKeyUnavailable, "", 0, uid, "") file, err := dao.CreateFile(filename, description, resourceID, &storageID, storageKeyUnavailable, "", 0, uid, "", tag)
if err != nil { if err != nil {
log.Error("failed to create file in db: ", err) log.Error("failed to create file in db: ", err)
return nil, model.NewInternalServerError("failed to create file in db") return nil, model.NewInternalServerError("failed to create file in db")

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

@@ -1,14 +1,20 @@
package service package service
import ( import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url" "net/url"
"nysoure/server/config" "nysoure/server/config"
"nysoure/server/dao" "nysoure/server/dao"
"nysoure/server/model" "nysoure/server/model"
"nysoure/server/search" "nysoure/server/search"
"nysoure/server/utils" "nysoure/server/utils"
"slices"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/log" "github.com/gofiber/fiber/v3/log"
@@ -24,9 +30,22 @@ 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"`
GalleryNsfw []uint `json:"gallery_nsfw"`
Characters []CharacterParams `json:"characters"`
}
type CharacterParams struct {
Name string `json:"name" binding:"required"`
Alias []string `json:"alias"`
CV string `json:"cv"`
Role string `json:"role"`
Image uint `json:"image"`
} }
func CreateResource(uid uint, params *ResourceParams) (uint, error) { func CreateResource(uid uint, params *ResourceParams) (uint, error) {
@@ -54,14 +73,65 @@ func CreateResource(uid uint, params *ResourceParams) (uint, error) {
}, },
} }
} }
gallery := make([]uint, 0, len(params.Gallery))
for _, id := range params.Gallery {
if slices.Contains(params.Images, id) {
gallery = append(gallery, id)
}
}
nsfw := make([]uint, 0, len(params.GalleryNsfw))
for _, id := range params.GalleryNsfw {
if slices.Contains(gallery, id) {
nsfw = append(nsfw, id)
}
}
characters := make([]model.Character, len(params.Characters))
for i, c := range params.Characters {
role := c.Role
if role == "" {
role = "primary"
}
var imageID *uint
if c.Image != 0 {
imageID = &c.Image
}
characters[i] = model.Character{
Name: c.Name,
Alias: c.Alias,
CV: c.CV,
Role: role,
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,
GalleryNsfw: nsfw,
Characters: characters,
} }
if r, err = dao.CreateResource(r); err != nil { if r, err = dao.CreateResource(r); err != nil {
return 0, err return 0, err
@@ -434,7 +504,7 @@ func GetResourcesWithUser(username string, page int) ([]model.ResourceView, int,
return views, totalPages, nil return views, totalPages, nil
} }
func EditResource(uid, rid uint, params *ResourceParams) error { func UpdateResource(uid, rid uint, params *ResourceParams) error {
isAdmin, err := checkUserCanUpload(uid) isAdmin, err := checkUserCanUpload(uid)
if err != nil { if err != nil {
log.Error("checkUserCanUpload error: ", err) log.Error("checkUserCanUpload error: ", err)
@@ -448,10 +518,64 @@ func EditResource(uid, rid uint, params *ResourceParams) error {
return model.NewUnAuthorizedError("You have not permission to edit this resource") return model.NewUnAuthorizedError("You have not permission to edit this resource")
} }
gallery := make([]uint, 0, len(params.Gallery))
for _, id := range params.Gallery {
if slices.Contains(params.Images, id) {
gallery = append(gallery, id)
}
}
nsfw := make([]uint, 0, len(params.GalleryNsfw))
for _, id := range params.GalleryNsfw {
if slices.Contains(gallery, id) {
nsfw = append(nsfw, id)
}
}
characters := make([]model.Character, len(params.Characters))
for i, c := range params.Characters {
role := c.Role
if role == "" {
role = "primary"
}
var imageID *uint
if c.Image != 0 {
imageID = &c.Image
}
characters[i] = model.Character{
Name: c.Name,
Alias: c.Alias,
CV: c.CV,
Role: role,
ImageID: imageID,
}
}
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.GalleryNsfw = nsfw
r.Characters = characters
images := make([]model.Image, len(params.Images)) images := make([]model.Image, len(params.Images))
for i, id := range params.Images { for i, id := range params.Images {
@@ -530,3 +654,281 @@ func GetPinnedResources() ([]model.ResourceView, error) {
} }
return views, nil return views, nil
} }
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{}
jsonStr := fmt.Sprintf(`
{
"filters": ["id", "=", "%s"],
"fields": "va.character.name, va.staff.name, va.staff.original, va.character.original, va.character.image.url, va.character.vns.role"
}
`, vnID)
jsonStr = strings.TrimSpace(jsonStr)
reader := strings.NewReader(jsonStr)
resp, err := client.Post("https://api.vndb.org/kana/vn", "application/json", reader)
if err != nil {
return nil, model.NewInternalServerError("Failed to fetch data from VNDB")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, model.NewInternalServerError("Failed to fetch data from VNDB")
}
// 定义 VNDB API 响应结构
type VndbResponse struct {
Results []struct {
ID string `json:"id"`
VA []struct {
Character struct {
ID string `json:"id"`
Name string `json:"name"`
Original string `json:"original"`
Image struct {
URL string `json:"url"`
} `json:"image"`
VNS []struct {
ID string `json:"id"`
Role string `json:"role"`
} `json:"vns"`
} `json:"character"`
Staff struct {
ID string `json:"id"`
Name string `json:"name"`
Original string `json:"original"`
} `json:"staff"`
} `json:"va"`
} `json:"results"`
}
// 解析响应
var vndbResp VndbResponse
if err := json.NewDecoder(resp.Body).Decode(&vndbResp); err != nil {
return nil, model.NewInternalServerError("Failed to parse VNDB response")
}
if len(vndbResp.Results) == 0 {
return []CharacterParams{}, nil
}
result := vndbResp.Results[0]
var characters []CharacterParams
processedCharacters := make(map[string]bool) // 避免重复角色
// 遍历声优信息
for _, va := range result.VA {
role := "Unknown"
for _, vn := range va.Character.VNS {
if vn.ID == vnID {
role = vn.Role
break
}
}
if role != "primary" && role != "side" && role != "main" {
continue
}
// 避免重复角色
if processedCharacters[va.Character.ID] {
continue
}
processedCharacters[va.Character.ID] = true
// 优先使用 original 字段作为角色名,如果没有则使用 name
characterName := strings.ReplaceAll(va.Character.Original, " ", "")
if characterName == "" {
characterName = va.Character.Name
}
if characterName == "" {
continue // 跳过没有名字的角色
}
// 使用 original 字段作为声优名,如果没有则使用 name
cvName := strings.ReplaceAll(va.Staff.Original, " ", "")
if cvName == "" {
cvName = va.Staff.Name
}
character := CharacterParams{
Name: characterName,
Alias: []string{},
CV: cvName,
Role: role,
Image: 0, // 默认值,下面会下载图片
}
// 下载并保存角色图片
if va.Character.Image.URL != "" {
imageID, err := downloadAndCreateImage(va.Character.Image.URL)
if err != nil {
log.Error("Failed to download character image:", err)
// 继续处理,即使图片下载失败
} else {
character.Image = imageID
}
}
characters = append(characters, character)
}
return characters, nil
}
// downloadAndCreateImage 下载图片并使用 CreateImage 保存
func downloadAndCreateImage(imageURL string) (uint, error) {
// 创建 HTTP 客户端
client := &http.Client{
Timeout: 30 * time.Second,
}
// 下载图片
resp, err := client.Get(imageURL)
if err != nil {
return 0, fmt.Errorf("failed to download image: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("failed to download image: HTTP %d", resp.StatusCode)
}
// 读取图片数据
imageData, err := io.ReadAll(resp.Body)
if err != nil {
return 0, fmt.Errorf("failed to read image data: %w", err)
}
// 限制图片大小,防止内存溢出
if len(imageData) > 8*1024*1024 { // 8MB 限制
return 0, fmt.Errorf("image too large")
}
// 使用系统用户ID (假设为1) 创建图片
// 注意:这里使用系统账户,实际使用时可能需要调整
imageID, err := CreateImage(1, "127.0.0.1", imageData)
if err != nil {
return 0, fmt.Errorf("failed to create image: %w", err)
}
return imageID, nil
}
// UpdateCharacterImage 更新角色的图片ID
func UpdateCharacterImage(uid, resourceID, characterID, imageID uint) error {
// 检查资源是否存在并且用户有权限修改
resource, err := dao.GetResourceByID(resourceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return model.NewNotFoundError("Resource not found")
}
return err
}
isAdmin, err := CheckUserIsAdmin(uid)
if err != nil {
return err
}
// 检查用户是否有权限修改这个资源
if resource.UserID != uid && !isAdmin {
return model.NewUnAuthorizedError("You don't have permission to modify this resource")
}
// 更新角色图片
err = dao.UpdateCharacterImage(characterID, imageID)
if err != nil {
return err
}
return nil
}
// GetLowResolutionCharacters 获取低清晰度的角色图片
func GetLowResolutionCharacters(page int, pageSize int, maxWidth, maxHeight int) ([]model.LowResCharacterView, int, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 50 // 默认每页50个角色
}
if pageSize > 1000 {
pageSize = 1000 // 限制最大页面大小
}
offset := (page - 1) * pageSize
// 获取角色列表
characters, err := dao.GetLowResolutionCharacters(maxWidth, maxHeight, pageSize, offset)
if err != nil {
return nil, 0, err
}
// 获取总数
totalCount, err := dao.GetLowResolutionCharactersCount(maxWidth, maxHeight)
if err != nil {
return nil, 0, err
}
totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize))
return characters, totalPages, nil
}
// GetLowResolutionResourceImages 获取低清晰度的资源图片
func GetLowResolutionResourceImages(page int, pageSize int, maxWidth, maxHeight int) ([]model.LowResResourceImageView, int, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 50 // 默认每页50个图片
}
if pageSize > 1000 {
pageSize = 1000 // 限制最大页面大小
}
offset := (page - 1) * pageSize
// 获取资源图片列表
images, err := dao.GetLowResolutionResourceImages(maxWidth, maxHeight, pageSize, offset)
if err != nil {
return nil, 0, err
}
// 获取总数
totalCount, err := dao.GetLowResolutionResourceImagesCount(maxWidth, maxHeight)
if err != nil {
return nil, 0, err
}
totalPages := int((totalCount + int64(pageSize) - 1) / int64(pageSize))
return images, totalPages, nil
}
// UpdateResourceImage 更新资源图片
func UpdateResourceImage(uid, resourceID, oldImageID, newImageID uint) error {
// 首先检查用户权限 - 确保用户是资源的所有者或管理员
resource, err := dao.GetResourceByID(resourceID)
if err != nil {
return err
}
isAdmin, err := CheckUserIsAdmin(uid)
if err != nil {
return err
}
if resource.UserID != uid && !isAdmin {
return model.NewUnAuthorizedError("You don't have permission to update this resource")
}
// 更新资源图片
return dao.UpdateResourceImage(resourceID, oldImageID, newImageID)
}

View File

@@ -0,0 +1,63 @@
package service
import (
"fmt"
"nysoure/server/dao"
"nysoure/server/utils"
"os"
"time"
)
type Statistic struct {
TotalResources int64 `json:"total_resources"`
TotalFiles int64 `json:"total_files"`
StartTime int64 `json:"start_time"`
}
var (
startTime int64
cache = utils.NewMemValueCache[*Statistic](1 * time.Minute)
)
func init() {
timeFile := utils.GetStoragePath() + "/.start_time"
if _, err := os.Stat(timeFile); os.IsNotExist(err) {
startTime = time.Now().Unix()
str := fmt.Sprintf("%d", startTime)
err := os.WriteFile(timeFile, []byte(str), 0644)
if err != nil {
panic("Failed to write start time file: " + err.Error())
}
} else {
data, err := os.ReadFile(timeFile)
if err != nil {
panic("Failed to read start time file: " + err.Error())
}
var t int64
_, err = fmt.Sscanf(string(data), "%d", &t)
if err != nil {
panic("Failed to parse start time: " + err.Error())
}
startTime = t
}
}
func getStatistic() (*Statistic, error) {
totalResources, err := dao.CountResources()
if err != nil {
return nil, err
}
totalFiles, err := dao.CountFiles()
if err != nil {
return nil, err
}
return &Statistic{
TotalResources: totalResources,
TotalFiles: totalFiles,
StartTime: startTime,
}, nil
}
func GetStatistic() (*Statistic, error) {
return cache.Get(getStatistic)
}

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

29
server/utils/cache.go Normal file
View File

@@ -0,0 +1,29 @@
package utils
import "time"
type MemValueCache[T any] struct {
value T
duration time.Duration
expiry time.Time
}
func NewMemValueCache[T any](duration time.Duration) *MemValueCache[T] {
return &MemValueCache[T]{
duration: duration,
}
}
func (c *MemValueCache[T]) Get(fetchFunc func() (T, error)) (T, error) {
var zero T
if time.Now().Before(c.expiry) {
return c.value, nil
}
v, err := fetchFunc()
if err != nil {
return zero, err
}
c.value = v
c.expiry = time.Now().Add(c.duration)
return v, 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
}