mirror of
https://github.com/wgh136/nysoure.git
synced 2025-12-16 15:51:14 +00:00
Compare commits
84 Commits
0a3e255dfe
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cd743af7f | |||
| 79bae828d8 | |||
| a42087ce5c | |||
| a9d2f05562 | |||
| 31b9fb5d45 | |||
| 116efcdf93 | |||
| 9ad8d9d7e9 | |||
| 8e2ab62297 | |||
| cb61ce99bf | |||
| 2767f8a30f | |||
| 5a5c2edfda | |||
| d860bdf06a | |||
| a3de195eca | |||
| 6cabff8f8d | |||
| 78f6130b23 | |||
| ddd856529b | |||
| 48638111ec | |||
| d255ecc503 | |||
| 00321b01c3 | |||
| 59904223b4 | |||
| b732e1be83 | |||
| fd86d6c221 | |||
| fbe8ac27bf | |||
| fb1f47c0c0 | |||
| ecfea63edd | |||
| ae547522ed | |||
| 96cdd2c41c | |||
| 566234c30c | |||
| 4a6c214709 | |||
| 4550720cbb | |||
| e833783da1 | |||
| 1406f76fbb | |||
| 6040f88034 | |||
| 23269ad9d1 | |||
| 57b0a10c4d | |||
| 26f5308d9a | |||
| 4f1600296c | |||
| 1a120d2378 | |||
| a0fb279b29 | |||
| 1d78207004 | |||
| 1544c535de | |||
| 48790ef5e0 | |||
| dd2eab4c4b | |||
| 5febba690b | |||
| 574e762fd1 | |||
| 7d41f8f5a5 | |||
| 2ae04c3180 | |||
| 940393c150 | |||
| e671083f09 | |||
| 762ca44873 | |||
| 79441a7226 | |||
| 4b1639e789 | |||
| 53684db11c | |||
| ee83eb9589 | |||
| 070fe1b416 | |||
| c41ef094ea | |||
| 6f711823ac | |||
| 85eff4ecac | |||
| 0395bc4686 | |||
| b1c01431fc | |||
| c55a6612bd | |||
| 9f5f2c6e47 | |||
| c554a05b60 | |||
| 4a60ad6133 | |||
| 67070fee4d | |||
| b794d4cc96 | |||
| e04fd8ceb1 | |||
| c9a5f096bd | |||
| 8b340ab175 | |||
| 327fd72be0 | |||
| 1c23bf1d6e | |||
| 070b9c7656 | |||
| d118ad7d14 | |||
| b811ca25c4 | |||
| 43273fece2 | |||
| 5d1e43f88d | |||
| 27bda316df | |||
| cacd936ab3 | |||
| e2c30e5d77 | |||
| 5a253b60d0 | |||
| 03bf9ec97b | |||
| 92e4e05d7d | |||
| 46a19c12fa | |||
| 9d9a2545f9 |
38
.env.example
Normal file
38
.env.example
Normal 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
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -2,4 +2,6 @@
|
||||
*.iml
|
||||
test.db
|
||||
.idea/
|
||||
build/
|
||||
build/
|
||||
temp/
|
||||
.env
|
||||
28
Dockerfile.backup
Normal file
28
Dockerfile.backup
Normal 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
40
backup-entrypoint.sh
Normal 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
190
backup.sh
Normal 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
|
||||
@@ -9,28 +9,59 @@ services:
|
||||
- app_data:/var/lib/nysoure
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
- DB_HOST=db
|
||||
- DB_PORT=3306
|
||||
- DB_USER=nysoure
|
||||
- DB_PASSWORD=nysoure_password
|
||||
- DB_NAME=nysoure
|
||||
- BANNED_REDIRECT_DOMAINS=example.com,example.org
|
||||
- redis
|
||||
env_file:
|
||||
- .env
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
db:
|
||||
image: mariadb:latest
|
||||
volumes:
|
||||
- db_data:/var/lib/mysql
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=root_password
|
||||
- MYSQL_DATABASE=nysoure
|
||||
- MYSQL_USER=nysoure
|
||||
- MYSQL_PASSWORD=nysoure_password
|
||||
ports:
|
||||
- "3306"
|
||||
env_file:
|
||||
- .env
|
||||
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:
|
||||
app_data:
|
||||
db_data:
|
||||
backup_data:
|
||||
|
||||
96
frontend/package-lock.json
generated
96
frontend/package-lock.json
generated
@@ -15,7 +15,6 @@
|
||||
"masonic": "^4.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.5.3",
|
||||
@@ -29,7 +28,7 @@
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/spark-md5": "^3.0.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"daisyui": "^5.0.35",
|
||||
"daisyui": "^5.5.5",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-prettier": "^5.4.1",
|
||||
@@ -281,15 +280,6 @@
|
||||
"@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": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.1.tgz",
|
||||
@@ -2558,9 +2548,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/daisyui": {
|
||||
"version": "5.0.35",
|
||||
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.0.35.tgz",
|
||||
"integrity": "sha512-AWi11n/x5++mps55jcwrBf0Lmip1euWY0FYcH/05SFGmoqrU7S7/aIUWaiaeqlJ5EcmEZ/7zEY73aOxMv6hcIg==",
|
||||
"version": "5.5.5",
|
||||
"resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.5.tgz",
|
||||
"integrity": "sha512-ekvI93ZkWIJoCOtDl0D2QMxnWvTejk9V5nWBqRv+7t0xjiBXqAK5U6o6JE2RPvlIC3EqwNyUoIZSdHX9MZK3nw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -3618,15 +3608,6 @@
|
||||
"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": {
|
||||
"version": "3.0.1",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -5681,32 +5630,6 @@
|
||||
"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": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||
@@ -6418,7 +6341,7 @@
|
||||
"version": "5.7.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
|
||||
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
@@ -6727,15 +6650,6 @@
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@types/spark-md5": "^3.0.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"daisyui": "^5.0.35",
|
||||
"daisyui": "^5.5.5",
|
||||
"eslint": "^9.22.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-prettier": "^5.4.1",
|
||||
|
||||
@@ -19,6 +19,7 @@ import CreateCollectionPage from "./pages/create_collection_page.tsx";
|
||||
import CollectionPage from "./pages/collection_page.tsx";
|
||||
import { i18nData } from "./i18n.ts";
|
||||
import { i18nContext } from "./utils/i18n.ts";
|
||||
import NotificationPage from "./pages/notification_page.tsx";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -49,6 +50,7 @@ export default function App() {
|
||||
element={<CreateCollectionPage />}
|
||||
/>
|
||||
<Route path={"/collection/:id"} element={<CollectionPage />} />
|
||||
<Route path={"/notifications"} element={<NotificationPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { CharacterParams } from "../network/models";
|
||||
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 CharactorEditor({charactor, setCharactor, onDelete}: {
|
||||
charactor: CharacterParams;
|
||||
setCharactor: (charactor: CharacterParams) => void;
|
||||
export default function CharacterEditer({character, setCharacter, onDelete}: {
|
||||
character: CharacterParams;
|
||||
setCharacter: (character: CharacterParams) => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -26,8 +26,8 @@ export default function CharactorEditor({charactor, setCharactor, onDelete}: {
|
||||
const result = await network.uploadImage(file);
|
||||
setUploading(false);
|
||||
if (result.success) {
|
||||
setCharactor({
|
||||
...charactor,
|
||||
setCharacter({
|
||||
...character,
|
||||
image: result.data!,
|
||||
});
|
||||
} else {
|
||||
@@ -51,7 +51,7 @@ export default function CharactorEditor({charactor, setCharactor, onDelete}: {
|
||||
}
|
||||
<img
|
||||
className="w-full h-full object-cover bg-base-200/80 hover:bg-base-200 transition-colors"
|
||||
src={charactor.image === 0 ? "/cp.webp" : network.getImageUrl(charactor.image)} alt={charactor.name}
|
||||
src={character.image === 0 ? "/cp.webp" : network.getImageUrl(character.image)} alt={character.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -61,8 +61,8 @@ export default function CharactorEditor({charactor, setCharactor, onDelete}: {
|
||||
type="text"
|
||||
className="input input-sm input-bordered flex-1"
|
||||
placeholder={t("Name")}
|
||||
value={charactor.name}
|
||||
onChange={(e) => setCharactor({ ...charactor, name: e.target.value })}
|
||||
value={character.name}
|
||||
onChange={(e) => setCharacter({ ...character, name: e.target.value })}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-sm btn-error btn-square"
|
||||
@@ -78,17 +78,26 @@ export default function CharactorEditor({charactor, setCharactor, onDelete}: {
|
||||
type="text"
|
||||
className="input input-sm input-bordered"
|
||||
placeholder="CV"
|
||||
value={charactor.cv}
|
||||
onChange={(e) => setCharactor({ ...charactor, cv: e.target.value })}
|
||||
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={charactor.alias.join('\n')}
|
||||
onChange={(e) => setCharactor({
|
||||
...charactor,
|
||||
value={character.alias.join('\n')}
|
||||
onChange={(e) => setCharacter({
|
||||
...character,
|
||||
alias: e.target.value.split('\n').filter(line => line.trim() !== '')
|
||||
})}
|
||||
/>
|
||||
431
frontend/src/components/gallery.tsx
Normal file
431
frontend/src/components/gallery.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,10 @@ import { app } from "../app.ts";
|
||||
import { network } from "../network/network.ts";
|
||||
import { useNavigate, useOutlet } from "react-router";
|
||||
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 UploadingSideBar from "./uploading_side_bar.tsx";
|
||||
import { ThemeSwitcher } from "./theme_switcher.tsx";
|
||||
import { IoLogoGithub } from "react-icons/io";
|
||||
import { useAppContext } from "./AppContext.tsx";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
@@ -67,7 +66,7 @@ export default function Navigator() {
|
||||
{/* Background overlay */}
|
||||
{background && (
|
||||
<div
|
||||
className="bg-base-100 opacity-60 dark:opacity-40"
|
||||
className="bg-base-100 opacity-20 dark:opacity-40"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
@@ -234,16 +233,7 @@ export default function Navigator() {
|
||||
<SearchBar />
|
||||
<UploadingSideBar />
|
||||
<ThemeSwitcher />
|
||||
<a
|
||||
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() && <NotificationButton />}
|
||||
{app.isLoggedIn() ? (
|
||||
<UserButton />
|
||||
) : (
|
||||
@@ -554,3 +544,41 @@ function FloatingToTopButton() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -156,8 +156,8 @@ export const i18nData = {
|
||||
"Cloudflare Turnstile Secret Key": "Cloudflare Turnstile 密钥",
|
||||
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.":
|
||||
"如果设置了 Cloudflare Turnstile 密钥,将在注册和下载时启用验证",
|
||||
"The first image will be used as the cover image":
|
||||
"第一张图片将用作封面图片",
|
||||
"You can select a cover image using the radio button in the Cover column":
|
||||
"您可以使用封面列中的单选按钮选择封面图片",
|
||||
"Please enter a search keyword": "请输入搜索关键词",
|
||||
"Searching...": "搜索中...",
|
||||
"Create Tag": "创建标签",
|
||||
@@ -179,6 +179,8 @@ export const i18nData = {
|
||||
"Views Descending": "浏览量降序",
|
||||
"Downloads Ascending": "下载量升序",
|
||||
"Downloads Descending": "下载量降序",
|
||||
"Release Date Ascending": "发布日期升序",
|
||||
"Release Date Descending": "发布日期降序",
|
||||
"File Url": "文件链接",
|
||||
"Provide a file url for the server to download, and the file will be moved to the selected storage.":
|
||||
"提供一个文件链接供服务器下载,文件将被移动到选定的存储中。",
|
||||
@@ -258,6 +260,12 @@ export const i18nData = {
|
||||
"Survival time": "存活时间",
|
||||
"Characters": "角色",
|
||||
"Aliases (one per line)": "别名(每行一个)",
|
||||
"File Size": "文件大小",
|
||||
"Tag": "标签",
|
||||
"Optional": "可选",
|
||||
"Download": "下载",
|
||||
"Notifications": "通知",
|
||||
"Release Date": "发售日期",
|
||||
},
|
||||
},
|
||||
"zh-TW": {
|
||||
@@ -417,8 +425,8 @@ export const i18nData = {
|
||||
"Cloudflare Turnstile Secret Key": "Cloudflare Turnstile 密鑰",
|
||||
"If the cloudflare turnstile keys are not empty, the turnstile will be used for register and download.":
|
||||
"如果設置了 Cloudflare Turnstile 密鑰,將在註冊和下載時啟用驗證",
|
||||
"The first image will be used as the cover image":
|
||||
"第一張圖片將用作封面圖片",
|
||||
"You can select a cover image using the radio button in the Cover column":
|
||||
"您可以使用封面列中的單選按鈕選擇封面圖片",
|
||||
"Please enter a search keyword": "請輸入搜尋關鍵字",
|
||||
"Searching...": "搜尋中...",
|
||||
"Create Tag": "創建標籤",
|
||||
@@ -440,6 +448,8 @@ export const i18nData = {
|
||||
"Views Descending": "瀏覽量降序",
|
||||
"Downloads Ascending": "下載量升序",
|
||||
"Downloads Descending": "下載量降序",
|
||||
"Release Date Ascending": "發布日期升序",
|
||||
"Release Date Descending": "發布日期降序",
|
||||
"File Url": "檔案連結",
|
||||
"Provide a file url for the server to download, and the file will be moved to the selected storage.":
|
||||
"提供一個檔案連結供伺服器下載,檔案將被移動到選定的儲存中。",
|
||||
@@ -517,6 +527,14 @@ export const i18nData = {
|
||||
"Private": "私有",
|
||||
"View {count} more replies": "查看另外 {count} 條回覆",
|
||||
"Survival time": "存活時間",
|
||||
"Characters": "角色",
|
||||
"Aliases (one per line)": "別名(每行一個)",
|
||||
"File Size": "檔案大小",
|
||||
"Tag": "標籤",
|
||||
"Optional": "可選",
|
||||
"Download": "下載",
|
||||
"Notifications": "通知",
|
||||
"Release Date": "發售日期",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,10 +3,7 @@ import { Response } from "./models.ts";
|
||||
|
||||
const KunApi = {
|
||||
isAvailable(): boolean {
|
||||
return (
|
||||
window.location.hostname === "res.nyne.dev" ||
|
||||
window.location.hostname.startsWith("localhost")
|
||||
);
|
||||
return true;
|
||||
},
|
||||
|
||||
async getPatch(id: string): Promise<Response<KunPatchResponse>> {
|
||||
@@ -16,8 +13,10 @@ const KunApi = {
|
||||
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(
|
||||
`https://www.moyu.moe/api/hikari?vndb_id=${id}`,
|
||||
`/api/proxy?uri=${uriBase64}`,
|
||||
);
|
||||
if (res.status === 404) {
|
||||
return {
|
||||
|
||||
@@ -44,19 +44,24 @@ export interface CreateResourceParams {
|
||||
title: string;
|
||||
alternative_titles: string[];
|
||||
links: RLink[];
|
||||
release_date?: string;
|
||||
tags: number[];
|
||||
article: string;
|
||||
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 {
|
||||
@@ -74,6 +79,7 @@ export interface Resource {
|
||||
id: number;
|
||||
title: string;
|
||||
created_at: string;
|
||||
release_date?: string;
|
||||
tags: Tag[];
|
||||
image?: Image;
|
||||
author: User;
|
||||
@@ -86,8 +92,10 @@ export interface ResourceDetails {
|
||||
links: RLink[];
|
||||
article: string;
|
||||
createdAt: string;
|
||||
releaseDate?: string;
|
||||
tags: Tag[];
|
||||
images: Image[];
|
||||
coverId?: number;
|
||||
files: RFile[];
|
||||
author: User;
|
||||
views: number;
|
||||
@@ -96,7 +104,7 @@ export interface ResourceDetails {
|
||||
related: Resource[];
|
||||
gallery: number[];
|
||||
galleryNsfw: number[];
|
||||
charactors: CharacterParams[];
|
||||
characters: CharacterParams[];
|
||||
}
|
||||
|
||||
export interface Storage {
|
||||
@@ -120,6 +128,7 @@ export interface RFile {
|
||||
hash?: string;
|
||||
storage_name?: string;
|
||||
created_at: number; // unix timestamp
|
||||
tag?: string;
|
||||
}
|
||||
|
||||
export interface UploadingFile {
|
||||
@@ -190,6 +199,8 @@ export enum RSort {
|
||||
ViewsDesc = 3,
|
||||
DownloadsAsc = 4,
|
||||
DownloadsDesc = 5,
|
||||
ReleaseDateAsc = 6,
|
||||
ReleaseDateDesc = 7,
|
||||
}
|
||||
|
||||
export enum ActivityType {
|
||||
|
||||
@@ -457,6 +457,28 @@ class Network {
|
||||
);
|
||||
}
|
||||
|
||||
async createFTPStorage(
|
||||
name: string,
|
||||
host: string,
|
||||
username: string,
|
||||
password: string,
|
||||
basePath: string,
|
||||
domain: string,
|
||||
maxSizeInMB: number,
|
||||
): Promise<Response<any>> {
|
||||
return this._callApi(() =>
|
||||
axios.post(`${this.apiBaseUrl}/storage/ftp`, {
|
||||
name,
|
||||
host,
|
||||
username,
|
||||
password,
|
||||
basePath,
|
||||
domain,
|
||||
maxSizeInMB,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async listStorages(): Promise<Response<Storage[]>> {
|
||||
return this._callApi(() => axios.get(`${this.apiBaseUrl}/storage`));
|
||||
}
|
||||
@@ -479,6 +501,7 @@ class Network {
|
||||
fileSize: number,
|
||||
resourceId: number,
|
||||
storageId: number,
|
||||
tag: string,
|
||||
): Promise<Response<UploadingFile>> {
|
||||
return this._callApi(() =>
|
||||
axios.post(`${this.apiBaseUrl}/files/upload/init`, {
|
||||
@@ -487,6 +510,7 @@ class Network {
|
||||
file_size: fileSize,
|
||||
resource_id: resourceId,
|
||||
storage_id: storageId,
|
||||
tag,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -529,6 +553,9 @@ class Network {
|
||||
description: string,
|
||||
resourceId: number,
|
||||
redirectUrl: string,
|
||||
fileSize: number,
|
||||
md5: string,
|
||||
tag: string,
|
||||
): Promise<Response<RFile>> {
|
||||
return this._callApi(() =>
|
||||
axios.post(`${this.apiBaseUrl}/files/redirect`, {
|
||||
@@ -536,6 +563,9 @@ class Network {
|
||||
description,
|
||||
resource_id: resourceId,
|
||||
redirect_url: redirectUrl,
|
||||
file_size: fileSize,
|
||||
md5,
|
||||
tag,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -546,6 +576,7 @@ class Network {
|
||||
description: string,
|
||||
resourceId: number,
|
||||
storageId: number,
|
||||
tag: string,
|
||||
): Promise<Response<RFile>> {
|
||||
return this._callApi(() =>
|
||||
axios.post(`${this.apiBaseUrl}/files/upload/url`, {
|
||||
@@ -554,6 +585,7 @@ class Network {
|
||||
description,
|
||||
resource_id: resourceId,
|
||||
storage_id: storageId,
|
||||
tag,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -566,11 +598,13 @@ class Network {
|
||||
fileId: string,
|
||||
filename: string,
|
||||
description: string,
|
||||
tag: string,
|
||||
): Promise<Response<RFile>> {
|
||||
return this._callApi(() =>
|
||||
axios.put(`${this.apiBaseUrl}/files/${fileId}`, {
|
||||
filename,
|
||||
description,
|
||||
tag,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -696,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(
|
||||
title: string,
|
||||
article: string,
|
||||
|
||||
@@ -201,6 +201,7 @@ class UploadingManager extends Listenable {
|
||||
resourceID: number,
|
||||
storageID: number,
|
||||
description: string,
|
||||
tag: string,
|
||||
onFinished: () => void,
|
||||
): Promise<Response<void>> {
|
||||
const res = await network.initFileUpload(
|
||||
@@ -209,6 +210,7 @@ class UploadingManager extends Listenable {
|
||||
file.size,
|
||||
resourceID,
|
||||
storageID,
|
||||
tag,
|
||||
);
|
||||
if (!res.success) {
|
||||
return {
|
||||
|
||||
@@ -20,18 +20,20 @@ import {
|
||||
SelectAndUploadImageButton,
|
||||
UploadClipboardImageButton,
|
||||
} from "../components/image_selector.tsx";
|
||||
import CharactorEditor, { FetchVndbCharactersButton } from "../components/charactor_edit.tsx";
|
||||
import CharacterEditer, { FetchVndbCharactersButton } from "../components/character_edit.tsx";
|
||||
|
||||
export default function EditResourcePage() {
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [altTitles, setAltTitles] = useState<string[]>([]);
|
||||
const [releaseDate, setReleaseDate] = useState<string | undefined>(undefined);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [article, setArticle] = useState<string>("");
|
||||
const [images, setImages] = useState<number[]>([]);
|
||||
const [coverId, setCoverId] = useState<number | undefined>(undefined);
|
||||
const [links, setLinks] = useState<{ label: string; url: string }[]>([]);
|
||||
const [galleryImages, setGalleryImages] = useState<number[]>([]);
|
||||
const [galleryNsfw, setGalleryNsfw] = useState<number[]>([]);
|
||||
const [charactors, setCharactors] = useState<CharacterParams[]>([]);
|
||||
const [characters, setCharacters] = useState<CharacterParams[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
@@ -54,14 +56,16 @@ export default function EditResourcePage() {
|
||||
if (res.success) {
|
||||
const data = res.data!;
|
||||
setTitle(data.title);
|
||||
setAltTitles(data.alternativeTitles);
|
||||
setAltTitles(data.alternativeTitles ?? []);
|
||||
setTags(data.tags);
|
||||
setArticle(data.article);
|
||||
setImages(data.images.map((i) => i.id));
|
||||
setCoverId(data.coverId);
|
||||
setLinks(data.links ?? []);
|
||||
setGalleryImages(data.gallery ?? []);
|
||||
setGalleryNsfw(data.galleryNsfw ?? []);
|
||||
setCharactors(data.charactors ?? []);
|
||||
setReleaseDate(data.releaseDate?.split("T")[0] ?? undefined);
|
||||
setCharacters(data.characters ?? []);
|
||||
setLoading(false);
|
||||
} else {
|
||||
showToast({ message: t("Failed to load resource"), type: "error" });
|
||||
@@ -104,10 +108,12 @@ export default function EditResourcePage() {
|
||||
tags: tags.map((tag) => tag.id),
|
||||
article: article,
|
||||
images: images,
|
||||
cover_id: coverId,
|
||||
links: links,
|
||||
gallery: galleryImages,
|
||||
gallery_nsfw: galleryNsfw,
|
||||
characters: charactors,
|
||||
characters: characters,
|
||||
release_date: releaseDate,
|
||||
});
|
||||
if (res.success) {
|
||||
setSubmitting(false);
|
||||
@@ -156,7 +162,7 @@ export default function EditResourcePage() {
|
||||
/>
|
||||
<div className={"h-4"}></div>
|
||||
<p className={"my-1"}>{t("Alternative Titles")}</p>
|
||||
{altTitles.map((title, index) => {
|
||||
{altTitles && altTitles.map((title, index) => {
|
||||
return (
|
||||
<div key={index} className={"flex items-center my-2"}>
|
||||
<input
|
||||
@@ -194,6 +200,14 @@ export default function EditResourcePage() {
|
||||
{t("Add Alternative Title")}
|
||||
</button>
|
||||
<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>
|
||||
<div className={"flex flex-col"}>
|
||||
{links.map((link, index) => {
|
||||
@@ -317,7 +331,7 @@ export default function EditResourcePage() {
|
||||
"Images will not be displayed automatically, you need to reference them in the description",
|
||||
)}
|
||||
</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
|
||||
@@ -328,6 +342,7 @@ export default function EditResourcePage() {
|
||||
<tr>
|
||||
<td>{t("Preview")}</td>
|
||||
<td>{"Markdown"}</td>
|
||||
<td>{t("Cover")}</td>
|
||||
<td>{t("Gallery")}</td>
|
||||
<td>{"Nsfw"}</td>
|
||||
<td>{t("Action")}</td>
|
||||
@@ -357,6 +372,15 @@ export default function EditResourcePage() {
|
||||
<MdContentCopy />
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="radio"
|
||||
name="cover"
|
||||
className="radio radio-accent"
|
||||
checked={coverId === image}
|
||||
onChange={() => setCoverId(image)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -398,6 +422,9 @@ export default function EditResourcePage() {
|
||||
const newImages = [...images];
|
||||
newImages.splice(index, 1);
|
||||
setImages(newImages);
|
||||
if (coverId === id) {
|
||||
setCoverId(undefined);
|
||||
}
|
||||
network.deleteImage(id);
|
||||
}}
|
||||
>
|
||||
@@ -428,18 +455,18 @@ export default function EditResourcePage() {
|
||||
<p className={"my-1"}>{t("Characters")}</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 my-2 gap-4">
|
||||
{
|
||||
charactors.map((charactor, index) => {
|
||||
return <CharactorEditor
|
||||
charactor={charactor}
|
||||
setCharactor={(newCharactor) => {
|
||||
const newCharactors = [...charactors];
|
||||
newCharactors[index] = newCharactor;
|
||||
setCharactors(newCharactors);
|
||||
characters.map((character, index) => {
|
||||
return <CharacterEditer
|
||||
character={character}
|
||||
setCharacter={(newCharacter) => {
|
||||
const newCharacters = [...characters];
|
||||
newCharacters[index] = newCharacter;
|
||||
setCharacters(newCharacters);
|
||||
}}
|
||||
onDelete={() => {
|
||||
const newCharactors = [...charactors];
|
||||
newCharactors.splice(index, 1);
|
||||
setCharactors(newCharactors);
|
||||
const newCharacters = [...characters];
|
||||
newCharacters.splice(index, 1);
|
||||
setCharacters(newCharacters);
|
||||
}} />;
|
||||
})
|
||||
}
|
||||
@@ -449,7 +476,7 @@ export default function EditResourcePage() {
|
||||
className={"btn h-9"}
|
||||
type={"button"}
|
||||
onClick={() => {
|
||||
setCharactors([...charactors, { name: "", alias: [], cv: "", image: 0 }]);
|
||||
setCharacters([...characters, { name: "", alias: [], cv: "", image: 0, role: "primary" }]);
|
||||
}}
|
||||
>
|
||||
<MdAdd />
|
||||
@@ -461,7 +488,7 @@ export default function EditResourcePage() {
|
||||
<FetchVndbCharactersButton
|
||||
vnID={links.find(link => link.label.toLowerCase() === "vndb")?.url.split("/").pop() ?? ""}
|
||||
onFetch={(fetchedCharacters) => {
|
||||
setCharactors(fetchedCharacters);
|
||||
setCharacters(fetchedCharacters);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { app } from "../app.ts";
|
||||
import { Resource, RSort, Statistics } from "../network/models.ts";
|
||||
import { useTranslation } from "../utils/i18n";
|
||||
import { useAppContext } from "../components/AppContext.tsx";
|
||||
import Select from "../components/select.tsx";
|
||||
import { useNavigate } from "react-router";
|
||||
import { useNavigator } from "../components/navigator.tsx";
|
||||
import {
|
||||
@@ -40,23 +39,32 @@ export default function HomePage() {
|
||||
<>
|
||||
<HomeHeader />
|
||||
<div className={"flex pt-4 px-4 items-center"}>
|
||||
<Select
|
||||
values={[
|
||||
<select
|
||||
value={order}
|
||||
className="select select-primary max-w-72"
|
||||
onChange={(e) => {
|
||||
const order = parseInt(e.target.value);
|
||||
setOrder(order);
|
||||
if (appContext) {
|
||||
appContext.set("home_page_order", order);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{[
|
||||
t("Time Ascending"),
|
||||
t("Time Descending"),
|
||||
t("Views Ascending"),
|
||||
t("Views Descending"),
|
||||
t("Downloads Ascending"),
|
||||
t("Downloads Descending"),
|
||||
]}
|
||||
current={order}
|
||||
onSelected={(index) => {
|
||||
setOrder(index);
|
||||
if (appContext) {
|
||||
appContext.set("home_page_order", index);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
t("Release Date Ascending"),
|
||||
t("Release Date Descending"),
|
||||
].map((label, idx) => (
|
||||
<option key={idx} value={idx}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<ResourcesView
|
||||
key={`home_page_${order}`}
|
||||
@@ -70,6 +78,7 @@ export default function HomePage() {
|
||||
function HomeHeader() {
|
||||
const [pinnedResources, setPinnedResources] = useState<Resource[]>([]);
|
||||
const [statistic, setStatistic] = useState<Statistics | null>(null);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const navigator = useNavigator();
|
||||
const appContext = useAppContext();
|
||||
|
||||
@@ -127,13 +136,30 @@ function HomeHeader() {
|
||||
fetchStatistics();
|
||||
}, [appContext, navigator]);
|
||||
|
||||
// Auto-scroll carousel every 5 seconds
|
||||
useEffect(() => {
|
||||
if (pinnedResources.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prevIndex) => (prevIndex + 1) % pinnedResources.length);
|
||||
}, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [pinnedResources.length, currentIndex]);
|
||||
|
||||
if (pinnedResources.length == 0 || statistic == null) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 p-4 gap-4">
|
||||
<PinnedResourceItem resource={pinnedResources[0]} />
|
||||
<PinnedResourcesCarousel
|
||||
resources={pinnedResources}
|
||||
currentIndex={currentIndex}
|
||||
onIndexChange={setCurrentIndex}
|
||||
/>
|
||||
<div className={"hidden md:flex h-52 md:h-60 flex-col"}>
|
||||
<div className={"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>
|
||||
@@ -145,6 +171,49 @@ function HomeHeader() {
|
||||
);
|
||||
}
|
||||
|
||||
function PinnedResourcesCarousel({
|
||||
resources,
|
||||
currentIndex,
|
||||
onIndexChange,
|
||||
}: {
|
||||
resources: Resource[];
|
||||
currentIndex: number;
|
||||
onIndexChange: (index: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="overflow-hidden rounded-2xl">
|
||||
<div
|
||||
className="flex transition-transform duration-500 ease-in-out"
|
||||
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
|
||||
>
|
||||
{resources.map((resource) => (
|
||||
<div key={resource.id} className="w-full flex-shrink-0">
|
||||
<PinnedResourceItem resource={resource} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{resources.length > 1 && (
|
||||
<div className="absolute bottom-2 left-1/2 transform -translate-x-1/2 flex gap-2 z-10">
|
||||
{resources.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onIndexChange(index)}
|
||||
className={`w-2 h-2 rounded-full transition-all ${
|
||||
index === currentIndex
|
||||
? "bg-white w-6"
|
||||
: "bg-white/50 hover:bg-white/75"
|
||||
}`}
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PinnedResourceItem({ resource }: { resource: Resource }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
|
||||
@@ -244,6 +244,7 @@ export default function StorageView() {
|
||||
enum StorageType {
|
||||
local,
|
||||
s3,
|
||||
ftp,
|
||||
}
|
||||
|
||||
function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
|
||||
@@ -259,6 +260,10 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
|
||||
bucketName: "",
|
||||
maxSizeInMB: 0,
|
||||
domain: "",
|
||||
host: "",
|
||||
username: "",
|
||||
password: "",
|
||||
basePath: "",
|
||||
});
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -305,6 +310,28 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
|
||||
params.maxSizeInMB,
|
||||
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) {
|
||||
@@ -368,6 +395,15 @@ function NewStorageDialog({ onAdded }: { onAdded: () => void }) {
|
||||
setStorageType(StorageType.s3);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="btn"
|
||||
type="radio"
|
||||
name="type"
|
||||
aria-label={t("FTP")}
|
||||
onInput={() => {
|
||||
setStorageType(StorageType.ftp);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
|
||||
{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"} />}
|
||||
|
||||
<div className="modal-action">
|
||||
|
||||
212
frontend/src/pages/notification_page.tsx
Normal file
212
frontend/src/pages/notification_page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -19,20 +19,22 @@ import {
|
||||
SelectAndUploadImageButton,
|
||||
UploadClipboardImageButton,
|
||||
} from "../components/image_selector.tsx";
|
||||
import CharactorEditor from "../components/charactor_edit.tsx";
|
||||
import CharacterEditer, { FetchVndbCharactersButton } from "../components/character_edit.tsx";
|
||||
|
||||
export default function PublishPage() {
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [altTitles, setAltTitles] = useState<string[]>([]);
|
||||
const [releaseDate, setReleaseDate] = useState<string | undefined>(undefined);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [article, setArticle] = useState<string>("");
|
||||
const [images, setImages] = useState<number[]>([]);
|
||||
const [coverId, setCoverId] = useState<number | undefined>(undefined);
|
||||
const [links, setLinks] = useState<{ label: string; url: string }[]>([]);
|
||||
const [galleryImages, setGalleryImages] = useState<number[]>([]);
|
||||
const [galleryNsfw, setGalleryNsfw] = useState<number[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setSubmitting] = useState(false);
|
||||
const [charactors, setCharactors] = useState<CharacterParams[]>([]);
|
||||
const [characters, setCharacters] = useState<CharacterParams[]>([]);
|
||||
const isFirstLoad = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -43,9 +45,15 @@ export default function PublishPage() {
|
||||
const data = JSON.parse(oldData);
|
||||
setTitle(data.title || "");
|
||||
setAltTitles(data.alternative_titles || []);
|
||||
setReleaseDate(data.release_date || undefined);
|
||||
setTags(data.tags || []);
|
||||
setArticle(data.article || "");
|
||||
setImages(data.images || []);
|
||||
setCoverId(data.cover_id || undefined);
|
||||
setLinks(data.links || []);
|
||||
setGalleryImages(data.gallery || []);
|
||||
setGalleryNsfw(data.gallery_nsfw || []);
|
||||
setCharacters(data.characters || []);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse publish_data from localStorage", e);
|
||||
}
|
||||
@@ -58,11 +66,17 @@ export default function PublishPage() {
|
||||
tags: tags,
|
||||
article: article,
|
||||
images: images,
|
||||
cover_id: coverId,
|
||||
links: links,
|
||||
gallery: galleryImages,
|
||||
gallery_nsfw: galleryNsfw,
|
||||
characters: characters,
|
||||
release_date: releaseDate,
|
||||
};
|
||||
const dataString = JSON.stringify(data);
|
||||
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 { t } = useTranslation();
|
||||
@@ -105,13 +119,15 @@ export default function PublishPage() {
|
||||
const res = await network.createResource({
|
||||
title: title,
|
||||
alternative_titles: altTitles,
|
||||
release_date: releaseDate,
|
||||
tags: tags.map((tag) => tag.id),
|
||||
article: article,
|
||||
images: images,
|
||||
cover_id: coverId,
|
||||
links: links,
|
||||
gallery: galleryImages,
|
||||
gallery_nsfw: galleryNsfw,
|
||||
characters: charactors,
|
||||
characters: characters,
|
||||
});
|
||||
if (res.success) {
|
||||
localStorage.removeItem("publish_data");
|
||||
@@ -201,6 +217,14 @@ export default function PublishPage() {
|
||||
{t("Add Alternative Title")}
|
||||
</button>
|
||||
<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>
|
||||
<div className={"flex flex-col"}>
|
||||
{links.map((link, index) => {
|
||||
@@ -324,7 +348,7 @@ export default function PublishPage() {
|
||||
"Images will not be displayed automatically, you need to reference them in the description",
|
||||
)}
|
||||
</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
|
||||
@@ -335,7 +359,8 @@ export default function PublishPage() {
|
||||
<tr>
|
||||
<td>{t("Preview")}</td>
|
||||
<td>{"Markdown"}</td>
|
||||
<td>{"Gallery"}</td>
|
||||
<td>{t("Cover")}</td>
|
||||
<td>{t("Gallery")}</td>
|
||||
<td>{"Nsfw"}</td>
|
||||
<td>{t("Action")}</td>
|
||||
</tr>
|
||||
@@ -364,6 +389,15 @@ export default function PublishPage() {
|
||||
<MdContentCopy />
|
||||
</button>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="radio"
|
||||
name="cover"
|
||||
className="radio radio-accent"
|
||||
checked={coverId === image}
|
||||
onChange={() => setCoverId(image)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -405,6 +439,9 @@ export default function PublishPage() {
|
||||
const newImages = [...images];
|
||||
newImages.splice(index, 1);
|
||||
setImages(newImages);
|
||||
if (coverId === id) {
|
||||
setCoverId(undefined);
|
||||
}
|
||||
network.deleteImage(id);
|
||||
}}
|
||||
>
|
||||
@@ -435,18 +472,18 @@ export default function PublishPage() {
|
||||
<p className={"my-1"}>{t("Characters")}</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 my-2 gap-4">
|
||||
{
|
||||
charactors.map((charactor, index) => {
|
||||
return <CharactorEditor
|
||||
charactor={charactor}
|
||||
setCharactor={(newCharactor) => {
|
||||
const newCharactors = [...charactors];
|
||||
newCharactors[index] = newCharactor;
|
||||
setCharactors(newCharactors);
|
||||
}}
|
||||
characters.map((character, index) => {
|
||||
return <CharacterEditer
|
||||
character={character}
|
||||
setCharacter={(newCharacter) => {
|
||||
const newCharacters = [...characters];
|
||||
newCharacters[index] = newCharacter;
|
||||
setCharacters(newCharacters);
|
||||
}}
|
||||
onDelete={() => {
|
||||
const newCharactors = [...charactors];
|
||||
newCharactors.splice(index, 1);
|
||||
setCharactors(newCharactors);
|
||||
const newCharacters = [...characters];
|
||||
newCharacters.splice(index, 1);
|
||||
setCharacters(newCharacters);
|
||||
}} />;
|
||||
})
|
||||
}
|
||||
@@ -456,12 +493,23 @@ export default function PublishPage() {
|
||||
className={"btn my-2"}
|
||||
type={"button"}
|
||||
onClick={() => {
|
||||
setCharactors([...charactors, { name: "", alias: [], cv: "", image: 0 }]);
|
||||
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 && (
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -31,8 +32,6 @@ import {
|
||||
MdOutlineAdd,
|
||||
MdOutlineArchive,
|
||||
MdOutlineArticle,
|
||||
MdOutlineChevronLeft,
|
||||
MdOutlineChevronRight,
|
||||
MdOutlineCloud,
|
||||
MdOutlineComment,
|
||||
MdOutlineContentCopy,
|
||||
@@ -69,7 +68,7 @@ import KunApi, {
|
||||
} from "../network/kun.ts";
|
||||
import { Debounce } from "../utils/debounce.ts";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import Gallery from "../components/gallery.tsx";
|
||||
|
||||
export default function ResourcePage() {
|
||||
const params = useParams();
|
||||
@@ -198,7 +197,7 @@ export default function ResourcePage() {
|
||||
<div className="flex">
|
||||
<div className="flex-1">
|
||||
<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 (
|
||||
<h2
|
||||
key={i}
|
||||
@@ -210,6 +209,14 @@ export default function ResourcePage() {
|
||||
</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
|
||||
onClick={() => {
|
||||
navigate(
|
||||
@@ -466,141 +473,141 @@ function DeleteResourceDialog({
|
||||
);
|
||||
}
|
||||
|
||||
const context = createContext<() => void>(() => {});
|
||||
const context = createContext<() => void>(() => { });
|
||||
|
||||
function Article({ resource }: { resource: ResourceDetails }) {
|
||||
return (
|
||||
<>
|
||||
<article>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ node, ...props }) => {
|
||||
if (
|
||||
typeof props.children === "object" &&
|
||||
(props.children as ReactElement).type === "strong"
|
||||
) {
|
||||
// @ts-ignore
|
||||
const child = (
|
||||
props.children as ReactElement
|
||||
).props.children.toString() as string;
|
||||
if (child.startsWith("<iframe")) {
|
||||
<article>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ node, ...props }) => {
|
||||
if (
|
||||
typeof props.children === "object" &&
|
||||
(props.children as ReactElement).type === "strong"
|
||||
) {
|
||||
// @ts-ignore
|
||||
let html = child;
|
||||
let splits = html.split(" ");
|
||||
splits = splits.filter((s: string) => {
|
||||
return !(
|
||||
s.startsWith("width") ||
|
||||
s.startsWith("height") ||
|
||||
s.startsWith("class") ||
|
||||
s.startsWith("style")
|
||||
);
|
||||
});
|
||||
html = splits.join(" ");
|
||||
return (
|
||||
<div
|
||||
className={`w-full my-3 max-w-xl rounded-xl overflow-clip ${html.includes("youtube") ? "aspect-video" : "h-48 sm:h-64"}`}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: html,
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
typeof props.children === "object" &&
|
||||
// @ts-ignore
|
||||
props.children?.props &&
|
||||
// @ts-ignore
|
||||
props.children?.props.href
|
||||
) {
|
||||
const a = props.children as ReactElement;
|
||||
const childProps = a.props as any;
|
||||
const href = childProps.href as string;
|
||||
// @ts-ignore
|
||||
if (childProps.children?.length === 2) {
|
||||
// @ts-ignore
|
||||
const first = childProps.children[0] as ReactNode;
|
||||
// @ts-ignore
|
||||
const second = childProps.children[1] as ReactNode;
|
||||
|
||||
if (
|
||||
typeof first === "object" &&
|
||||
(typeof second === "string" || typeof second === "object")
|
||||
) {
|
||||
const img = first as ReactElement;
|
||||
const child = (
|
||||
props.children as ReactElement
|
||||
).props.children.toString() as string;
|
||||
if (child.startsWith("<iframe")) {
|
||||
// @ts-ignore
|
||||
if (img.type === "img") {
|
||||
return (
|
||||
<a
|
||||
className={
|
||||
"inline-block card shadow bg-base-100 no-underline hover:shadow-md transition-shadow my-2"
|
||||
}
|
||||
target={"_blank"}
|
||||
href={href}
|
||||
>
|
||||
<figure className={"max-h-96 min-w-48 min-h-24"}>
|
||||
{img}
|
||||
</figure>
|
||||
<div className={"card-body text-base-content text-lg"}>
|
||||
<div className={"flex items-center"}>
|
||||
<span className={"flex-1"}>{second}</span>
|
||||
<span>
|
||||
<MdOutlineOpenInNew size={24} />
|
||||
</span>
|
||||
let html = child;
|
||||
let splits = html.split(" ");
|
||||
splits = splits.filter((s: string) => {
|
||||
return !(
|
||||
s.startsWith("width") ||
|
||||
s.startsWith("height") ||
|
||||
s.startsWith("class") ||
|
||||
s.startsWith("style")
|
||||
);
|
||||
});
|
||||
html = splits.join(" ");
|
||||
return (
|
||||
<div
|
||||
className={`w-full my-3 max-w-xl rounded-xl overflow-clip ${html.includes("youtube") ? "aspect-video" : "h-48 sm:h-64"}`}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: html,
|
||||
}}
|
||||
></div>
|
||||
);
|
||||
}
|
||||
} else if (
|
||||
typeof props.children === "object" &&
|
||||
// @ts-ignore
|
||||
props.children?.props &&
|
||||
// @ts-ignore
|
||||
props.children?.props.href
|
||||
) {
|
||||
const a = props.children as ReactElement;
|
||||
const childProps = a.props as any;
|
||||
const href = childProps.href as string;
|
||||
// @ts-ignore
|
||||
if (childProps.children?.length === 2) {
|
||||
// @ts-ignore
|
||||
const first = childProps.children[0] as ReactNode;
|
||||
// @ts-ignore
|
||||
const second = childProps.children[1] as ReactNode;
|
||||
|
||||
if (
|
||||
typeof first === "object" &&
|
||||
(typeof second === "string" || typeof second === "object")
|
||||
) {
|
||||
const img = first as ReactElement;
|
||||
// @ts-ignore
|
||||
if (img.type === "img") {
|
||||
return (
|
||||
<a
|
||||
className={
|
||||
"inline-block card shadow bg-base-100 no-underline hover:shadow-md transition-shadow my-2"
|
||||
}
|
||||
target={"_blank"}
|
||||
href={href}
|
||||
>
|
||||
<figure className={"max-h-96 min-w-48 min-h-24"}>
|
||||
{img}
|
||||
</figure>
|
||||
<div className={"card-body text-base-content text-lg"}>
|
||||
<div className={"flex items-center"}>
|
||||
<span className={"flex-1"}>{second}</span>
|
||||
<span>
|
||||
<MdOutlineOpenInNew size={24} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (href.startsWith("https://store.steampowered.com/app/")) {
|
||||
const appId = href
|
||||
.substring("https://store.steampowered.com/app/".length)
|
||||
.split("/")[0];
|
||||
if (!Number.isNaN(Number(appId))) {
|
||||
return (
|
||||
<div className={"max-w-xl h-52 sm:h-48 my-2"}>
|
||||
<iframe
|
||||
className={"scheme-light"}
|
||||
src={`https://store.steampowered.com/widget/${appId}/`}
|
||||
></iframe>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (href.startsWith("https://store.steampowered.com/app/")) {
|
||||
const appId = href
|
||||
.substring("https://store.steampowered.com/app/".length)
|
||||
.split("/")[0];
|
||||
if (!Number.isNaN(Number(appId))) {
|
||||
return (
|
||||
<div className={"max-w-xl h-52 sm:h-48 my-2"}>
|
||||
<iframe
|
||||
className={"scheme-light"}
|
||||
src={`https://store.steampowered.com/widget/${appId}/`}
|
||||
></iframe>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return <p {...props}>{props.children}</p>;
|
||||
},
|
||||
a: ({ node, ...props }) => {
|
||||
const href = props.href as string;
|
||||
const origin = window.location.origin;
|
||||
return <p {...props}>{props.children}</p>;
|
||||
},
|
||||
a: ({ node, ...props }) => {
|
||||
const href = props.href as string;
|
||||
const origin = window.location.origin;
|
||||
|
||||
if (href.startsWith(origin) || href.startsWith("/")) {
|
||||
let path = href;
|
||||
if (path.startsWith(origin)) {
|
||||
path = path.substring(origin.length);
|
||||
}
|
||||
const content = props.children?.toString();
|
||||
if (path.startsWith("/resources/")) {
|
||||
const id = path.substring("/resources/".length);
|
||||
for (const r of resource.related ?? []) {
|
||||
if (r.id.toString() === id) {
|
||||
return <RelatedResourceCard r={r} content={content} />;
|
||||
if (href.startsWith(origin) || href.startsWith("/")) {
|
||||
let path = href;
|
||||
if (path.startsWith(origin)) {
|
||||
path = path.substring(origin.length);
|
||||
}
|
||||
const content = props.children?.toString();
|
||||
if (path.startsWith("/resources/")) {
|
||||
const id = path.substring("/resources/".length);
|
||||
for (const r of resource.related ?? []) {
|
||||
if (r.id.toString() === id) {
|
||||
return <RelatedResourceCard r={r} content={content} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return <a target={"_blank"} {...props}></a>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{resource.article.replaceAll("\n", " \n")}
|
||||
</Markdown>
|
||||
</article>
|
||||
<div className="border-b border-base-300 h-8"></div>
|
||||
<Characters charactors={resource.charactors} />
|
||||
return <a target={"_blank"} {...props}></a>;
|
||||
},
|
||||
}}
|
||||
>
|
||||
{resource.article.replaceAll("\n", " \n")}
|
||||
</Markdown>
|
||||
</article>
|
||||
<div className="border-b border-base-300 h-8"></div>
|
||||
<Characters characters={resource.characters} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -801,6 +808,11 @@ function FileTile({ file }: { file: RFile }) {
|
||||
{file.storage_name}
|
||||
</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)}
|
||||
@@ -874,6 +886,8 @@ function CloudflarePopup({ file }: { file: RFile }) {
|
||||
|
||||
const [isLoading, setLoading] = useState(true);
|
||||
|
||||
const [downloadToken, setDownloadToken] = useState<string | null>(null);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -883,13 +897,15 @@ function CloudflarePopup({ file }: { file: RFile }) {
|
||||
{isLoading ? (
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
) : 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"}>
|
||||
<Turnstile
|
||||
siteKey={app.cloudflareTurnstileSiteKey!}
|
||||
@@ -897,17 +913,29 @@ function CloudflarePopup({ file }: { file: RFile }) {
|
||||
setLoading(false);
|
||||
}}
|
||||
onSuccess={(token) => {
|
||||
closePopup();
|
||||
const link = network.getFileDownloadLink(file.id, token);
|
||||
window.open(link, "_blank");
|
||||
setDownloadToken(token);
|
||||
}}
|
||||
></Turnstile>
|
||||
</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(
|
||||
"Please check your network if the verification takes too long or the captcha does not appear.",
|
||||
)}
|
||||
</p>
|
||||
</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -919,11 +947,72 @@ function Files({
|
||||
files: RFile[];
|
||||
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 (
|
||||
<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>;
|
||||
})}
|
||||
{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>
|
||||
{(app.canUpload() || (app.allowNormalUserUpload && app.isLoggedIn())) && (
|
||||
<div className={"flex flex-row-reverse"}>
|
||||
@@ -954,6 +1043,10 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
|
||||
const [storage, setStorage] = useState<Storage | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
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>("");
|
||||
|
||||
@@ -985,11 +1078,38 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
|
||||
setSubmitting(false);
|
||||
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(
|
||||
filename,
|
||||
description,
|
||||
resourceId,
|
||||
redirectUrl,
|
||||
fileSizeNum,
|
||||
md5,
|
||||
tag,
|
||||
);
|
||||
if (res.success) {
|
||||
setSubmitting(false);
|
||||
@@ -1014,6 +1134,7 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
|
||||
resourceId,
|
||||
storage.id,
|
||||
description,
|
||||
tag,
|
||||
() => {
|
||||
if (mounted.current) {
|
||||
reload();
|
||||
@@ -1046,6 +1167,7 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
|
||||
description,
|
||||
resourceId,
|
||||
storage.id,
|
||||
tag,
|
||||
);
|
||||
if (res.success) {
|
||||
setSubmitting(false);
|
||||
@@ -1119,15 +1241,7 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
|
||||
<p className={"text-sm font-bold p-2"}>{t("Type")}</p>
|
||||
<form className="filter mb-2">
|
||||
<input
|
||||
className="btn btn-square"
|
||||
type="reset"
|
||||
value="×"
|
||||
onClick={() => {
|
||||
setFileType(null);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="btn text-sm"
|
||||
className="btn"
|
||||
type="radio"
|
||||
name="type"
|
||||
aria-label={t("Redirect")}
|
||||
@@ -1136,7 +1250,7 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="btn text-sm"
|
||||
className="btn"
|
||||
type="radio"
|
||||
name="type"
|
||||
aria-label={t("Upload")}
|
||||
@@ -1145,7 +1259,7 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="btn text-sm"
|
||||
className="btn"
|
||||
type="radio"
|
||||
name="type"
|
||||
aria-label={t("File Url")}
|
||||
@@ -1153,6 +1267,14 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
|
||||
setFileType(FileType.serverTask);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="btn btn-square"
|
||||
type="reset"
|
||||
value="×"
|
||||
onClick={() => {
|
||||
setFileType(null);
|
||||
}}
|
||||
/>
|
||||
</form>
|
||||
|
||||
{fileType === FileType.redirect && (
|
||||
@@ -1183,6 +1305,45 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
|
||||
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);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1239,6 +1400,14 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
|
||||
setDescription(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="input w-full my-2"
|
||||
placeholder={t("Tag") + " (" + t("Optional") + ")"}
|
||||
onChange={(e) => {
|
||||
setTag(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1311,6 +1480,14 @@ function CreateFileDialog({ resourceId }: { resourceId: number }) {
|
||||
setDescription(e.target.value);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="input w-full my-2"
|
||||
placeholder={t("Tag") + " (" + t("Optional") + ")"}
|
||||
onChange={(e) => {
|
||||
setTag(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1340,6 +1517,8 @@ function UpdateFileInfoDialog({ file }: { file: RFile }) {
|
||||
|
||||
const [description, setDescription] = useState(file.description);
|
||||
|
||||
const [tag, setTag] = useState(file.tag || "");
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const reload = useContext(context);
|
||||
@@ -1349,7 +1528,7 @@ function UpdateFileInfoDialog({ file }: { file: RFile }) {
|
||||
return;
|
||||
}
|
||||
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(
|
||||
`update_file_info_dialog_${file.id}`,
|
||||
) as HTMLDialogElement;
|
||||
@@ -1397,6 +1576,12 @@ function UpdateFileInfoDialog({ file }: { file: RFile }) {
|
||||
value={description}
|
||||
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">
|
||||
<form method="dialog">
|
||||
<button className="btn btn-ghost">{t("Close")}</button>
|
||||
@@ -1886,246 +2071,68 @@ function CollectionSelector({
|
||||
);
|
||||
}
|
||||
|
||||
function Gallery({ images, nsfw }: { images: number[], nsfw: number[] }) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [direction, setDirection] = useState(0); // 方向:1=向右,-1=向左
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [width, setWidth] = useState(0);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const updateWidth = () => {
|
||||
if (containerRef.current) {
|
||||
setWidth(containerRef.current.clientWidth);
|
||||
}
|
||||
};
|
||||
|
||||
updateWidth();
|
||||
window.addEventListener("resize", updateWidth);
|
||||
return () => {
|
||||
window.removeEventListener("resize", updateWidth);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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 (
|
||||
<>
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
onClick={() => {
|
||||
dialogRef.current?.close();
|
||||
}}
|
||||
className="modal"
|
||||
>
|
||||
<div className="modal-box w-full h-full max-h-screen max-w-screen p-4 bg-transparent shadow-none flex items-center justify-center">
|
||||
<motion.img
|
||||
src={network.getImageUrl(images[currentIndex])}
|
||||
alt=""
|
||||
className="max-w-full max-h-full object-contain rounded-xl"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
</div>
|
||||
</dialog>
|
||||
<div
|
||||
className="relative w-full overflow-hidden rounded-xl bg-base-100-tr82 shadow-sm"
|
||||
style={{ aspectRatio: "16/9" }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{/* 图片区域 */}
|
||||
<div ref={containerRef} className="w-full h-full relative" onClick={() => {
|
||||
dialogRef.current?.showModal();
|
||||
}}>
|
||||
{width > 0 && (
|
||||
<AnimatePresence initial={false} custom={direction} mode="sync">
|
||||
<motion.div
|
||||
key={currentIndex}
|
||||
className="absolute w-full h-full object-contain"
|
||||
variants={{
|
||||
enter: (dir: number) => ({
|
||||
x: dir > 0 ? width : -width,
|
||||
}),
|
||||
center: {
|
||||
x: 0,
|
||||
transition: { duration: 0.3, ease: "linear" },
|
||||
},
|
||||
exit: (dir: number) => ({
|
||||
x: dir > 0 ? -width : width,
|
||||
transition: { duration: 0.3, ease: "linear" },
|
||||
}),
|
||||
}}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
custom={direction}
|
||||
>
|
||||
<GalleryImage
|
||||
src={network.getImageUrl(images[currentIndex])}
|
||||
nfsw={nsfw.includes(images[currentIndex])}
|
||||
/>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 左右按钮 */}
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
className={`absolute left-2 top-1/2 -translate-y-1/2 transition-opacity hover:cursor-pointer ${
|
||||
isHovered ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
onClick={goToPrevious}
|
||||
>
|
||||
<MdOutlineChevronLeft size={28} />
|
||||
</button>
|
||||
<button
|
||||
className={`absolute right-2 top-1/2 -translate-y-1/2 transition-opacity hover:cursor-pointer ${
|
||||
isHovered ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
onClick={goToNext}
|
||||
>
|
||||
<MdOutlineChevronRight size={28} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 底部指示器 */}
|
||||
{images.length > 1 && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||
{showDots ? (
|
||||
/* 圆点指示器 */
|
||||
<div className="flex gap-2">
|
||||
{images.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`w-2 h-2 rounded-full transition-all ${
|
||||
index === currentIndex
|
||||
? "bg-primary w-4"
|
||||
: "bg-base-content/30 hover:bg-base-content/50"
|
||||
}`}
|
||||
onClick={() => goToIndex(index)}
|
||||
aria-label={`Go to image ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
/* 数字指示器 */
|
||||
<div className="bg-base-100/20 px-2 py-1 rounded-full text-xs">
|
||||
{currentIndex + 1} / {images.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function GalleryImage({src, nfsw}: {src: string, nfsw: boolean}) {
|
||||
const [show, setShow] = useState(!nfsw);
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
className={`w-full h-full object-contain transition-all duration-300 ${!show ? 'blur-xl' : ''}`}
|
||||
/>
|
||||
{!show && (
|
||||
<>
|
||||
<div className="absolute inset-0 bg-base-content/20 cursor-pointer" onClick={(event) => {
|
||||
setShow(true)
|
||||
event.stopPropagation();
|
||||
}} />
|
||||
<div className="absolute top-4 left-4">
|
||||
<Badge className="badge-error shadow-lg">
|
||||
NSFW
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Characters({ charactors }: { charactors: CharacterParams[] }) {
|
||||
function Characters({ characters }: { characters: CharacterParams[] }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!charactors || charactors.length === 0) {
|
||||
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-4">
|
||||
{charactors.map((charactor, index) => (
|
||||
<CharacterCard key={index} charactor={charactor} />
|
||||
<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({ charactor }: { charactor: CharacterParams }) {
|
||||
function CharacterCard({ character }: { character: CharacterParams }) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleCVClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (charactor.cv) {
|
||||
navigate(`/search?keyword=${encodeURIComponent(charactor.cv)}`);
|
||||
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={network.getImageUrl(charactor.image)}
|
||||
alt={charactor.name}
|
||||
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-2 py-2 border border-base-100/40 rounded-lg bg-base-100/60">
|
||||
<h4 className="font-semibold text-sm leading-tight line-clamp border border-transparent px-1">
|
||||
{charactor.name}
|
||||
|
||||
<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>
|
||||
|
||||
{charactor.cv && (
|
||||
|
||||
{character.cv && (
|
||||
<button
|
||||
onClick={handleCVClick}
|
||||
className="hover:bg-base-200/80 px-1 border border-transparent hover:border-base-300/50 rounded-sm text-xs transition-colors cursor-pointer"
|
||||
className="hover:bg-base-200/80 border border-transparent hover:border-base-300/50 rounded-sm text-xs transition-colors cursor-pointer"
|
||||
>
|
||||
CV: {charactor.cv}
|
||||
CV: {character.cv}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,8 +8,8 @@ export default defineConfig({
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3000",
|
||||
// target: "https://res.nyne.dev",
|
||||
// target: "http://localhost:3000",
|
||||
target: "https://nysoure.com",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"https://www.moyu.moe": {
|
||||
|
||||
18
go.mod
18
go.mod
@@ -14,13 +14,17 @@ require (
|
||||
github.com/blevesearch/bleve v1.0.14
|
||||
github.com/chai2010/webp v1.4.0
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/jlaffaye/ftp v0.2.0
|
||||
github.com/redis/go-redis/v9 v9.17.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/wgh136/cloudflare-error-page v0.0.1
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/RoaringBitmap/roaring v0.4.23 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
|
||||
github.com/blevesearch/mmap-go v1.0.2 // indirect
|
||||
github.com/blevesearch/segment v0.9.0 // indirect
|
||||
@@ -30,17 +34,28 @@ require (
|
||||
github.com/blevesearch/zap/v13 v13.0.6 // indirect
|
||||
github.com/blevesearch/zap/v14 v14.0.5 // 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/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/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/golang/protobuf v1.3.2 // indirect
|
||||
github.com/golang/protobuf v1.5.0 // indirect
|
||||
github.com/golang/snappy v0.0.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.0.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/mschoch/smat v0.2.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/steveyen/gtreap v0.1.0 // indirect
|
||||
github.com/willf/bitset v1.1.10 // indirect
|
||||
go.etcd.io/bbolt v1.3.5 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -69,6 +84,7 @@ require (
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.28 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.65.0 // indirect
|
||||
|
||||
56
go.sum
56
go.sum
@@ -6,6 +6,8 @@ github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/blevesearch/bleve v1.0.14 h1:Q8r+fHTt35jtGXJUM0ULwM3Tzg+MRfyai4ZkWDy2xO4=
|
||||
github.com/blevesearch/bleve v1.0.14/go.mod h1:e/LJTr+E7EaoVdkQZTfoz7dt4KoDNvDbLb8MSKuNTLQ=
|
||||
github.com/blevesearch/blevex v1.0.0 h1:pnilj2Qi3YSEGdWgLj1Pn9Io7ukfXPoQcpAI1Bv8n/o=
|
||||
@@ -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/v15 v15.0.3 h1:Ylj8Oe+mo0P25tr9iLPp33lN6d4qcztGjaIsP51UxaY=
|
||||
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/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
|
||||
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/go.mod h1:FcwrEivFpNi24R3jLOs3n+fs5RnuQnQqCLBJ1uAg1W4=
|
||||
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/cznic/b v0.0.0-20181122101859-a26611c4d92d h1:SwD98825d6bdB+pEuTxWOXiSjBrHdOl/UVp75eI7JT8=
|
||||
github.com/cznic/b v0.0.0-20181122101859-a26611c4d92d/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8=
|
||||
github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM=
|
||||
@@ -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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
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/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
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/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
|
||||
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
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/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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
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/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
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/go.mod h1:Q6Qx+uH3RAqyK4rFQroq9RL7mdkABMcfhEI+nNuzMJQ=
|
||||
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/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kljensen/snowball v0.6.0/go.mod h1:27N7E8fVU5H68RlUmnWwZCfxgt4POBJfENGMvNRhldw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
@@ -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.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
|
||||
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
@@ -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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/redis/go-redis/v9 v9.17.0 h1:K6E+ZlYN95KSMmZeEQPbU/c++wfmEvfFB17yEAq/VhM=
|
||||
github.com/redis/go-redis/v9 v9.17.0/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
@@ -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/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8=
|
||||
github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4=
|
||||
github.com/wgh136/cloudflare-error-page v0.0.1 h1:OZ2JWfEF85JlwSVE71Jx0f+++HkotvZZ1Fb6YUyoFcQ=
|
||||
github.com/wgh136/cloudflare-error-page v0.0.1/go.mod h1:/0dw1xavAlZLFlJla5qeLIh1/hv0irtR8oN7SBVMD8s=
|
||||
github.com/willf/bitset v1.1.10 h1:NotGKqX0KwQ72NUzqrjZq5ipPNDQex9lo3WpaS8L2sc=
|
||||
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
@@ -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=
|
||||
go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
|
||||
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
@@ -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/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
|
||||
12
main.go
12
main.go
@@ -6,7 +6,9 @@ import (
|
||||
"nysoure/server/middleware"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/adaptor"
|
||||
"github.com/gofiber/fiber/v3/middleware/logger"
|
||||
prom "github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -19,6 +21,8 @@ func main() {
|
||||
Format: "[${ip}]:${port} ${status} - ${method} ${path}\n",
|
||||
}))
|
||||
|
||||
app.Use(middleware.UnsupportedRegionMiddleware)
|
||||
|
||||
app.Use(middleware.ErrorHandler)
|
||||
|
||||
app.Use(middleware.RealUserMiddleware)
|
||||
@@ -27,6 +31,10 @@ func main() {
|
||||
|
||||
app.Use(middleware.FrontendMiddleware)
|
||||
|
||||
app.Use(middleware.StatMiddleware)
|
||||
|
||||
app.Get("/metrics", adaptor.HTTPHandler(prom.Handler()))
|
||||
|
||||
apiG := app.Group("/api")
|
||||
{
|
||||
api.AddUserRoutes(apiG)
|
||||
@@ -38,7 +46,9 @@ func main() {
|
||||
api.AddCommentRoutes(apiG)
|
||||
api.AddConfigRoutes(apiG)
|
||||
api.AddActivityRoutes(apiG)
|
||||
api.AddCollectionRoutes(apiG) // 新增
|
||||
api.AddCollectionRoutes(apiG)
|
||||
api.AddProxyRoutes(apiG)
|
||||
api.AddDevAPI(apiG)
|
||||
}
|
||||
|
||||
log.Fatal(app.Listen(":3000"))
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"nysoure/server/model"
|
||||
"nysoure/server/service"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
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) {
|
||||
router.Get("/activity", handleGetActivity)
|
||||
notificationrouter := router.Group("/notification")
|
||||
{
|
||||
notificationrouter.Get("/", handleGetUserNotifications)
|
||||
notificationrouter.Post("/reset", handleResetUserNotificationsCount)
|
||||
notificationrouter.Get("/count", handleGetUserNotificationsCount)
|
||||
}
|
||||
}
|
||||
|
||||
62
server/api/dev.go
Normal file
62
server/api/dev.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"nysoure/server/middleware"
|
||||
"nysoure/server/model"
|
||||
"nysoure/server/service"
|
||||
"nysoure/server/stat"
|
||||
"nysoure/server/utils"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -43,6 +44,7 @@ func initUpload(c fiber.Ctx) error {
|
||||
FileSize int64 `json:"file_size"`
|
||||
ResourceID uint `json:"resource_id"`
|
||||
StorageID uint `json:"storage_id"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
var req InitUploadRequest
|
||||
@@ -50,7 +52,10 @@ func initUpload(c fiber.Ctx) error {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -136,6 +141,9 @@ func createRedirectFile(c fiber.Ctx) error {
|
||||
Description string `json:"description"`
|
||||
ResourceID uint `json:"resource_id"`
|
||||
RedirectURL string `json:"redirect_url"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
Md5 string `json:"md5"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
var req CreateRedirectFileRequest
|
||||
@@ -143,7 +151,11 @@ func createRedirectFile(c fiber.Ctx) error {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -172,6 +184,7 @@ func updateFile(c fiber.Ctx) error {
|
||||
type UpdateFileRequest struct {
|
||||
Filename string `json:"filename"`
|
||||
Description string `json:"description"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
var req UpdateFileRequest
|
||||
@@ -179,7 +192,10 @@ func updateFile(c fiber.Ctx) error {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -210,7 +226,23 @@ func downloadFile(c fiber.Ctx) error {
|
||||
return err
|
||||
}
|
||||
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{
|
||||
"path": s,
|
||||
@@ -221,6 +253,7 @@ func downloadFile(c fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return model.NewInternalServerError("Failed to generate download token")
|
||||
}
|
||||
stat.RecordDownload()
|
||||
return c.Redirect().Status(fiber.StatusFound).To(fmt.Sprintf("%s/api/files/download/local?token=%s", c.BaseURL(), token))
|
||||
}
|
||||
|
||||
@@ -259,13 +292,18 @@ func createServerDownloadTask(c fiber.Ctx) error {
|
||||
Description string `json:"description"`
|
||||
ResourceID uint `json:"resource_id"`
|
||||
StorageID uint `json:"storage_id"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
|
||||
var req InitUploadRequest
|
||||
if err := c.Bind().Body(&req); err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
127
server/api/proxy.go
Normal file
127
server/api/proxy.go
Normal 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)
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func handleListResources(c fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return model.NewRequestError("Invalid sort parameter")
|
||||
}
|
||||
if sortInt < 0 || sortInt > 5 {
|
||||
if sortInt < 0 || sortInt > 7 {
|
||||
return model.NewRequestError("Sort parameter out of range")
|
||||
}
|
||||
sort := model.RSort(sortInt)
|
||||
@@ -282,22 +282,230 @@ func handleGetPinnedResources(c fiber.Ctx) error {
|
||||
})
|
||||
}
|
||||
|
||||
func handleGetCharactorsFromVndb(c fiber.Ctx) error {
|
||||
func handleGetCharactersFromVndb(c fiber.Ctx) error {
|
||||
vnID := c.Query("vnid")
|
||||
if vnID == "" {
|
||||
return model.NewRequestError("VNDB ID is required")
|
||||
}
|
||||
characters, err := service.GetCharactorsFromVndb(vnID)
|
||||
uid, ok := c.Locals("uid").(uint)
|
||||
if !ok {
|
||||
return model.NewUnAuthorizedError("You must be logged in to get characters from VNDB")
|
||||
}
|
||||
characters, err := service.GetCharactersFromVndb(vnID, uid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Status(fiber.StatusOK).JSON(model.Response[[]service.CharactorParams]{
|
||||
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, ¶ms)
|
||||
if err != nil {
|
||||
return model.NewRequestError("Invalid request body")
|
||||
}
|
||||
|
||||
uid, ok := c.Locals("uid").(uint)
|
||||
if !ok {
|
||||
return model.NewUnAuthorizedError("You must be logged in to 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, ¶ms)
|
||||
if err != nil {
|
||||
return model.NewRequestError("Invalid request body")
|
||||
}
|
||||
|
||||
uid, ok := c.Locals("uid").(uint)
|
||||
if !ok {
|
||||
return model.NewUnAuthorizedError("You must be logged in to 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) {
|
||||
resource := api.Group("/resource")
|
||||
{
|
||||
@@ -306,11 +514,15 @@ func AddResourceRoutes(api fiber.Router) {
|
||||
resource.Get("/", handleListResources)
|
||||
resource.Get("/random", handleGetRandomResource)
|
||||
resource.Get("/pinned", handleGetPinnedResources)
|
||||
resource.Get("/vndb/characters", handleGetCharactorsFromVndb)
|
||||
resource.Get("/vndb/characters", handleGetCharactersFromVndb)
|
||||
resource.Get("/characters/low-resolution", handleGetLowResolutionCharacters)
|
||||
resource.Get("/images/low-resolution", handleGetLowResolutionResourceImages)
|
||||
resource.Get("/:id", handleGetResource)
|
||||
resource.Delete("/:id", handleDeleteResource)
|
||||
resource.Get("/tag/:tag", handleListResourcesWithTag)
|
||||
resource.Get("/user/:username", handleGetResourcesWithUser)
|
||||
resource.Post("/:id", handleUpdateResource)
|
||||
resource.Put("/:resourceId/character/:characterId/image", handleUpdateCharacterImage)
|
||||
resource.Put("/:resourceId/image/:oldImageId", handleUpdateResourceImage)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¶ms); 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 {
|
||||
storages, err := service.ListStorages()
|
||||
if err != nil {
|
||||
@@ -136,6 +167,7 @@ func AddStorageRoutes(r fiber.Router) {
|
||||
s := r.Group("storage")
|
||||
s.Post("/s3", handleCreateS3Storage)
|
||||
s.Post("/local", handleCreateLocalStorage)
|
||||
s.Post("/ftp", handleCreateFTPStorage)
|
||||
s.Get("/", handleListStorages)
|
||||
s.Delete("/:id", handleDeleteStorage)
|
||||
s.Put("/:id/default", handleSetDefaultStorage)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"nysoure/server/middleware"
|
||||
"nysoure/server/model"
|
||||
"nysoure/server/service"
|
||||
"nysoure/server/stat"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -24,6 +25,7 @@ func handleUserRegister(c fiber.Ctx) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stat.RecordRegister()
|
||||
return c.Status(fiber.StatusOK).JSON(model.Response[model.UserViewWithToken]{
|
||||
Success: true,
|
||||
Data: user,
|
||||
|
||||
42
server/cache/cache.go
vendored
Normal file
42
server/cache/cache.go
vendored
Normal 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()
|
||||
}
|
||||
@@ -2,9 +2,10 @@ package dao
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"gorm.io/gorm"
|
||||
"nysoure/server/model"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func AddNewResourceActivity(userID, resourceID uint) error {
|
||||
@@ -42,13 +43,20 @@ func AddUpdateResourceActivity(userID, resourceID uint) error {
|
||||
return db.Create(activity).Error
|
||||
}
|
||||
|
||||
func AddNewCommentActivity(userID, commentID uint) error {
|
||||
activity := &model.Activity{
|
||||
UserID: userID,
|
||||
Type: model.ActivityTypeNewComment,
|
||||
RefID: commentID,
|
||||
}
|
||||
return db.Create(activity).Error
|
||||
func AddNewCommentActivity(userID, commentID, notifyTo uint) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
activity := &model.Activity{
|
||||
UserID: userID,
|
||||
Type: model.ActivityTypeNewComment,
|
||||
RefID: commentID,
|
||||
NotifyTo: notifyTo,
|
||||
}
|
||||
err := tx.Create(activity).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&model.User{}).Where("id = ?", notifyTo).UpdateColumn("unread_notifications_count", gorm.Expr("unread_notifications_count + ?", 1)).Error
|
||||
})
|
||||
}
|
||||
|
||||
func AddNewFileActivity(userID, fileID uint) error {
|
||||
@@ -82,3 +90,18 @@ func GetActivityList(offset, limit int) ([]model.Activity, int, error) {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func init() {
|
||||
&model.Activity{},
|
||||
&model.Collection{},
|
||||
&model.CollectionResource{},
|
||||
&model.Charactor{},
|
||||
&model.Character{},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"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
|
||||
uf := &model.UploadingFile{
|
||||
Filename: filename,
|
||||
@@ -22,6 +22,7 @@ func CreateUploadingFile(filename string, description string, fileSize int64, bl
|
||||
TargetResourceID: resourceID,
|
||||
TargetStorageID: storageID,
|
||||
UserID: userID,
|
||||
Tag: tag,
|
||||
}
|
||||
if err := db.Create(uf).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -73,7 +74,7 @@ func GetUploadingFilesOlderThan(time time.Time) ([]model.UploadingFile, error) {
|
||||
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 == "" {
|
||||
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,
|
||||
UserID: userID,
|
||||
Hash: hash,
|
||||
Tag: tag,
|
||||
}
|
||||
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
@@ -171,7 +173,7 @@ func DeleteFile(id string) error {
|
||||
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{}
|
||||
if err := db.Where("uuid = ?", id).First(f).Error; err != nil {
|
||||
return nil, err
|
||||
@@ -182,6 +184,9 @@ func UpdateFile(id string, filename string, description string) (*model.File, er
|
||||
if description != "" {
|
||||
f.Description = description
|
||||
}
|
||||
if tag != "" {
|
||||
f.Tag = tag
|
||||
}
|
||||
if err := db.Save(f).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, model.NewNotFoundError("file not found")
|
||||
|
||||
@@ -47,7 +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 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 charactors WHERE image_id = images.id)").
|
||||
Where("NOT EXISTS (SELECT 1 FROM characters WHERE image_id = images.id)").
|
||||
Where("created_at < ?", oneDayAgo).
|
||||
Find(&images).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
|
||||
@@ -2,8 +2,10 @@ package dao
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"nysoure/server/model"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
@@ -16,14 +18,18 @@ import (
|
||||
func CreateResource(r model.Resource) (model.Resource, error) {
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
r.ModifiedTime = time.Now()
|
||||
charactors := r.Charactors
|
||||
r.Charactors = nil
|
||||
characters := r.Characters
|
||||
r.Characters = nil
|
||||
err := tx.Create(&r).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range charactors {
|
||||
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
|
||||
}
|
||||
@@ -50,7 +56,7 @@ func GetResourceByID(id uint) (model.Resource, error) {
|
||||
Preload("Files").
|
||||
Preload("Files.User").
|
||||
Preload("Files.Storage").
|
||||
Preload("Charactors").
|
||||
Preload("Characters").
|
||||
First(&r, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.Resource{}, model.NewNotFoundError("Resource not found")
|
||||
@@ -93,6 +99,10 @@ func GetResourceList(page, pageSize int, sort model.RSort) ([]model.Resource, in
|
||||
order = "downloads ASC"
|
||||
case model.RSortDownloadsDesc:
|
||||
order = "downloads DESC"
|
||||
case model.RSortReleaseDateAsc:
|
||||
order = "release_date ASC"
|
||||
case model.RSortReleaseDateDesc:
|
||||
order = "release_date DESC"
|
||||
default:
|
||||
order = "modified_time DESC" // Default sort order
|
||||
}
|
||||
@@ -111,14 +121,14 @@ func UpdateResource(r model.Resource) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
images := r.Images
|
||||
tags := r.Tags
|
||||
charactors := r.Charactors
|
||||
r.Charactors = nil
|
||||
characters := r.Characters
|
||||
r.Characters = nil
|
||||
r.Images = nil
|
||||
r.Tags = nil
|
||||
r.Files = nil
|
||||
r.ModifiedTime = time.Now()
|
||||
oldCharactors := []model.Charactor{}
|
||||
if err := db.Model(&model.Charactor{}).Where("resource_id = ?", r.ID).Find(&oldCharactors).Error; err != nil {
|
||||
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 {
|
||||
@@ -130,10 +140,10 @@ func UpdateResource(r model.Resource) error {
|
||||
if err := db.Model(&r).Association("Tags").Replace(tags); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range oldCharactors {
|
||||
for _, c := range oldCharacters {
|
||||
shouldDelete := true
|
||||
for _, nc := range charactors {
|
||||
if c.ID == nc.ID {
|
||||
for _, nc := range characters {
|
||||
if c.Equal(&nc) {
|
||||
shouldDelete = false
|
||||
break
|
||||
}
|
||||
@@ -144,9 +154,9 @@ func UpdateResource(r model.Resource) error {
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range charactors {
|
||||
for _, c := range characters {
|
||||
shouldAdd := true
|
||||
for _, oc := range oldCharactors {
|
||||
for _, oc := range oldCharacters {
|
||||
if c.Equal(&oc) {
|
||||
shouldAdd = false
|
||||
break
|
||||
@@ -155,6 +165,10 @@ func UpdateResource(r model.Resource) error {
|
||||
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
|
||||
}
|
||||
@@ -517,3 +531,191 @@ func CountResources() (int64, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ package dao
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"gorm.io/gorm"
|
||||
"nysoure/server/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
if exists {
|
||||
return user, &model.RequestError{
|
||||
Message: "User already exists",
|
||||
}
|
||||
return user, model.NewRequestError("User already exists")
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
return user, err
|
||||
@@ -132,3 +131,15 @@ func DeleteUser(id uint) 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
|
||||
}
|
||||
|
||||
22
server/middleware/dev_middleware.go
Normal file
22
server/middleware/dev_middleware.go
Normal 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()
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"nysoure/server/model"
|
||||
|
||||
"github.com/gofiber/fiber/v3/log"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
@@ -13,73 +12,22 @@ import (
|
||||
func ErrorHandler(c fiber.Ctx) error {
|
||||
err := c.Next()
|
||||
if err != nil {
|
||||
var requestErr *model.RequestError
|
||||
var unauthorizedErr *model.UnAuthorizedError
|
||||
var notFoundErr *model.NotFoundError
|
||||
var fiberErr *fiber.Error
|
||||
if errors.As(err, &requestErr) {
|
||||
log.Error("Request Error: ", err)
|
||||
return c.Status(fiber.StatusBadRequest).JSON(model.Response[any]{
|
||||
Success: false,
|
||||
Data: nil,
|
||||
Message: requestErr.Error(),
|
||||
})
|
||||
} else if errors.As(err, &unauthorizedErr) {
|
||||
log.Error("Unauthorized Error: ", err)
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(model.Response[any]{
|
||||
Success: false,
|
||||
Data: nil,
|
||||
Message: unauthorizedErr.Error(),
|
||||
})
|
||||
} else if errors.As(err, ¬FoundErr) {
|
||||
log.Error("Not Found Error: ", err)
|
||||
return c.Status(fiber.StatusNotFound).JSON(model.Response[any]{
|
||||
Success: false,
|
||||
Data: nil,
|
||||
Message: notFoundErr.Error(),
|
||||
})
|
||||
} else if errors.Is(err, fiber.ErrNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(model.Response[any]{
|
||||
Success: false,
|
||||
Data: nil,
|
||||
Message: "Not found",
|
||||
})
|
||||
} else if errors.Is(err, fiber.ErrMethodNotAllowed) {
|
||||
return c.Status(fiber.StatusMethodNotAllowed).JSON(model.Response[any]{
|
||||
Success: false,
|
||||
Data: nil,
|
||||
Message: "Method not allowed",
|
||||
})
|
||||
} else if errors.As(err, &fiberErr) && fiberErr.Message != "" {
|
||||
return c.Status(fiberErr.Code).JSON(model.Response[any]{
|
||||
Success: false,
|
||||
Data: nil,
|
||||
Message: fiberErr.Message,
|
||||
})
|
||||
} else if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return c.Status(fiber.StatusNotFound).JSON(model.Response[any]{
|
||||
Success: false,
|
||||
Data: nil,
|
||||
Message: "Not found",
|
||||
})
|
||||
} else {
|
||||
var fiberErr *fiber.Error
|
||||
if errors.As(err, &fiberErr) {
|
||||
if fiberErr.Code == fiber.StatusNotFound {
|
||||
return c.Status(fiber.StatusNotFound).JSON(model.Response[any]{
|
||||
Success: false,
|
||||
Data: nil,
|
||||
Message: "Not found",
|
||||
})
|
||||
}
|
||||
if errors.As(err, &fiberErr) {
|
||||
if fiberErr.Code != fiber.StatusInternalServerError {
|
||||
return c.Status(fiberErr.Code).JSON(model.Response[any]{
|
||||
Success: false,
|
||||
Data: nil,
|
||||
Message: fiberErr.Message,
|
||||
})
|
||||
}
|
||||
log.Error("Internal Server Error: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(model.Response[any]{
|
||||
Success: false,
|
||||
Data: nil,
|
||||
Message: "Internal server error",
|
||||
})
|
||||
}
|
||||
log.Error("Internal Server Error: ", err)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(model.Response[any]{
|
||||
Success: false,
|
||||
Data: nil,
|
||||
Message: "Internal server error",
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -20,6 +20,10 @@ func FrontendMiddleware(c fiber.Ctx) error {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
if strings.HasPrefix(c.Path(), "/metrics") {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
path := c.Path()
|
||||
file := "static" + path
|
||||
|
||||
@@ -32,6 +36,7 @@ func FrontendMiddleware(c fiber.Ctx) error {
|
||||
}
|
||||
|
||||
if _, err := os.Stat(file); path == "/" || os.IsNotExist(err) {
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
return serveIndexHtml(c)
|
||||
} else {
|
||||
c.Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
|
||||
21
server/middleware/stat_middleware.go
Normal file
21
server/middleware/stat_middleware.go
Normal 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
|
||||
}
|
||||
60
server/middleware/unsupported_region_middleware.go
Normal file
60
server/middleware/unsupported_region_middleware.go
Normal 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)
|
||||
}
|
||||
@@ -18,9 +18,10 @@ const (
|
||||
|
||||
type Activity struct {
|
||||
gorm.Model
|
||||
UserID uint `gorm:"not null"`
|
||||
Type ActivityType `gorm:"not null;index:idx_type_refid"`
|
||||
RefID uint `gorm:"not null;index:idx_type_refid"`
|
||||
UserID uint `gorm:"not null"`
|
||||
Type ActivityType `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 {
|
||||
|
||||
67
server/model/character.go
Normal file
67
server/model/character.go
Normal 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
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package model
|
||||
|
||||
type Charactor 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)"`
|
||||
ImageID uint
|
||||
ResourceID uint
|
||||
Image *Image `gorm:"foreignKey:ImageID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"`
|
||||
}
|
||||
|
||||
type CharactorView struct {
|
||||
Id uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Alias []string `json:"alias"`
|
||||
CV string `json:"cv"`
|
||||
Image uint `json:"image"`
|
||||
}
|
||||
|
||||
func (c *Charactor) ToView() *CharactorView {
|
||||
return &CharactorView{
|
||||
Id: c.ID,
|
||||
Name: c.Name,
|
||||
Alias: c.Alias,
|
||||
CV: c.CV,
|
||||
Image: c.ImageID,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Charactor) Equal(other *Charactor) bool {
|
||||
if c.Name != other.Name || c.CV != other.CV || 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
|
||||
}
|
||||
@@ -2,78 +2,31 @@ package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type RequestError struct {
|
||||
Message string `json:"message"`
|
||||
func NewRequestError(message string) error {
|
||||
return fiber.NewError(400, message)
|
||||
}
|
||||
|
||||
func (e *RequestError) Error() string {
|
||||
return e.Message
|
||||
func NewUnAuthorizedError(message string) error {
|
||||
return fiber.NewError(403, message)
|
||||
}
|
||||
|
||||
func NewRequestError(message string) *RequestError {
|
||||
return &RequestError{
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
func IsRequestError(err error) bool {
|
||||
var requestError *RequestError
|
||||
ok := errors.As(err, &requestError)
|
||||
return ok
|
||||
}
|
||||
|
||||
type UnAuthorizedError struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *UnAuthorizedError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func NewUnAuthorizedError(message string) *UnAuthorizedError {
|
||||
return &UnAuthorizedError{
|
||||
Message: message,
|
||||
}
|
||||
}
|
||||
|
||||
func IsUnAuthorizedError(err error) bool {
|
||||
var unAuthorizedError *UnAuthorizedError
|
||||
ok := errors.As(err, &unAuthorizedError)
|
||||
return ok
|
||||
}
|
||||
|
||||
type NotFoundError struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *NotFoundError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func NewNotFoundError(message string) *NotFoundError {
|
||||
return &NotFoundError{
|
||||
Message: message,
|
||||
}
|
||||
func NewNotFoundError(message string) error {
|
||||
return fiber.NewError(404, message)
|
||||
}
|
||||
|
||||
func IsNotFoundError(err error) bool {
|
||||
var notFoundError *NotFoundError
|
||||
ok := errors.As(err, ¬FoundError)
|
||||
return ok
|
||||
}
|
||||
|
||||
type InternalServerError struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
func (e *InternalServerError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
func NewInternalServerError(message string) *InternalServerError {
|
||||
return &InternalServerError{
|
||||
Message: message,
|
||||
var fiberError *fiber.Error
|
||||
ok := errors.As(err, &fiberError)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return fiberError.Code == 404
|
||||
}
|
||||
|
||||
func NewInternalServerError(message string) error {
|
||||
return fiber.NewError(500, message)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type File struct {
|
||||
User User `gorm:"foreignKey:UserID"`
|
||||
Size int64
|
||||
Hash string `gorm:"default:null"`
|
||||
Tag string `gorm:"type:text;default:null"`
|
||||
}
|
||||
|
||||
type FileView struct {
|
||||
@@ -32,6 +33,7 @@ type FileView struct {
|
||||
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 {
|
||||
@@ -45,6 +47,7 @@ func (f *File) ToView() *FileView {
|
||||
Hash: f.Hash,
|
||||
StorageName: f.Storage.Name,
|
||||
CreatedAt: f.CreatedAt.Unix(),
|
||||
Tag: f.Tag,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,5 +67,6 @@ func (f *File) ToViewWithResource() *FileView {
|
||||
User: f.User.ToView(),
|
||||
Resource: resource,
|
||||
Hash: f.Hash,
|
||||
Tag: f.Tag,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ type Resource struct {
|
||||
Title string
|
||||
AlternativeTitles []string `gorm:"serializer:json"`
|
||||
Links []Link `gorm:"serializer:json"`
|
||||
ReleaseDate *time.Time
|
||||
Article string
|
||||
Images []Image `gorm:"many2many:resource_images;"`
|
||||
Tags []Tag `gorm:"many2many:resource_tags;"`
|
||||
Files []File `gorm:"foreignKey:ResourceID"`
|
||||
CoverID *uint
|
||||
Tags []Tag `gorm:"many2many:resource_tags;"`
|
||||
Files []File `gorm:"foreignKey:ResourceID"`
|
||||
UserID uint
|
||||
User User
|
||||
Views uint
|
||||
@@ -23,7 +25,7 @@ type Resource struct {
|
||||
ModifiedTime time.Time
|
||||
Gallery []uint `gorm:"serializer:json"`
|
||||
GalleryNsfw []uint `gorm:"serializer:json"`
|
||||
Charactors []Charactor `gorm:"foreignKey:ResourceID"`
|
||||
Characters []Character `gorm:"foreignKey:ResourceID"`
|
||||
}
|
||||
|
||||
type Link struct {
|
||||
@@ -32,12 +34,13 @@ type Link struct {
|
||||
}
|
||||
|
||||
type ResourceView struct {
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Tags []TagView `json:"tags"`
|
||||
Image *ImageView `json:"image"`
|
||||
Author UserView `json:"author"`
|
||||
ID uint `json:"id"`
|
||||
Title string `json:"title"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ReleaseDate *time.Time `json:"release_date,omitempty"`
|
||||
Tags []TagView `json:"tags"`
|
||||
Image *ImageView `json:"image"`
|
||||
Author UserView `json:"author"`
|
||||
}
|
||||
|
||||
type ResourceDetailView struct {
|
||||
@@ -47,8 +50,10 @@ type ResourceDetailView struct {
|
||||
Links []Link `json:"links"`
|
||||
Article string `json:"article"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ReleaseDate *time.Time `json:"releaseDate,omitempty"`
|
||||
Tags []TagView `json:"tags"`
|
||||
Images []ImageView `json:"images"`
|
||||
CoverID *uint `json:"coverId,omitempty"`
|
||||
Files []FileView `json:"files"`
|
||||
Author UserView `json:"author"`
|
||||
Views uint `json:"views"`
|
||||
@@ -57,7 +62,15 @@ type ResourceDetailView struct {
|
||||
Related []ResourceView `json:"related"`
|
||||
Gallery []uint `json:"gallery"`
|
||||
GalleryNsfw []uint `json:"galleryNsfw"`
|
||||
Charactors []CharactorView `json:"charactors"`
|
||||
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 {
|
||||
@@ -67,18 +80,30 @@ func (r *Resource) ToView() ResourceView {
|
||||
}
|
||||
|
||||
var image *ImageView
|
||||
if len(r.Images) > 0 {
|
||||
if r.CoverID != nil {
|
||||
// Use the cover image if specified
|
||||
for _, img := range r.Images {
|
||||
if img.ID == *r.CoverID {
|
||||
v := img.ToView()
|
||||
image = &v
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// If no cover is set or cover image not found, use the first image
|
||||
if image == nil && len(r.Images) > 0 {
|
||||
v := r.Images[0].ToView()
|
||||
image = &v
|
||||
}
|
||||
|
||||
return ResourceView{
|
||||
ID: r.ID,
|
||||
Title: r.Title,
|
||||
CreatedAt: r.CreatedAt,
|
||||
Tags: tags,
|
||||
Image: image,
|
||||
Author: r.User.ToView(),
|
||||
ID: r.ID,
|
||||
Title: r.Title,
|
||||
CreatedAt: r.CreatedAt,
|
||||
ReleaseDate: r.ReleaseDate,
|
||||
Tags: tags,
|
||||
Image: image,
|
||||
Author: r.User.ToView(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,9 +121,9 @@ func (r *Resource) ToDetailView() ResourceDetailView {
|
||||
for i, file := range r.Files {
|
||||
files[i] = *file.ToView()
|
||||
}
|
||||
charactors := make([]CharactorView, len(r.Charactors))
|
||||
for i, charactor := range r.Charactors {
|
||||
charactors[i] = *charactor.ToView()
|
||||
characters := make([]CharacterView, len(r.Characters))
|
||||
for i, character := range r.Characters {
|
||||
characters[i] = *character.ToView()
|
||||
}
|
||||
return ResourceDetailView{
|
||||
ID: r.ID,
|
||||
@@ -107,8 +132,10 @@ func (r *Resource) ToDetailView() ResourceDetailView {
|
||||
Links: r.Links,
|
||||
Article: r.Article,
|
||||
CreatedAt: r.CreatedAt,
|
||||
ReleaseDate: r.ReleaseDate,
|
||||
Tags: tags,
|
||||
Images: images,
|
||||
CoverID: r.CoverID,
|
||||
Files: files,
|
||||
Author: r.User.ToView(),
|
||||
Views: r.Views,
|
||||
@@ -116,6 +143,6 @@ func (r *Resource) ToDetailView() ResourceDetailView {
|
||||
Comments: r.Comments,
|
||||
Gallery: r.Gallery,
|
||||
GalleryNsfw: r.GalleryNsfw,
|
||||
Charactors: charactors,
|
||||
Characters: characters,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,4 +9,6 @@ const (
|
||||
RSortViewsDesc
|
||||
RSortDownloadsAsc
|
||||
RSortDownloadsDesc
|
||||
RSortReleaseDateAsc
|
||||
RSortReleaseDateDesc
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ type UploadingFile struct {
|
||||
TempPath string
|
||||
Resource Resource `gorm:"foreignKey:TargetResourceID"`
|
||||
Storage Storage `gorm:"foreignKey:TargetStorageID"`
|
||||
Tag string `gorm:"type:text;default:null"`
|
||||
}
|
||||
|
||||
func (uf *UploadingFile) BlocksCount() int {
|
||||
|
||||
@@ -9,16 +9,17 @@ import (
|
||||
|
||||
type User struct {
|
||||
gorm.Model
|
||||
Username string `gorm:"uniqueIndex;not null"`
|
||||
PasswordHash []byte
|
||||
IsAdmin bool
|
||||
CanUpload bool
|
||||
AvatarVersion int
|
||||
ResourcesCount int
|
||||
FilesCount int
|
||||
CommentsCount int
|
||||
Resources []Resource `gorm:"foreignKey:UserID"`
|
||||
Bio string
|
||||
Username string `gorm:"uniqueIndex;not null"`
|
||||
PasswordHash []byte
|
||||
IsAdmin bool
|
||||
CanUpload bool
|
||||
AvatarVersion int
|
||||
ResourcesCount int
|
||||
FilesCount int
|
||||
CommentsCount int
|
||||
Resources []Resource `gorm:"foreignKey:UserID"`
|
||||
Bio string
|
||||
UnreadNotificationsCount uint `gorm:"not null;default:0"`
|
||||
}
|
||||
|
||||
type UserView struct {
|
||||
|
||||
@@ -3,30 +3,54 @@ package search
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"nysoure/server/dao"
|
||||
"nysoure/server/model"
|
||||
"nysoure/server/utils"
|
||||
"os"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/blevesearch/bleve"
|
||||
)
|
||||
|
||||
var (
|
||||
index bleve.Index
|
||||
mu = sync.RWMutex{}
|
||||
)
|
||||
|
||||
type ResourceParams struct {
|
||||
Id uint
|
||||
Title string
|
||||
Subtitles []string
|
||||
Time time.Time
|
||||
Id uint
|
||||
Title string
|
||||
Subtitles []string
|
||||
Time time.Time
|
||||
Characters []ResourceCharacter
|
||||
}
|
||||
|
||||
var index bleve.Index
|
||||
type ResourceCharacter struct {
|
||||
Name string
|
||||
Alias []string
|
||||
CV string
|
||||
}
|
||||
|
||||
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{
|
||||
Id: r.ID,
|
||||
Title: r.Title,
|
||||
Subtitles: r.AlternativeTitles,
|
||||
Time: r.CreatedAt,
|
||||
Id: r.ID,
|
||||
Title: r.Title,
|
||||
Subtitles: r.AlternativeTitles,
|
||||
Time: r.CreatedAt,
|
||||
Characters: cs,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,16 +64,25 @@ func createIndex() error {
|
||||
}
|
||||
page := 1
|
||||
total := 1
|
||||
current := 0
|
||||
for page <= total {
|
||||
res, totalPages, err := dao.GetResourceList(page, 100, model.RSortTimeAsc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range res {
|
||||
err := AddResourceToIndex(r)
|
||||
r, err := dao.GetResourceByID(r.ID)
|
||||
if err != nil {
|
||||
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++
|
||||
total = totalPages
|
||||
@@ -80,6 +113,8 @@ func init() {
|
||||
}
|
||||
|
||||
func SearchResource(keyword string) ([]uint, error) {
|
||||
mu.RLock()
|
||||
defer mu.RUnlock()
|
||||
query := bleve.NewMatchQuery(keyword)
|
||||
searchRequest := bleve.NewSearchRequest(query)
|
||||
searchResults, err := index.Search(searchRequest)
|
||||
@@ -112,3 +147,24 @@ func IsStopWord(word string) bool {
|
||||
tokens := analyzer.Analyze([]byte(word))
|
||||
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
|
||||
}
|
||||
|
||||
@@ -68,3 +68,67 @@ func GetActivityList(page int) ([]model.ActivityView, int, error) {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
var notifyTo uint
|
||||
|
||||
switch cType {
|
||||
case model.CommentTypeResource:
|
||||
resourceExists, err := dao.ExistsResource(refID)
|
||||
@@ -39,12 +41,18 @@ func CreateComment(req CommentRequest, userID uint, refID uint, ip string, cType
|
||||
if !resourceExists {
|
||||
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:
|
||||
_, err := dao.GetCommentByID(refID)
|
||||
comment, err := dao.GetCommentByID(refID)
|
||||
if err != nil {
|
||||
log.Error("Error getting reply comment:", err)
|
||||
return nil, model.NewNotFoundError("Reply comment not found")
|
||||
}
|
||||
notifyTo = comment.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)
|
||||
return nil, model.NewInternalServerError("Error creating comment")
|
||||
}
|
||||
err = dao.AddNewCommentActivity(userID, c.ID)
|
||||
err = dao.AddNewCommentActivity(userID, c.ID, notifyTo)
|
||||
if err != nil {
|
||||
log.Error("Error creating comment activity:", err)
|
||||
}
|
||||
|
||||
@@ -80,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 == "" {
|
||||
return nil, model.NewRequestError("filename is empty")
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func CreateUploadingFile(uid uint, filename string, description string, fileSize
|
||||
log.Error("failed to create temp dir: ", err)
|
||||
return nil, model.NewInternalServerError("failed to create temp dir")
|
||||
}
|
||||
uploadingFile, err := dao.CreateUploadingFile(filename, description, fileSize, blockSize, tempPath, resourceID, storageID, uid)
|
||||
uploadingFile, err := dao.CreateUploadingFile(filename, description, fileSize, blockSize, tempPath, resourceID, storageID, uid, tag)
|
||||
if err != nil {
|
||||
log.Error("failed to create uploading file: ", err)
|
||||
_ = os.Remove(tempPath)
|
||||
@@ -245,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")
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Error("failed to create file in db: ", err)
|
||||
_ = os.Remove(resultFilePath)
|
||||
@@ -309,7 +309,7 @@ func CancelUploadingFile(uid uint, fid uint) error {
|
||||
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")
|
||||
@@ -329,7 +329,7 @@ func CreateRedirectFile(uid uint, filename string, description string, resourceI
|
||||
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 {
|
||||
log.Error("failed to create file in db: ", err)
|
||||
return nil, model.NewInternalServerError("failed to create file in db")
|
||||
@@ -373,7 +373,7 @@ func DeleteFile(uid uint, fid string) error {
|
||||
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)
|
||||
if err != nil {
|
||||
log.Error("failed to get file: ", err)
|
||||
@@ -390,7 +390,7 @@ func UpdateFile(uid uint, fid string, filename string, description string) (*mod
|
||||
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 {
|
||||
log.Error("failed to update file in db: ", err)
|
||||
return nil, model.NewInternalServerError("failed to update file in db")
|
||||
@@ -575,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)
|
||||
if err != nil {
|
||||
log.Error("failed to check user permission: ", err)
|
||||
@@ -596,7 +596,7 @@ func CreateServerDownloadTask(uid uint, url, filename, description string, resou
|
||||
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 {
|
||||
log.Error("failed to create file in db: ", err)
|
||||
return nil, model.NewInternalServerError("failed to create file in db")
|
||||
|
||||
@@ -31,8 +31,78 @@ import (
|
||||
|
||||
const (
|
||||
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() {
|
||||
// Start a goroutine to delete unused images every hour
|
||||
go func() {
|
||||
@@ -108,13 +178,24 @@ func CreateImage(uid uint, ip string, data []byte) (uint, error) {
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
i, err := dao.CreateImage(filename, img.Bounds().Dx(), img.Bounds().Dy())
|
||||
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
|
||||
}
|
||||
|
||||
@@ -127,11 +208,11 @@ func GetImage(id uint) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
imageDir := utils.GetStoragePath() + "/images/"
|
||||
if _, err := os.Stat(imageDir); os.IsNotExist(err) {
|
||||
filepath := getImagePath(i.FileName)
|
||||
if _, err := os.Stat(filepath); os.IsNotExist(err) {
|
||||
return nil, model.NewNotFoundError("Image not found")
|
||||
}
|
||||
data, err := os.ReadFile(imageDir + i.FileName)
|
||||
data, err := os.ReadFile(filepath)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to read image file")
|
||||
}
|
||||
@@ -161,11 +242,13 @@ func deleteImage(id uint) error {
|
||||
return err
|
||||
}
|
||||
|
||||
imageDir := utils.GetStoragePath() + "/images/"
|
||||
_ = os.Remove(imageDir + i.FileName)
|
||||
// Delete from both potential locations (new subdir and legacy flat)
|
||||
filepath := getImagePath(i.FileName)
|
||||
_ = os.Remove(filepath)
|
||||
|
||||
resampledDir := utils.GetStoragePath() + "/resampled/"
|
||||
_ = os.Remove(resampledDir + strconv.Itoa(int(i.ID)) + ".webp")
|
||||
// Delete resampled image from subdirectory structure
|
||||
resampledPath := getResampledImagePath(i.ID)
|
||||
_ = os.Remove(resampledPath)
|
||||
|
||||
if err := dao.DeleteImage(id); err != nil {
|
||||
return err
|
||||
@@ -173,6 +256,7 @@ func deleteImage(id uint) error {
|
||||
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) {
|
||||
i, err := dao.GetImageByID(id)
|
||||
if err != nil {
|
||||
@@ -189,14 +273,8 @@ func GetResampledImage(id uint) ([]byte, error) {
|
||||
}
|
||||
|
||||
func getOrCreateResampledImage(i model.Image) ([]byte, error) {
|
||||
baseDir := utils.GetStoragePath() + "/resampled/"
|
||||
if _, err := os.Stat(baseDir); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(baseDir, 0755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
resampledFilepath := baseDir + strconv.Itoa(int(i.ID)) + ".webp"
|
||||
// Check if resampled image already exists
|
||||
resampledFilepath := getResampledImagePath(i.ID)
|
||||
if _, err := os.Stat(resampledFilepath); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
@@ -205,7 +283,7 @@ func getOrCreateResampledImage(i model.Image) ([]byte, error) {
|
||||
return os.ReadFile(resampledFilepath)
|
||||
}
|
||||
|
||||
originalFilepath := utils.GetStoragePath() + "/images/" + i.FileName
|
||||
originalFilepath := getImagePath(i.FileName)
|
||||
if _, err := os.Stat(originalFilepath); os.IsNotExist(err) {
|
||||
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 {
|
||||
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 {
|
||||
return nil, errors.New("failed to save resampled image file")
|
||||
}
|
||||
|
||||
@@ -30,18 +30,21 @@ type ResourceParams struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
AlternativeTitles []string `json:"alternative_titles"`
|
||||
Links []model.Link `json:"links"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Tags []uint `json:"tags"`
|
||||
Article string `json:"article"`
|
||||
Images []uint `json:"images"`
|
||||
CoverID *uint `json:"cover_id"`
|
||||
Gallery []uint `json:"gallery"`
|
||||
GalleryNsfw []uint `json:"gallery_nsfw"`
|
||||
Charactors []CharactorParams `json:"characters"`
|
||||
Characters []CharacterParams `json:"characters"`
|
||||
}
|
||||
|
||||
type CharactorParams struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -82,26 +85,53 @@ func CreateResource(uid uint, params *ResourceParams) (uint, error) {
|
||||
nsfw = append(nsfw, id)
|
||||
}
|
||||
}
|
||||
charactors := make([]model.Charactor, len(params.Charactors))
|
||||
for i, c := range params.Charactors {
|
||||
charactors[i] = model.Charactor{
|
||||
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,
|
||||
ImageID: c.Image,
|
||||
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{
|
||||
Title: params.Title,
|
||||
AlternativeTitles: params.AlternativeTitles,
|
||||
Article: params.Article,
|
||||
Links: params.Links,
|
||||
ReleaseDate: date,
|
||||
Images: images,
|
||||
CoverID: coverID,
|
||||
Tags: tags,
|
||||
UserID: uid,
|
||||
Gallery: gallery,
|
||||
GalleryNsfw: nsfw,
|
||||
Charactors: charactors,
|
||||
Characters: characters,
|
||||
}
|
||||
if r, err = dao.CreateResource(r); err != nil {
|
||||
return 0, err
|
||||
@@ -500,23 +530,52 @@ func UpdateResource(uid, rid uint, params *ResourceParams) error {
|
||||
nsfw = append(nsfw, id)
|
||||
}
|
||||
}
|
||||
charactors := make([]model.Charactor, len(params.Charactors))
|
||||
for i, c := range params.Charactors {
|
||||
charactors[i] = model.Charactor{
|
||||
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,
|
||||
ImageID: c.Image,
|
||||
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.AlternativeTitles = params.AlternativeTitles
|
||||
r.Article = params.Article
|
||||
r.Links = params.Links
|
||||
r.ReleaseDate = date
|
||||
r.CoverID = coverID
|
||||
r.Gallery = gallery
|
||||
r.GalleryNsfw = nsfw
|
||||
r.Charactors = charactors
|
||||
r.Characters = characters
|
||||
|
||||
images := make([]model.Image, len(params.Images))
|
||||
for i, id := range params.Images {
|
||||
@@ -596,7 +655,15 @@ func GetPinnedResources() ([]model.ResourceView, error) {
|
||||
return views, nil
|
||||
}
|
||||
|
||||
func GetCharactorsFromVndb(vnID string) ([]CharactorParams, error) {
|
||||
func GetCharactersFromVndb(vnID string, uid uint) ([]CharacterParams, error) {
|
||||
canUpload, err := checkUserCanUpload(uid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !canUpload {
|
||||
return nil, model.NewUnAuthorizedError("You have not permission to fetch characters from VNDB")
|
||||
}
|
||||
|
||||
client := http.Client{}
|
||||
jsonStr := fmt.Sprintf(`
|
||||
{
|
||||
@@ -647,26 +714,24 @@ func GetCharactorsFromVndb(vnID string) ([]CharactorParams, error) {
|
||||
}
|
||||
|
||||
if len(vndbResp.Results) == 0 {
|
||||
return []CharactorParams{}, nil
|
||||
return []CharacterParams{}, nil
|
||||
}
|
||||
|
||||
result := vndbResp.Results[0]
|
||||
var charactors []CharactorParams
|
||||
var characters []CharacterParams
|
||||
processedCharacters := make(map[string]bool) // 避免重复角色
|
||||
|
||||
// 遍历声优信息
|
||||
for _, va := range result.VA {
|
||||
// 检查角色是否为主要角色
|
||||
isPrimary := false
|
||||
role := "Unknown"
|
||||
for _, vn := range va.Character.VNS {
|
||||
if vn.Role == "primary" {
|
||||
isPrimary = true
|
||||
if vn.ID == vnID {
|
||||
role = vn.Role
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 只处理主要角色
|
||||
if !isPrimary {
|
||||
if role != "primary" && role != "side" && role != "main" {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -691,10 +756,11 @@ func GetCharactorsFromVndb(vnID string) ([]CharactorParams, error) {
|
||||
cvName = va.Staff.Name
|
||||
}
|
||||
|
||||
charactor := CharactorParams{
|
||||
character := CharacterParams{
|
||||
Name: characterName,
|
||||
Alias: []string{}, // 按要求不添加别名
|
||||
Alias: []string{},
|
||||
CV: cvName,
|
||||
Role: role,
|
||||
Image: 0, // 默认值,下面会下载图片
|
||||
}
|
||||
|
||||
@@ -705,14 +771,14 @@ func GetCharactorsFromVndb(vnID string) ([]CharactorParams, error) {
|
||||
log.Error("Failed to download character image:", err)
|
||||
// 继续处理,即使图片下载失败
|
||||
} else {
|
||||
charactor.Image = imageID
|
||||
character.Image = imageID
|
||||
}
|
||||
}
|
||||
|
||||
charactors = append(charactors, charactor)
|
||||
characters = append(characters, character)
|
||||
}
|
||||
|
||||
return charactors, nil
|
||||
return characters, nil
|
||||
}
|
||||
|
||||
// downloadAndCreateImage 下载图片并使用 CreateImage 保存
|
||||
@@ -753,3 +819,116 @@ func downloadAndCreateImage(imageURL string) (uint, error) {
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,42 @@ func CreateLocalStorage(uid uint, params CreateLocalStorageParams) error {
|
||||
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) {
|
||||
storages, err := dao.GetStorages()
|
||||
if err != nil {
|
||||
|
||||
@@ -390,3 +390,11 @@ func validateUsername(username string) error {
|
||||
}
|
||||
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
52
server/stat/stat.go
Normal 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
164
server/storage/ftp.go
Normal 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
|
||||
}
|
||||
@@ -43,6 +43,14 @@ func NewStorage(s model.Storage) IStorage {
|
||||
return nil
|
||||
}
|
||||
return &r
|
||||
|
||||
case "ftp":
|
||||
r := FTPStorage{}
|
||||
err := r.FromString(s.Config)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return &r
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ package utils
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -93,3 +94,24 @@ func ParseTemporaryToken(token string) (string, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user