diff --git a/.env.production b/.env.production index e174fba..51b5509 100644 --- a/.env.production +++ b/.env.production @@ -1,20 +1,42 @@ -# 生产环境配置 +# 梦回高句丽 - 生产环境配置 NODE_ENV=production -PORT=4001 + +# 服务器端口配置 (宝塔面板推荐范围: 4001-4010) +PORT=4003 +FRONTEND_PORT=4004 + +# 域名配置 +DOMAIN=ggl.xi.plus +BASE_URL=https://ggl.xi.plus # 数据库配置 DB_PATH=./database.db +DB_BACKUP_PATH=./database/goguryeo_video.db.backup -# JWT配置 -JWT_SECRET=your-production-jwt-secret-key-here +# JWT配置 (生产环境请使用强密钥) +JWT_SECRET=ggl-xi-plus-production-jwt-secret-2024 +JWT_EXPIRES_IN=7d # 文件上传配置 UPLOAD_DIR=./uploads MAX_FILE_SIZE=100MB +ALLOWED_FILE_TYPES=jpg,jpeg,png,gif,webp,mp4,avi,mov,wmv # 服务器配置 HOST=0.0.0.0 -CORS_ORIGIN=* +CORS_ORIGIN=https://ggl.xi.plus,http://ggl.xi.plus + +# 安全配置 +SECURE_COOKIES=true +CSRF_PROTECTION=true +RATE_LIMIT_WINDOW=15 +RATE_LIMIT_MAX=100 + +# 性能配置 +CACHE_TTL=3600 +STATIC_CACHE_MAX_AGE=31536000 # 日志配置 -LOG_LEVEL=info \ No newline at end of file +LOG_LEVEL=info +LOG_FILE=./logs/production.log +ERROR_LOG_FILE=./logs/error.log \ No newline at end of file diff --git a/.well-known/acme-challenge/gsfKg36gD--OGPGB5GqQUSy8JXZ86aUF7nLeJRg8ztk b/.well-known/acme-challenge/gsfKg36gD--OGPGB5GqQUSy8JXZ86aUF7nLeJRg8ztk new file mode 100644 index 0000000..b5d4548 --- /dev/null +++ b/.well-known/acme-challenge/gsfKg36gD--OGPGB5GqQUSy8JXZ86aUF7nLeJRg8ztk @@ -0,0 +1 @@ +gsfKg36gD--OGPGB5GqQUSy8JXZ86aUF7nLeJRg8ztk.OSdKi3nCfYN2E9beqMDFL03NkU68V1A8He3MYctm_jw \ No newline at end of file diff --git a/BAOTA_DEPLOYMENT.md b/BAOTA_DEPLOYMENT.md new file mode 100644 index 0000000..ff2a8d5 --- /dev/null +++ b/BAOTA_DEPLOYMENT.md @@ -0,0 +1,191 @@ +# 梦回高句丽 - 宝塔面板部署指南 + +## 项目概述 +- **项目名称**: 梦回高句丽视频网站 +- **域名**: ggl.xi.plus +- **技术栈**: Vue.js + Node.js + Express + SQLite +- **部署方式**: 宝塔面板 NODE 项目 + +## 部署步骤 + +### 1. 宝塔面板 NODE 项目配置 + +#### 基本设置 +- **项目名称**: `梦回高句丽` +- **项目路径**: `/www/wwwroot/ggl` +- **启动文件**: `start-production.cjs` +- **运行端口**: `4001` (API) + `4002` (前端) +- **Node.js 版本**: `18.x` 或更高 + +#### 启动命令配置 +```bash +# 在宝塔面板 NODE 项目中设置 +启动命令: node start-production.cjs +或者使用 npm 脚本: npm run start:bt +``` + +### 2. 环境准备 + +#### 安装依赖 +```bash +cd /www/wwwroot/ggl +npm install +``` + +#### 构建前端项目 +```bash +npm run build +``` + +#### 检查必要文件 +确保以下文件存在: +- `dist/` 目录 (前端构建产物) +- `database.db` 或 `database/goguryeo_video.db` +- `.env.production` 配置文件 +- `logs/` 目录 (自动创建) + +### 3. 域名和 SSL 配置 + +#### 域名绑定 +1. 在宝塔面板中添加站点 `ggl.xi.plus` +2. 设置网站目录为 `/www/wwwroot/ggl/dist` +3. 申请并配置 SSL 证书 + +#### Nginx 配置 +使用提供的 `nginx-ggl-xi-plus.conf` 配置文件: + +```bash +# 复制配置到宝塔 Nginx 配置目录 +cp nginx-ggl-xi-plus.conf /www/server/panel/vhost/nginx/ggl.xi.plus.conf + +# 重载 Nginx 配置 +nginx -s reload +``` + +### 4. 启动项目 + +#### 方式一:宝塔面板 NODE 项目 (推荐) +1. 在宝塔面板 → 软件商店 → Node.js 项目管理 +2. 添加项目: + - 项目名称: `梦回高句丽` + - 项目路径: `/www/wwwroot/ggl` + - 启动文件: `start-production.cjs` + - 端口: `4001` +3. 点击启动项目 + +#### 方式二:PM2 管理 (备选) +```bash +# 使用 PM2 启动 +npm run pm2:start + +# 查看状态 +pm2 status + +# 查看日志 +pm2 logs ggl-xi-plus-production +``` + +### 5. 验证部署 + +#### 检查服务状态 +```bash +# 检查端口占用 +netstat -tlnp | grep :4001 +netstat -tlnp | grep :4002 + +# 检查进程 +ps aux | grep node +``` + +#### 访问测试 +- 前端页面: `https://ggl.xi.plus` +- API 接口: `https://ggl.xi.plus/api/health` +- 上传文件: `https://ggl.xi.plus/uploads/` + +### 6. 监控和维护 + +#### 日志文件位置 +``` +logs/ +├── production.log # 应用日志 +├── error.log # 错误日志 +├── pm2-combined.log # PM2 综合日志 +├── pm2-out.log # PM2 输出日志 +└── pm2-error.log # PM2 错误日志 +``` + +#### 常用维护命令 +```bash +# 重启服务 (宝塔面板) +# 在 NODE 项目管理中点击重启 + +# 重启服务 (PM2) +npm run pm2:restart + +# 查看实时日志 +tail -f logs/production.log + +# 清理日志 +truncate -s 0 logs/*.log +``` + +### 7. 性能优化配置 + +#### 系统配置 +- **内存限制**: 1GB (可根据实际情况调整) +- **文件上传限制**: 100MB +- **并发连接**: 根据服务器配置调整 +- **缓存策略**: 静态资源 1 年,API 响应适当缓存 + +#### 安全配置 +- SSL/TLS 加密传输 +- CORS 跨域限制 +- 请求频率限制 +- 敏感文件访问禁止 +- 安全响应头设置 + +## 故障排除 + +### 常见问题 + +1. **端口冲突** + ```bash + # 检查端口占用 + lsof -i :4001 + lsof -i :4002 + ``` + +2. **权限问题** + ```bash + # 设置正确的文件权限 + chown -R www:www /www/wwwroot/ggl + chmod -R 755 /www/wwwroot/ggl + ``` + +3. **数据库连接失败** + ```bash + # 检查数据库文件权限 + ls -la database/ + chmod 664 database/*.db + ``` + +4. **前端资源 404** + ```bash + # 重新构建前端 + npm run build + ``` + +### 联系支持 +如遇到部署问题,请检查: +1. Node.js 版本是否符合要求 +2. 依赖包是否完整安装 +3. 端口是否被占用 +4. 文件权限是否正确 +5. Nginx 配置是否生效 + +--- + +**部署完成后,项目将在 https://ggl.xi.plus 上运行** + +**最后更新**: 2024年1月 +**维护者**: 系统管理员 \ No newline at end of file diff --git a/api/app.ts b/api/app.ts index 92e65af..e3db952 100755 --- a/api/app.ts +++ b/api/app.ts @@ -59,6 +59,8 @@ app.use('/api/health', (req: Request, res: Response, next: NextFunction): void = }); }); + + /** * error handler middleware */ diff --git a/api/routes/auth.ts b/api/routes/auth.ts index 03e86bd..44d3443 100755 --- a/api/routes/auth.ts +++ b/api/routes/auth.ts @@ -234,6 +234,37 @@ router.post('/refresh', async (req: Request, res: Response): Promise => { } }); +/** + * Get Captcha + * GET /api/auth/captcha + */ +router.get('/captcha', async (req: Request, res: Response): Promise => { + try { + // 生成简单的数学验证码 + const num1 = Math.floor(Math.random() * 10) + 1; + const num2 = Math.floor(Math.random() * 10) + 1; + const answer = num1 + num2; + + // 生成验证码ID + const captchaId = Math.random().toString(36).substring(2, 15); + + res.json({ + success: true, + data: { + captchaId, + question: `${num1} + ${num2} = ?`, + answer // 在实际生产环境中,这应该存储在服务器端而不是返回给客户端 + } + }); + } catch (error) { + console.error('获取验证码失败:', error); + res.status(500).json({ + success: false, + message: '服务器内部错误' + }); + } +}); + /** * User Logout * POST /api/auth/logout diff --git a/api/routes/videos.ts b/api/routes/videos.ts index f0a5e4d..b5d253b 100755 --- a/api/routes/videos.ts +++ b/api/routes/videos.ts @@ -788,112 +788,7 @@ router.post('/upload', authenticateToken, upload.single('video'), handleMulterEr } }); -/** - * 批量删除视频 - * DELETE /api/videos/batch - */ -router.delete('/batch', authenticateToken, async (req: Request, res: Response): Promise => { - try { - const { videoIds } = req.body; - if (!videoIds || !Array.isArray(videoIds) || videoIds.length === 0) { - res.status(400).json({ - success: false, - message: '请提供要删除的视频ID列表' - }); - return; - } - - // 验证所有ID都是有效数字 - const validIds = videoIds.filter(id => typeof id === 'number' && !isNaN(id)); - if (validIds.length === 0) { - res.status(400).json({ - success: false, - message: '无效的视频ID列表' - }); - return; - } - - // 检查用户权限(只能删除自己上传的视频,或管理员可以删除所有视频) - const placeholders = validIds.map(() => '?').join(','); - const videos = await query(` - SELECT id, file_path, cover_image, user_id FROM videos - WHERE id IN (${placeholders}) - `, validIds); - - if (videos.length === 0) { - res.status(404).json({ - success: false, - message: '未找到要删除的视频' - }); - return; - } - - // 检查权限 - const unauthorizedVideos = videos.filter(video => - video.user_id !== req.user!.id && req.user!.role !== 'admin' - ); - - if (unauthorizedVideos.length > 0) { - res.status(403).json({ - success: false, - message: '没有权限删除部分视频' - }); - return; - } - - // 删除相关文件 - for (const video of videos) { - try { - // 删除视频文件 - if (video.file_path) { - const videoPath = path.join(process.cwd(), video.file_path); - if (fs.existsSync(videoPath)) { - fs.unlinkSync(videoPath); - } - } - - // 删除封面文件 - if (video.cover_image) { - const coverPath = path.join(process.cwd(), video.cover_image.replace(/^\//, '')); - if (fs.existsSync(coverPath)) { - fs.unlinkSync(coverPath); - } - } - } catch (fileError) { - // 删除文件失败 - } - } - - // 删除数据库记录 - const foundIds = videos.map(v => v.id); - const foundPlaceholders = foundIds.map(() => '?').join(','); - - // 删除相关统计数据 - await execute(`DELETE FROM video_stats WHERE video_id IN (${foundPlaceholders})`, foundIds); - - // 删除专题关联 - await execute(`DELETE FROM video_topics WHERE video_id IN (${foundPlaceholders})`, foundIds); - - // 删除视频记录 - await execute(`DELETE FROM videos WHERE id IN (${foundPlaceholders})`, foundIds); - - res.json({ - success: true, - message: `成功删除 ${foundIds.length} 个视频`, - data: { - deletedCount: foundIds.length, - deletedIds: foundIds - } - }); - } catch (error) { - // 批量删除视频失败 - res.status(500).json({ - success: false, - message: '服务器内部错误' - }); - } -}); /** * 批量删除视频 (POST方法) @@ -995,9 +890,11 @@ router.post('/batch/delete', authenticateToken, async (req: Request, res: Respon }); } catch (error) { console.error('批量删除视频失败:', error); + console.error('错误堆栈:', error.stack); res.status(500).json({ success: false, - message: '服务器内部错误' + message: '服务器内部错误', + error: process.env.NODE_ENV === 'development' ? error.message : undefined }); } }); @@ -1092,6 +989,8 @@ router.put('/batch/status', authenticateToken, async (req: Request, res: Respons * DELETE /api/videos/batch */ router.delete('/batch', authenticateToken, async (req: Request, res: Response): Promise => { + console.log('批量删除视频开始,请求体:', req.body); + console.log('用户信息:', req.user); try { const { videoIds } = req.body; @@ -1127,7 +1026,7 @@ router.delete('/batch', authenticateToken, async (req: Request, res: Response): // 获取要删除的视频信息 const placeholders = validIds.map(() => '?').join(','); const videos = await query(` - SELECT id, file_path, thumbnail + SELECT id, file_path, cover_image FROM videos WHERE id IN (${placeholders}) `, validIds); @@ -1150,8 +1049,8 @@ router.delete('/batch', authenticateToken, async (req: Request, res: Response): } // 删除缩略图文件 - if (video.thumbnail) { - const thumbnailPath = path.join(process.cwd(), 'public', video.thumbnail); + if (video.cover_image) { + const thumbnailPath = path.join(process.cwd(), 'public', video.cover_image); if (fs.existsSync(thumbnailPath)) { fs.unlinkSync(thumbnailPath); // 缩略图删除成功 @@ -1185,9 +1084,11 @@ router.delete('/batch', authenticateToken, async (req: Request, res: Response): }); } catch (error) { console.error('批量删除视频失败:', error); + console.error('错误堆栈:', error.stack); res.status(500).json({ success: false, - message: '服务器内部错误' + message: '服务器内部错误', + error: process.env.NODE_ENV === 'development' ? error.message : undefined }); } }); diff --git a/database/goguryeo_video.db b/database/goguryeo_video.db index f8f2fdd..1a2257d 100755 Binary files a/database/goguryeo_video.db and b/database/goguryeo_video.db differ diff --git a/ecosystem.config.js b/ecosystem.config.js index 0547e3c..548f7ab 100644 --- a/ecosystem.config.js +++ b/ecosystem.config.js @@ -1,38 +1,40 @@ module.exports = { - apps: [{ - name: 'ggl-app', - script: 'start-production.cjs', - cwd: '/www/wwwroot/ggl', - instances: 1, - exec_mode: 'fork', - env: { - NODE_ENV: 'production', - PORT: 4001, - FRONTEND_PORT: 4002 - }, - env_production: { - NODE_ENV: 'production', - PORT: 4001, - FRONTEND_PORT: 4002 - }, - // 日志配置 - log_file: './logs/combined.log', - out_file: './logs/out.log', - error_file: './logs/error.log', - log_date_format: 'YYYY-MM-DD HH:mm:ss Z', - - // 进程管理配置 - autorestart: true, - watch: false, - max_memory_restart: '1G', - - // 启动配置 - min_uptime: '10s', - max_restarts: 10, - restart_delay: 4000, - - // 其他配置 - merge_logs: true, - time: true - }] + apps: [ + { + name: 'ggl-xi-plus-production', + script: 'start-production.cjs', + instances: 1, + autorestart: true, + watch: false, + max_memory_restart: '1G', + min_uptime: '10s', + max_restarts: 10, + restart_delay: 4000, + env: { + NODE_ENV: 'production', + PORT: 4001, + FRONTEND_PORT: 4002, + DOMAIN: 'ggl.xi.plus' + }, + log_file: './logs/pm2-combined.log', + out_file: './logs/pm2-out.log', + error_file: './logs/pm2-error.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z', + merge_logs: true, + kill_timeout: 5000, + listen_timeout: 8000, + shutdown_with_message: true + } + ], + + deploy: { + production: { + user: 'www', + host: 'localhost', + ref: 'origin/main', + repo: 'git@github.com:username/ggl.git', + path: '/www/wwwroot/ggl', + 'post-deploy': 'npm install && npm run build && pm2 reload ecosystem.config.js --env production' + } + } }; \ No newline at end of file diff --git a/nginx-ggl-xi-plus.conf b/nginx-ggl-xi-plus.conf new file mode 100644 index 0000000..dccc26b --- /dev/null +++ b/nginx-ggl-xi-plus.conf @@ -0,0 +1,166 @@ +# 梦回高句丽 - ggl.xi.plus 域名配置 +# 适用于宝塔面板 Nginx 配置 + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name ggl.xi.plus www.ggl.xi.plus; + return 301 https://ggl.xi.plus$request_uri; +} + +# HTTPS 主配置 +server { + listen 443 ssl http2; + server_name ggl.xi.plus www.ggl.xi.plus; + + # 网站根目录 + root /www/wwwroot/ggl/dist; + index index.html index.htm; + + # SSL 证书配置 (宝塔面板自动管理) + # ssl_certificate /www/server/panel/vhost/cert/ggl.xi.plus/fullchain.pem; + # ssl_certificate_key /www/server/panel/vhost/cert/ggl.xi.plus/privkey.pem; + + # SSL 安全配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全头配置 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:; media-src 'self' https:;" always; + + # Gzip 压缩配置 + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml+rss + application/atom+xml + image/svg+xml; + + # 静态资源缓存配置 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot|webp)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + add_header Vary "Accept-Encoding"; + access_log off; + + # 跨域配置 + add_header Access-Control-Allow-Origin "https://ggl.xi.plus"; + add_header Access-Control-Allow-Methods "GET, OPTIONS"; + add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept"; + } + + # API 请求代理到后端 Node.js 服务 + location /api/ { + proxy_pass http://127.0.0.1:4003; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # 超时配置 + proxy_connect_timeout 30s; + proxy_read_timeout 60s; + proxy_send_timeout 30s; + + # WebSocket 支持 + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # 缓冲配置 + proxy_buffering on; + proxy_buffer_size 4k; + proxy_buffers 8 4k; + + # 错误处理 + proxy_next_upstream error timeout invalid_header http_500 http_502 http_503 http_504; + } + + # uploads 目录代理到后端 + location /uploads/ { + proxy_pass http://127.0.0.1:4003; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 文件上传大小限制 + client_max_body_size 100M; + + # 缓存配置 + expires 30d; + add_header Cache-Control "public"; + } + + # 健康检查端点 + location /health { + proxy_pass http://127.0.0.1:4003/health; + access_log off; + } + + # 前端路由处理 - 所有其他请求返回 index.html (SPA) + location / { + try_files $uri $uri/ /index.html; + + # HTML 文件不缓存 + location ~* \.html$ { + expires -1; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + } + } + + # 禁止访问敏感文件 + location ~ ^/(\.|package\.json|package-lock\.json|\.env|\.git|node_modules|api/|database/) { + return 404; + } + + # robots.txt + location = /robots.txt { + add_header Content-Type text/plain; + return 200 "User-agent: *\nDisallow: /api/\nDisallow: /uploads/\nSitemap: https://ggl.xi.plus/sitemap.xml\n"; + } + + # SSL 证书验证目录 + location /.well-known/ { + root /www/wwwroot/ggl; + allow all; + } + + # 错误页面配置 + error_page 404 /index.html; + error_page 500 502 503 504 /50x.html; + location = /50x.html { + root /www/wwwroot/ggl/dist; + } + + # 日志配置 + access_log /www/wwwlogs/ggl.xi.plus.log combined; + error_log /www/wwwlogs/ggl.xi.plus.error.log warn; +} + +# 限制请求频率 (防止 DDoS) +limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; +limit_req_zone $binary_remote_addr zone=static:10m rate=30r/s; + +# 应用限制到相应的 location +# 在 location /api/ 中添加: limit_req zone=api burst=20 nodelay; +# 在静态资源 location 中添加: limit_req zone=static burst=50 nodelay; \ No newline at end of file diff --git a/package.json b/package.json index 3173918..97d4949 100755 --- a/package.json +++ b/package.json @@ -4,15 +4,10 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "concurrently \"vite\" \"tsx api/server.ts\"", + "dev": "vite", "build": "vue-tsc && vite build", "preview": "vite preview", - "start": "node start-production.cjs", - "start:bt": "node start-production.cjs", - "pm2:start": "pm2 start ecosystem.config.js", - "pm2:stop": "pm2 stop ecosystem.config.js", - "pm2:restart": "pm2 restart ecosystem.config.js", - "check": "./check.sh" + "start:bt": "node start-production.cjs" }, "dependencies": { "16": "^0.0.2", diff --git a/src/pages/HomePage.vue b/src/pages/HomePage.vue index adc2cc5..8671ba3 100755 --- a/src/pages/HomePage.vue +++ b/src/pages/HomePage.vue @@ -219,13 +219,21 @@ const handleSearchFromModal = (query: string, filters?: any) => { // 分类数据 const categories = ref([ { id: 'all', name: '全部', icon: 'Grid', count: 0 }, - { id: 'history', name: '历史文化', icon: 'Monument', count: 0 }, - { id: 'architecture', name: '建筑艺术', icon: 'Picture', count: 0 }, - { id: 'archaeology', name: '考古发现', icon: 'Document', count: 0 }, - { id: 'documentary', name: '纪录片', icon: 'VideoCamera', count: 0 }, - { id: 'featured', name: '精选推荐', icon: 'Star', count: 0 } + { id: 'history', name: '历史文化', icon: 'Monument', count: 0, dbId: 1 }, + { id: 'art', name: '传统艺术', icon: 'Picture', count: 0, dbId: 2 }, + { id: 'language', name: '语言学习', icon: 'Document', count: 0, dbId: 3 }, + { id: 'archaeology', name: '考古发现', icon: 'VideoCamera', count: 0, dbId: 4 }, + { id: 'folklore', name: '民俗风情', icon: 'Star', count: 0, dbId: 5 }, + { id: 'other', name: '其他', icon: 'Grid', count: 0, dbId: 6 } ]) +// 分类映射函数 +const getCategoryDbId = (categoryId: string) => { + if (categoryId === 'all') return undefined + const category = categories.value.find(c => c.id === categoryId) + return category?.dbId +} + // 统计数据 const stats = ref({ totalVideos: 0, @@ -282,7 +290,7 @@ const loadVideos = async (append = false) => { const params = { page: currentPage.value, pageSize: pageSize.value, - category: selectedCategory.value === 'all' ? undefined : selectedCategory.value, + category: getCategoryDbId(selectedCategory.value), sortBy: sortBy.value, search: searchQuery.value || undefined } diff --git a/src/pages/VideoDetail.vue b/src/pages/VideoDetail.vue index 5a37195..93767be 100755 --- a/src/pages/VideoDetail.vue +++ b/src/pages/VideoDetail.vue @@ -41,7 +41,7 @@
@@ -52,8 +52,7 @@ + preload="auto" + playsinline + :volume="0.8" + crossorigin="anonymous" + > + + + + +

您的浏览器不支持视频播放。请尝试使用现代浏览器或更新您的浏览器版本。

+
() const showFilenameInFullscreen = ref(false) const filenameTimer = ref() +// 视频加载和错误处理相关 +const videoLoadError = ref(false) +const videoRetryCount = ref(0) +const maxRetryCount = 3 +const videoLoadStarted = ref(false) + // 界面状态 const showFullDescription = ref(false) const relatedVideos = ref([]) @@ -347,32 +364,105 @@ const videoId = computed(() => Number(route.params.id)) // 生命周期 onMounted(() => { loadVideoData() + // 添加所有浏览器的全屏变化事件监听 document.addEventListener('fullscreenchange', handleFullscreenChange) + document.addEventListener('webkitfullscreenchange', handleFullscreenChange) + document.addEventListener('mozfullscreenchange', handleFullscreenChange) + document.addEventListener('MSFullscreenChange', handleFullscreenChange) document.addEventListener('keydown', handleKeydown) - // 自动全屏播放 + // 强化的自动全屏播放逻辑 - 确保在任何访问方式下都能工作 nextTick(() => { - setTimeout(() => { - // 确保视频元素已加载 - if (videoPlayerRef.value) { - // 先进入全屏模式 + // 立即尝试全屏,不依赖视频加载状态(这个函数将被下面定义的强化版本替代) + const executeImmediateFullscreenOld = () => { + console.log('执行立即全屏逻辑') + + // 无条件进入全屏模式 + if (!document.fullscreenElement) { toggleFullscreen() - // 然后开始播放 - setTimeout(() => { - if (videoPlayerRef.value) { - showPoster.value = false - videoPlayerRef.value.play().catch(error => { - console.log('自动播放失败,可能需要用户交互:', error) - }) - } - }, 500) } - }, 1000) + + // 延迟尝试播放视频 + setTimeout(() => { + const videoElement = videoPlayerRef.value + if (videoElement) { + showPoster.value = false + + // 尝试播放,无论视频是否完全加载 + videoElement.play().catch(error => { + console.log('自动播放失败,可能需要用户交互:', error) + // 如果自动播放失败,显示播放按钮提示用户点击 + showPoster.value = true + }) + } + }, 500) + } + + // 等待视频数据加载完成后自动全屏播放(作为备用) + const checkVideoReady = () => { + if (video.value && videoPlayerRef.value) { + // 检查视频元素是否已经准备好 + const videoElement = videoPlayerRef.value + + // 如果视频元素已经有足够的数据,立即执行全屏播放 + if (videoElement.readyState >= 2) { // HAVE_CURRENT_DATA + executeAutoFullscreenPlay() + } else { + // 监听视频数据加载事件 + const onLoadedData = () => { + videoElement.removeEventListener('loadeddata', onLoadedData) + executeAutoFullscreenPlay() + } + videoElement.addEventListener('loadeddata', onLoadedData) + + // 设置超时保护,避免无限等待 + setTimeout(() => { + videoElement.removeEventListener('loadeddata', onLoadedData) + executeAutoFullscreenPlay() + }, 2000) // 减少超时时间 + } + } else { + // 如果视频还没准备好,继续等待,但减少等待时间 + setTimeout(checkVideoReady, 100) + } + } + + // 执行自动全屏播放的函数 + const executeAutoFullscreenPlay = () => { + console.log('执行自动全屏播放') + + // 先进入全屏模式 + if (!document.fullscreenElement) { + toggleFullscreen() + } + + // 短暂延迟后开始播放,确保全屏切换完成 + setTimeout(() => { + if (videoPlayerRef.value) { + showPoster.value = false + videoPlayerRef.value.play().catch(error => { + console.log('自动播放失败,可能需要用户交互:', error) + // 如果自动播放失败,显示播放按钮提示用户点击 + showPoster.value = true + }) + } + }, 200) + } + + // 立即执行全屏逻辑,不等待视频加载 + setTimeout(executeImmediateFullscreen, 100) + + // 同时启动增强的视频准备检查作为备用 + setTimeout(checkVideoReady, 500) }) }) onUnmounted(() => { + // 移除所有浏览器的全屏变化事件监听 document.removeEventListener('fullscreenchange', handleFullscreenChange) + document.removeEventListener('webkitfullscreenchange', handleFullscreenChange) + document.removeEventListener('mozfullscreenchange', handleFullscreenChange) + document.removeEventListener('MSFullscreenChange', handleFullscreenChange) document.removeEventListener('keydown', handleKeydown) if (controlsTimer.value) { clearTimeout(controlsTimer.value) @@ -389,6 +479,92 @@ watch(videoId, () => { }) // 方法 +const getVideoUrl = (video: any) => { + if (!video) return '' + + // 如果video_url已经是完整URL,直接返回 + if (video.video_url && video.video_url.startsWith('http')) { + return video.video_url + } + + // 如果video_url是相对路径,拼接完整URL + if (video.video_url) { + // 确保路径以/开头 + const path = video.video_url.startsWith('/') ? video.video_url : `/${video.video_url}` + return `https://ggl.xi.plus${path}` + } + + // 如果没有video_url,使用file_path + if (video.file_path) { + // 确保路径以/开头,避免双斜杠 + const path = video.file_path.startsWith('/') ? video.file_path : `/${video.file_path}` + return `https://ggl.xi.plus${path}` + } + + return '' +} + +// 验证视频URL是否可访问 +const validateVideoUrl = async (url: string): Promise => { + if (!url) return false + + try { + const response = await fetch(url, { + method: 'HEAD', + cache: 'no-cache' + }) + return response.ok + } catch (error) { + console.warn(`视频URL验证失败: ${url}`, error) + return false + } +} + +// 获取优化的视频URL +const getOptimizedVideoUrl = async (video: any): Promise => { + if (!video) return '' + + const primaryUrl = getVideoUrl(video) + + // 首先验证主要URL + if (await validateVideoUrl(primaryUrl)) { + return primaryUrl + } + + // 如果主要URL不可用,尝试备用方案 + console.warn('主要视频URL不可用,尝试备用方案') + + // 尝试不同的路径组合 + const fallbackUrls = [] + + if (video.file_path) { + const cleanPath = video.file_path.replace(/^\/+/, '/') + fallbackUrls.push(`https://ggl.xi.plus${cleanPath}`) + } + + if (video.video_url) { + const cleanPath = video.video_url.replace(/^\/+/, '/') + fallbackUrls.push(`https://ggl.xi.plus${cleanPath}`) + } + + // 尝试uploads目录 + if (video.filename || video.file_name) { + const filename = video.filename || video.file_name + fallbackUrls.push(`https://ggl.xi.plus/uploads/videos/${filename}`) + } + + // 验证备用URL + for (const url of fallbackUrls) { + if (await validateVideoUrl(url)) { + console.log(`找到可用的备用视频URL: ${url}`) + return url + } + } + + console.error('所有视频URL都不可用') + return primaryUrl // 返回原始URL,让浏览器处理错误 +} + const loadVideoData = async () => { try { videoLoading.value = true @@ -457,10 +633,9 @@ const handleVideoLoaded = () => { if (videoPlayerRef.value) { duration.value = videoPlayerRef.value.duration volume.value = videoPlayerRef.value.volume * 100 - // 自动播放视频 - videoPlayerRef.value.play().catch(error => { - console.log('自动播放失败,可能需要用户交互:', error) - }) + + // 视频加载完成,自动播放逻辑已在onMounted中的全屏播放逻辑中处理 + // 这里不再单独处理自动播放,避免重复播放 } } @@ -496,6 +671,352 @@ const handleVideoEnded = () => { startAutoReplayCountdown() } +// 视频加载开始 +const handleVideoLoadStart = () => { + videoLoadStarted.value = true + videoLoadError.value = false + console.log('视频开始加载...') +} + +// 视频可以播放 +const handleVideoCanPlay = () => { + videoLoadError.value = false + videoRetryCount.value = 0 + console.log('视频可以播放') +} + +// 获取备用视频源 +const getBackupVideoSources = (video: any) => { + const sources = [] + const baseUrl = getVideoUrl(video) + + if (baseUrl) { + // 主要视频源 + sources.push({ + src: baseUrl, + type: 'video/mp4' + }) + + // 尝试不同的视频格式作为备用 + const basePath = baseUrl.replace(/\.[^/.]+$/, '') + sources.push( + { src: `${basePath}.webm`, type: 'video/webm' }, + { src: `${basePath}.ogg`, type: 'video/ogg' } + ) + } + + return sources +} + +// 检查视频格式兼容性 +const checkVideoFormatSupport = () => { + const video = document.createElement('video') + const formats = { + mp4: video.canPlayType('video/mp4; codecs="avc1.42E01E"'), + webm: video.canPlayType('video/webm; codecs="vp8, vorbis"'), + ogg: video.canPlayType('video/ogg; codecs="theora"') + } + + console.log('支持的视频格式:', formats) + return formats +} + +// 强化的视频加载函数 +const loadVideoWithFallback = async (videoElement: HTMLVideoElement, video: any) => { + if (!videoElement || !video) return false + + // 首先尝试获取优化的视频URL + try { + const optimizedUrl = await getOptimizedVideoUrl(video) + if (optimizedUrl && optimizedUrl !== getVideoUrl(video)) { + console.log(`使用优化的视频URL: ${optimizedUrl}`) + + // 清除现有源 + const sources = videoElement.querySelectorAll('source') + sources.forEach(source => source.remove()) + + // 添加新的源 + const source = document.createElement('source') + source.src = optimizedUrl + source.type = 'video/mp4' + videoElement.appendChild(source) + + // 尝试加载 + const optimizedResult = await new Promise((resolve) => { + const timeout = setTimeout(() => { + console.warn('优化URL加载超时') + resolve(false) + }, 8000) // 8秒超时 + + const onLoad = () => { + clearTimeout(timeout) + videoElement.removeEventListener('loadeddata', onLoad) + videoElement.removeEventListener('error', onError) + console.log('优化URL加载成功') + resolve(true) + } + + const onError = () => { + clearTimeout(timeout) + videoElement.removeEventListener('loadeddata', onLoad) + videoElement.removeEventListener('error', onError) + console.warn('优化URL加载失败') + resolve(false) + } + + videoElement.addEventListener('loadeddata', onLoad, { once: true }) + videoElement.addEventListener('error', onError, { once: true }) + + // 开始加载 + videoElement.load() + }) + + if (optimizedResult) { + return true + } + } + } catch (error) { + console.warn('获取优化URL失败:', error) + } + + // 如果优化URL失败,使用原有的备用源策略 + const sources = getBackupVideoSources(video) + let loadSuccess = false + + for (const source of sources) { + try { + console.log(`尝试加载备用视频源: ${source.src}`) + + // 设置视频源 + videoElement.src = source.src + + // 等待视频加载 + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('视频加载超时')) + }, 5000) // 5秒超时 + + const onLoad = () => { + clearTimeout(timeout) + videoElement.removeEventListener('loadeddata', onLoad) + videoElement.removeEventListener('error', onError) + resolve(true) + } + + const onError = (e: Event) => { + clearTimeout(timeout) + videoElement.removeEventListener('loadeddata', onLoad) + videoElement.removeEventListener('error', onError) + reject(e) + } + + videoElement.addEventListener('loadeddata', onLoad, { once: true }) + videoElement.addEventListener('error', onError, { once: true }) + + // 开始加载 + videoElement.load() + }) + + loadSuccess = true + console.log(`备用视频源加载成功: ${source.src}`) + break + + } catch (error) { + console.warn(`备用视频源加载失败: ${source.src}`, error) + continue + } + } + + return loadSuccess +} + +// 强化的立即全屏执行函数 +const executeImmediateFullscreen = async () => { + try { + console.log('执行立即全屏逻辑') + + // 检查是否已经全屏 + if (document.fullscreenElement || isFullscreen.value) { + console.log('已经处于全屏状态,跳过') + return + } + + // 尝试全屏 + await toggleFullscreen() + + // 无论视频是否加载成功,都尝试播放 + setTimeout(() => { + if (videoPlayerRef.value) { + showPoster.value = false + videoPlayerRef.value.play().catch(error => { + console.log('自动播放失败,可能需要用户交互:', error) + // 如果自动播放失败,显示播放按钮提示用户点击 + showPoster.value = true + }) + } + }, 300) + + } catch (error) { + console.warn('立即全屏执行失败:', error) + // 即使全屏失败,也要尝试播放视频 + setTimeout(() => { + if (videoPlayerRef.value) { + showPoster.value = false + videoPlayerRef.value.play().catch(playError => { + console.log('播放失败:', playError) + showPoster.value = true + }) + } + }, 300) + } +} + +// 增强的视频准备检查函数 +const checkVideoReady = async () => { + let attempts = 0 + const maxAttempts = 10 + + const check = async () => { + attempts++ + console.log(`检查视频准备状态 (${attempts}/${maxAttempts})`) + + if (videoPlayerRef.value) { + const video = videoPlayerRef.value + + // 检查视频是否有基本信息 + if (video.duration && video.duration > 0) { + console.log('视频已准备就绪,执行全屏播放') + + try { + // 确保全屏 + if (!document.fullscreenElement && !isFullscreen.value) { + await toggleFullscreen() + } + + // 尝试播放 + showPoster.value = false + await video.play() + console.log('视频播放成功') + + } catch (error) { + console.log('视频播放失败,但保持全屏状态:', error) + showPoster.value = true + } + + return // 成功,退出检查 + } + } + + // 如果还没准备好且未达到最大尝试次数,继续检查 + if (attempts < maxAttempts) { + setTimeout(check, 500) + } else { + console.log('视频准备检查超时,执行降级全屏') + // 即使视频未准备好,也要确保全屏 + if (!document.fullscreenElement && !isFullscreen.value) { + try { + await toggleFullscreen() + } catch (error) { + console.warn('降级全屏失败:', error) + } + } + } + } + + // 开始检查 + setTimeout(check, 100) +} + +// 视频加载错误处理 +const handleVideoError = async (event: Event) => { + const videoElement = event.target as HTMLVideoElement + const error = videoElement.error + + console.error('视频加载错误:', error) + videoLoadError.value = true + + if (error) { + let errorMessage = '视频加载失败' + let shouldRetry = true + + switch (error.code) { + case MediaError.MEDIA_ERR_ABORTED: + errorMessage = '视频加载被中止' + shouldRetry = true // 网络中止通常可以重试 + break + case MediaError.MEDIA_ERR_NETWORK: + errorMessage = '网络错误,无法加载视频' + shouldRetry = true + break + case MediaError.MEDIA_ERR_DECODE: + errorMessage = '视频解码错误' + shouldRetry = false // 解码错误通常不需要重试 + break + case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED: + errorMessage = '视频格式不支持或文件不存在' + shouldRetry = true // 可能是URL问题,尝试备用源 + break + } + + console.error(`视频错误 (${error.code}): ${errorMessage}`) + + // 根据错误类型决定是否重试 + if (shouldRetry && videoRetryCount.value < maxRetryCount) { + videoRetryCount.value++ + console.log(`尝试重新加载视频 (${videoRetryCount.value}/${maxRetryCount})`) + + // 对于网络中止错误,使用更短的延迟 + const retryDelay = error.code === MediaError.MEDIA_ERR_ABORTED ? 500 : 1000 * videoRetryCount.value + + setTimeout(async () => { + if (videoPlayerRef.value && video.value) { + try { + // 清除当前错误状态 + videoLoadError.value = false + + // 使用强化的加载函数重试 + const success = await loadVideoWithFallback(videoPlayerRef.value, video.value) + if (success) { + videoRetryCount.value = 0 + console.log('视频重新加载成功') + + // 重新加载成功后,尝试播放 + setTimeout(() => { + if (videoPlayerRef.value && !isPlaying.value) { + videoPlayerRef.value.play().catch(playError => { + console.log('重新加载后播放失败:', playError) + }) + } + }, 200) + + } else { + throw new Error('所有视频源都加载失败') + } + } catch (retryError) { + console.error('视频重试加载失败:', retryError) + videoLoadError.value = true + + if (videoRetryCount.value >= maxRetryCount) { + ElMessage.error(`${errorMessage},请稍后重试`) + } + } + } + }, retryDelay) + } else { + ElMessage.error(`${errorMessage},请稍后重试`) + } + } + + // 即使视频加载失败,也要尝试全屏(如果是自动播放模式) + // 延迟执行,确保不会被视频错误阻断 + setTimeout(() => { + if (!document.fullscreenElement && !isFullscreen.value) { + console.log('视频加载失败,但仍尝试全屏') + executeImmediateFullscreen() + } + }, 1000) +} + // 视频点击事件处理 const handleVideoClick = (event: Event) => { event.preventDefault() @@ -583,16 +1104,50 @@ const toggleSpeed = () => { } } -const toggleFullscreen = () => { - if (!document.fullscreenElement) { - videoPlayerRef.value?.requestFullscreen() - } else { - document.exitFullscreen() +const toggleFullscreen = async () => { + try { + if (!document.fullscreenElement) { + // 尝试不同的全屏API以确保浏览器兼容性 + const element = videoPlayerRef.value + if (element) { + if (element.requestFullscreen) { + await element.requestFullscreen() + } else if ((element as any).webkitRequestFullscreen) { + // Safari + await (element as any).webkitRequestFullscreen() + } else if ((element as any).mozRequestFullScreen) { + // Firefox + await (element as any).mozRequestFullScreen() + } else if ((element as any).msRequestFullscreen) { + // IE/Edge + await (element as any).msRequestFullscreen() + } + } + } else { + // 退出全屏也需要考虑浏览器兼容性 + if (document.exitFullscreen) { + await document.exitFullscreen() + } else if ((document as any).webkitExitFullscreen) { + await (document as any).webkitExitFullscreen() + } else if ((document as any).mozCancelFullScreen) { + await (document as any).mozCancelFullScreen() + } else if ((document as any).msExitFullscreen) { + await (document as any).msExitFullscreen() + } + } + } catch (error) { + console.warn('全屏操作失败:', error) + // 即使全屏失败,也不阻止其他操作 } } const handleFullscreenChange = () => { - isFullscreen.value = !!document.fullscreenElement + // 检查不同浏览器的全屏状态 + const fullscreenElement = document.fullscreenElement || + (document as any).webkitFullscreenElement || + (document as any).mozFullScreenElement || + (document as any).msFullscreenElement + isFullscreen.value = !!fullscreenElement } const hideControlsAfterDelay = () => { diff --git a/src/utils/http.ts b/src/utils/http.ts index 4301a79..a94e918 100755 --- a/src/utils/http.ts +++ b/src/utils/http.ts @@ -126,7 +126,7 @@ http.interceptors.response.use( const userStore = useUserStore() userStore.logout() ElMessage.error('登录已过期,请重新登录') - router.push('/login') + router.push('/admin/login') return Promise.reject(refreshError) } finally { isRefreshing = false @@ -140,7 +140,7 @@ http.interceptors.response.use( const userStore = useUserStore() userStore.logout() ElMessage.error('登录已过期,请重新登录') - router.push('/login') + router.push('/admin/login') } break case 403: @@ -150,7 +150,7 @@ http.interceptors.response.use( const userStore = useUserStore() userStore.logout() ElMessage.error('登录已过期,请重新登录') - router.push('/login') + router.push('/admin/login') } else { ElMessage.error('权限不足') } diff --git a/start-production.cjs b/start-production.cjs index f4b6ab8..abb54e9 100644 --- a/start-production.cjs +++ b/start-production.cjs @@ -1,56 +1,117 @@ const { spawn } = require('child_process'); const path = require('path'); const express = require('express'); +const fs = require('fs'); -// 生产环境配置 +// 加载生产环境配置文件 +require('dotenv').config({ path: path.join(__dirname, '.env.production') }); + +// 生产环境配置 - 宝塔面板兼容 const API_PORT = process.env.PORT || 4001; const FRONTEND_PORT = process.env.FRONTEND_PORT || 4002; const NODE_ENV = 'production'; +const DOMAIN = process.env.DOMAIN || 'ggl.xi.plus'; + +// 验证端口范围 (4001-4010) +if (API_PORT < 4001 || API_PORT > 4010) { + console.warn(`警告: API端口 ${API_PORT} 不在推荐范围 4001-4010 内`); +} +if (FRONTEND_PORT < 4001 || FRONTEND_PORT > 4010) { + console.warn(`警告: 前端端口 ${FRONTEND_PORT} 不在推荐范围 4001-4010 内`); +} // 设置环境变量 process.env.NODE_ENV = NODE_ENV; process.env.PORT = API_PORT; +process.env.DOMAIN = DOMAIN; -console.log(`启动生产环境服务器`); +console.log(`=== 梦回高句丽 - 生产环境启动 ===`); +console.log(`域名: ${DOMAIN}`); console.log(`后端API端口: ${API_PORT}`); console.log(`前端静态文件端口: ${FRONTEND_PORT}`); console.log(`工作目录: ${process.cwd()}`); +console.log(`Node.js版本: ${process.version}`); +console.log(`启动时间: ${new Date().toLocaleString('zh-CN')}`); + +// 检查dist目录是否存在 +const distPath = path.join(__dirname, 'dist'); +if (!fs.existsSync(distPath)) { + console.error(`错误: dist目录不存在 (${distPath})`); + console.log('请先运行 npm run build 构建前端项目'); + process.exit(1); +} // 启动前端静态文件服务 const app = express(); -app.use(express.static(path.join(__dirname, 'dist'))); -app.get('*', (req, res) => { - res.sendFile(path.join(__dirname, 'dist', 'index.html')); + +// 安全头设置 +app.use((req, res, next) => { + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('X-XSS-Protection', '1; mode=block'); + next(); }); -const frontendServer = app.listen(FRONTEND_PORT, () => { - console.log(`前端静态文件服务已启动,端口: ${FRONTEND_PORT}`); +// 静态文件服务 +app.use(express.static(distPath, { + maxAge: '1y', + etag: true, + lastModified: true +})); + +// SPA路由处理 +app.get('*', (req, res) => { + res.sendFile(path.join(distPath, 'index.html')); }); +const frontendServer = app.listen(FRONTEND_PORT, '0.0.0.0', () => { + console.log(`✅ 前端静态文件服务已启动`); + console.log(` - 端口: ${FRONTEND_PORT}`); + console.log(` - 访问地址: http://${DOMAIN}:${FRONTEND_PORT}`); +}); + +// 检查API服务器文件是否存在 +const apiServerPath = path.join(__dirname, 'api', 'server.ts'); +if (!fs.existsSync(apiServerPath)) { + console.error(`错误: API服务器文件不存在 (${apiServerPath})`); + process.exit(1); +} + // 启动后端API服务器 +console.log(`🚀 启动后端API服务器...`); const apiServer = spawn('node', ['--import', 'tsx/esm', 'api/server.ts'], { - stdio: 'inherit', - env: { - ...process.env, - NODE_ENV: NODE_ENV, - PORT: API_PORT - } + stdio: 'inherit', + env: { + ...process.env, + NODE_ENV: NODE_ENV, + PORT: API_PORT, + DOMAIN: DOMAIN + }, + cwd: __dirname }); // 处理API服务器进程退出 apiServer.on('close', (code) => { - console.log(`API服务器进程退出,退出码: ${code}`); - frontendServer.close(); - process.exit(code); + console.log(`❌ API服务器进程退出,退出码: ${code}`); + frontendServer.close(); + process.exit(code); }); // 处理API服务器错误 apiServer.on('error', (err) => { - console.error('启动API服务器时发生错误:', err); - frontendServer.close(); - process.exit(1); + console.error('❌ 启动API服务器时发生错误:', err); + frontendServer.close(); + process.exit(1); }); +// API服务器启动成功提示 +setTimeout(() => { + console.log(`✅ 后端API服务已启动`); + console.log(` - 端口: ${API_PORT}`); + console.log(` - API地址: http://${DOMAIN}:${API_PORT}/api`); + console.log(`\n🌐 服务器已完全启动,可通过宝塔面板管理`); +}, 2000); + // 优雅关闭 process.on('SIGINT', () => { console.log('\n收到 SIGINT 信号,正在关闭服务器...'); diff --git a/uploads/covers/1758635476112_uv35usqon2s_cover.jpg b/uploads/covers/1758635476112_uv35usqon2s_cover.jpg new file mode 100644 index 0000000..21f7199 Binary files /dev/null and b/uploads/covers/1758635476112_uv35usqon2s_cover.jpg differ diff --git a/uploads/covers/1758635587029_mn919ot6ap_cover.jpg b/uploads/covers/1758635587029_mn919ot6ap_cover.jpg new file mode 100644 index 0000000..43cdc2e Binary files /dev/null and b/uploads/covers/1758635587029_mn919ot6ap_cover.jpg differ diff --git a/uploads/covers/1758635618518_j4v34cgrywo_cover.jpg b/uploads/covers/1758635618518_j4v34cgrywo_cover.jpg new file mode 100644 index 0000000..7eeb723 Binary files /dev/null and b/uploads/covers/1758635618518_j4v34cgrywo_cover.jpg differ diff --git a/uploads/covers/1758635656860_tibi7tdru1l_cover.jpg b/uploads/covers/1758635656860_tibi7tdru1l_cover.jpg new file mode 100644 index 0000000..84db74d Binary files /dev/null and b/uploads/covers/1758635656860_tibi7tdru1l_cover.jpg differ diff --git a/uploads/covers/1758635676482_k11pqslwssr_cover.jpg b/uploads/covers/1758635676482_k11pqslwssr_cover.jpg new file mode 100644 index 0000000..fe1db42 Binary files /dev/null and b/uploads/covers/1758635676482_k11pqslwssr_cover.jpg differ diff --git a/uploads/covers/1758635699097_a80wyypucga_cover.jpg b/uploads/covers/1758635699097_a80wyypucga_cover.jpg new file mode 100644 index 0000000..b10c3b9 Binary files /dev/null and b/uploads/covers/1758635699097_a80wyypucga_cover.jpg differ diff --git a/uploads/covers/1758635716063_80w4a56dvzt_cover.jpg b/uploads/covers/1758635716063_80w4a56dvzt_cover.jpg new file mode 100644 index 0000000..2733c7c Binary files /dev/null and b/uploads/covers/1758635716063_80w4a56dvzt_cover.jpg differ diff --git a/uploads/covers/1758635730773_uwfe7752zhi_cover.jpg b/uploads/covers/1758635730773_uwfe7752zhi_cover.jpg new file mode 100644 index 0000000..4dd3a1e Binary files /dev/null and b/uploads/covers/1758635730773_uwfe7752zhi_cover.jpg differ diff --git a/uploads/covers/1758635751948_s73n8gxszy_cover.jpg b/uploads/covers/1758635751948_s73n8gxszy_cover.jpg new file mode 100644 index 0000000..24c2824 Binary files /dev/null and b/uploads/covers/1758635751948_s73n8gxszy_cover.jpg differ diff --git a/uploads/covers/1758635770693_mikzsixxle_cover.jpg b/uploads/covers/1758635770693_mikzsixxle_cover.jpg new file mode 100644 index 0000000..5ccf509 Binary files /dev/null and b/uploads/covers/1758635770693_mikzsixxle_cover.jpg differ diff --git a/uploads/covers/1758635785954_khgxz5j64o_cover.jpg b/uploads/covers/1758635785954_khgxz5j64o_cover.jpg new file mode 100644 index 0000000..1f46e09 Binary files /dev/null and b/uploads/covers/1758635785954_khgxz5j64o_cover.jpg differ diff --git a/uploads/covers/1758635800547_u7gzdafwr5_cover.jpg b/uploads/covers/1758635800547_u7gzdafwr5_cover.jpg new file mode 100644 index 0000000..2dbcfc2 Binary files /dev/null and b/uploads/covers/1758635800547_u7gzdafwr5_cover.jpg differ diff --git a/uploads/covers/1758635818798_lcntc5bf9a_cover.jpg b/uploads/covers/1758635818798_lcntc5bf9a_cover.jpg new file mode 100644 index 0000000..5f75b41 Binary files /dev/null and b/uploads/covers/1758635818798_lcntc5bf9a_cover.jpg differ diff --git a/uploads/covers/1758635839632_iev7vutltr_cover.jpg b/uploads/covers/1758635839632_iev7vutltr_cover.jpg new file mode 100644 index 0000000..5f75b41 Binary files /dev/null and b/uploads/covers/1758635839632_iev7vutltr_cover.jpg differ diff --git a/uploads/covers/1758635854933_azqcx9o9qhn_cover.jpg b/uploads/covers/1758635854933_azqcx9o9qhn_cover.jpg new file mode 100644 index 0000000..a4355e9 Binary files /dev/null and b/uploads/covers/1758635854933_azqcx9o9qhn_cover.jpg differ diff --git a/uploads/covers/1758635902834_c4hyd3bw3pv_cover.jpg b/uploads/covers/1758635902834_c4hyd3bw3pv_cover.jpg new file mode 100644 index 0000000..eef693b Binary files /dev/null and b/uploads/covers/1758635902834_c4hyd3bw3pv_cover.jpg differ diff --git a/uploads/covers/1758635942774_85s9uzyxynh_cover.jpg b/uploads/covers/1758635942774_85s9uzyxynh_cover.jpg new file mode 100644 index 0000000..f08216e Binary files /dev/null and b/uploads/covers/1758635942774_85s9uzyxynh_cover.jpg differ diff --git a/uploads/videos/1758635476112_uv35usqon2s.mp4 b/uploads/videos/1758635476112_uv35usqon2s.mp4 new file mode 100644 index 0000000..9b0dce9 Binary files /dev/null and b/uploads/videos/1758635476112_uv35usqon2s.mp4 differ diff --git a/uploads/videos/1758635587029_mn919ot6ap.mp4 b/uploads/videos/1758635587029_mn919ot6ap.mp4 new file mode 100644 index 0000000..52d4d01 Binary files /dev/null and b/uploads/videos/1758635587029_mn919ot6ap.mp4 differ diff --git a/uploads/videos/1758635618518_j4v34cgrywo.mp4 b/uploads/videos/1758635618518_j4v34cgrywo.mp4 new file mode 100644 index 0000000..ce9b2cb Binary files /dev/null and b/uploads/videos/1758635618518_j4v34cgrywo.mp4 differ diff --git a/uploads/videos/1758635656860_tibi7tdru1l.mp4 b/uploads/videos/1758635656860_tibi7tdru1l.mp4 new file mode 100644 index 0000000..0a509c9 Binary files /dev/null and b/uploads/videos/1758635656860_tibi7tdru1l.mp4 differ diff --git a/uploads/videos/1758635676482_k11pqslwssr.mp4 b/uploads/videos/1758635676482_k11pqslwssr.mp4 new file mode 100644 index 0000000..7be9b9f Binary files /dev/null and b/uploads/videos/1758635676482_k11pqslwssr.mp4 differ diff --git a/uploads/videos/1758635699097_a80wyypucga.mp4 b/uploads/videos/1758635699097_a80wyypucga.mp4 new file mode 100644 index 0000000..d11d730 Binary files /dev/null and b/uploads/videos/1758635699097_a80wyypucga.mp4 differ diff --git a/uploads/videos/1758635716063_80w4a56dvzt.mp4 b/uploads/videos/1758635716063_80w4a56dvzt.mp4 new file mode 100644 index 0000000..785d057 Binary files /dev/null and b/uploads/videos/1758635716063_80w4a56dvzt.mp4 differ diff --git a/uploads/videos/1758635730773_uwfe7752zhi.mp4 b/uploads/videos/1758635730773_uwfe7752zhi.mp4 new file mode 100644 index 0000000..0721d4a Binary files /dev/null and b/uploads/videos/1758635730773_uwfe7752zhi.mp4 differ diff --git a/uploads/videos/1758635751948_s73n8gxszy.mp4 b/uploads/videos/1758635751948_s73n8gxszy.mp4 new file mode 100644 index 0000000..207fa50 Binary files /dev/null and b/uploads/videos/1758635751948_s73n8gxszy.mp4 differ diff --git a/uploads/videos/1758635770693_mikzsixxle.mp4 b/uploads/videos/1758635770693_mikzsixxle.mp4 new file mode 100644 index 0000000..5fd0dd5 Binary files /dev/null and b/uploads/videos/1758635770693_mikzsixxle.mp4 differ diff --git a/uploads/videos/1758635785954_khgxz5j64o.mp4 b/uploads/videos/1758635785954_khgxz5j64o.mp4 new file mode 100644 index 0000000..c6cb145 Binary files /dev/null and b/uploads/videos/1758635785954_khgxz5j64o.mp4 differ diff --git a/uploads/videos/1758635800547_u7gzdafwr5.mp4 b/uploads/videos/1758635800547_u7gzdafwr5.mp4 new file mode 100644 index 0000000..e6da33f Binary files /dev/null and b/uploads/videos/1758635800547_u7gzdafwr5.mp4 differ diff --git a/uploads/videos/1758635818798_lcntc5bf9a.mp4 b/uploads/videos/1758635818798_lcntc5bf9a.mp4 new file mode 100644 index 0000000..f7fed2d Binary files /dev/null and b/uploads/videos/1758635818798_lcntc5bf9a.mp4 differ diff --git a/uploads/videos/1758635854933_azqcx9o9qhn.mp4 b/uploads/videos/1758635854933_azqcx9o9qhn.mp4 new file mode 100644 index 0000000..93ffaf9 Binary files /dev/null and b/uploads/videos/1758635854933_azqcx9o9qhn.mp4 differ diff --git a/uploads/videos/1758635902834_c4hyd3bw3pv.mp4 b/uploads/videos/1758635902834_c4hyd3bw3pv.mp4 new file mode 100644 index 0000000..3d5c2b4 Binary files /dev/null and b/uploads/videos/1758635902834_c4hyd3bw3pv.mp4 differ diff --git a/uploads/videos/1758635942774_85s9uzyxynh.mp4 b/uploads/videos/1758635942774_85s9uzyxynh.mp4 new file mode 100644 index 0000000..40f7186 Binary files /dev/null and b/uploads/videos/1758635942774_85s9uzyxynh.mp4 differ