diff --git a/README.md b/README.md index 126fa443f..cc23078ff 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,103 @@ # PikPak 个人网页版 + ![pikpak](https://socialify.git.ci/tjsky/pikpak/image?forks=1&language=1&name=1&owner=1&pattern=Signal&stargazers=1&theme=Light) ## 官方地址 - * [PikPak官网](https://mypikpak.com) - * [PikPak官方网页版](https://drive.mypikpak.com/) - * [PikPak官方讨论群组](https://t.me/pikpak_userservice) +* [PikPak官网](https://mypikpak.com) +* [PikPak官方网页版](https://drive.mypikpak.com/) +* [PikPak官方讨论群组](https://t.me/pikpak_userservice) ## Demo - * [Demo](https://tjsky.github.io/pikpak/) + +* [Demo](https://tjsky.github.io/pikpak/) ## 安装教程 - * [教程](https://www.tjsky.net/?p=201) + +* [教程](https://www.tjsky.net/?p=201) ## 对原版代码的修改 -- [x] 增加对IDM下载功能的引导 -- [x] 修正反代代码,支持下载反代(感谢小樱提供修正代码) -- [x] 增加多个反代域名 -- [x] aira2多线程提速 + +- [x] 增加对IDM下载功能的引导 +- [x] 修正反代代码,支持下载反代(感谢小樱提供修正代码) +- [x] 增加多个反代域名 +- [x] aira2多线程提速 + +## 功能特性 + +- 📁 **全面的文件管理**: 支持文件和文件夹的浏览、重命名、复制、移动、删除和回收站管理。 +- 🎥 **在线媒体播放**: 直接在浏览器中播放视频、音频和预览图片。 +- 🧲 **强大的离线下载**: 支持添加磁力链接(Magnet)、HTTP 链接和 PikPak 秒传链接进行离线下载。 +- ⬇️ **灵活的下载方式**: 支持文件直接下载,也可以推送到您自己的 Aria2 服务器进行下载。 +- 🔗 **分享与协作**: 支持创建文件分享链接,方便与他人共享资源。 +- 👥 **完整的账户系统**: 支持邮箱/手机号登录、注册以及邀请码系统。 +- ⚙️ **高度自定义设置**: + - **Aria2 配置**: 对接您自己的 Aria2 服务,实现高效下载。 + - **反向代理设置**: 自定义 API 代理,解决跨域问题。 + - **自定义菜单**: 根据您的需求,为文件操作添加自定义的快捷方式。 + - **Telegram 绑定**: 关联您的 Telegram 账号。 + +## 部署指南 + +您可以将此项目部署在任何静态网站托管平台,如 GitHub Pages, Vercel, Netlify, 或您自己的服务器。部署过程主要分为 **前端项目打包** 和 **配置反向代理** 两个核心步骤。 + +### 步骤 1: 准备工作 + +确保您的本地环境已安装以下软件: +- [Node.js](https://nodejs.org/) (建议使用 v16 或更高版本) +- [Git](https://git-scm.com/) + +### 步骤 2: 获取并打包项目 + +```bash +# 1. 克隆仓库到本地 +git clone [https://github.com/victorqr/pikpak.git](https://github.com/victorqr/pikpak.git) + +# 2. 进入项目目录 +cd pikpak + +# 3. 安装项目依赖 +npm install + +# 4. 打包构建项目 +npm run build +```` + +构建成功后,所有用于部署的静态文件都会生成在 `dist` 文件夹中。 + +### 步骤 3: 配置反向代理 (关键) + +由于浏览器跨域安全策略的限制,我们需要一个反向代理来中转 API 请求。这里我们推荐使用免费的 **Cloudflare Worker**。 + +1. **登录 Cloudflare**: 打开 [Cloudflare Dashboard](https://dash.cloudflare.com/)。 +2. **进入 Workers 和 Pages**: 在左侧菜单中选择 `Workers & Pages`。 +3. **创建 Worker**: 点击 `Create application` -\> `Create Worker`,然后为您的 Worker 命名并部署。 +4. **编辑代码**: 部署成功后,点击 `Edit code`。**清空** 编辑器中所有默认代码,然后将仓库中 `cf-worker/index.js` 文件的全部内容复制并粘贴到编辑器中,最后点击 `Save and Deploy`。 + +现在,你的反向代理已经在 `https://<你的Worker名>.<你的子域名>.workers.dev` 上运行了。 + +### 步骤 4: 修改前端配置并重新打包 + +1. 打开项目代码中的 `src/config/index.ts` 文件。 + +2. 将其中的 `proxy` 数组修改为您刚刚创建的 Worker 地址。 + + ```typescript + export const proxy = [ + 'https://<你的Worker名>.<你的子域名>.workers.dev' + ] + ``` + +3. **保存文件后,重新执行打包命令**: + + ```bash + npm run build + ``` + +### 步骤 5: 部署 + +现在,将 `dist` 文件夹中的所有内容上传到您的静态网站托管平台即可。 ## 致谢 + 本项目 CDN 加速及安全防护由 [Tencent EdgeOne](https://edgeone.ai/zh?from=github) 赞助 -![image](https://github.com/user-attachments/assets/9b486747-77c5-4d28-9ce2-83591de5ee0f) diff --git a/cf-worker/index.js b/cf-worker/index.js index f53dbcd5e..07da1184f 100644 --- a/cf-worker/index.js +++ b/cf-worker/index.js @@ -1,135 +1,166 @@ -addEventListener('fetch', event => { - event.passThroughOnException() - - event.respondWith(handleRequest(event)) - }) - - /** - * Respond to the request - * @param {Request} request - */ - async function handleRequest(event) { +/** + * 预检请求的配置 + */ +const PREFLIGHT_INIT = { + status: 204, + headers: new Headers({ + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS", + "access-control-allow-headers": "Accept, Authorization, Cache-Control, Content-Type, DNT, If-Modified-Since, Keep-Alive, Origin, User-Agent, X-Requested-With, Token, x-access-token, Notion-Version", + }), +}; + +/** + * 黑名单:包含不希望代理的URL关键字或扩展名 + */ +const BLOCKED_KEYWORDS = new Set([ + ".m3u8", + ".ts", + ".acc", + ".m4s", + "photocall.tv", + "googlevideo.com", + "xunleix.com", +]); + +/** + * 白名单:允许代理的域名列表,支持通配符 + * 例如: + * - "mypikpak.com" // 精确匹配 mypikpak.com + * - "*.mypikpak.com" // 匹配所有 mypikpak.com 的子域名 + */ +const ALLOWED_DOMAINS = new Set([ + // PikPak 及其所有子域名 + "*.mypikpak.com", + "mypikpak.com", + + // 其他依赖服务 + "api.notion.com", + "invite.z7.workers.dev", + "pikpak-depot.z10.workers.dev" +]); + +/** + * 检查URL是否在黑名单中 + * @param {string} url 要检查的URL + * @returns {boolean} 如果在黑名单中则返回 true + */ +function isUrlBlocked(url) { + const lowercasedUrl = url.toLowerCase(); + for (const keyword of BLOCKED_KEYWORDS) { + if (lowercasedUrl.includes(keyword)) { + return true; + } + } + return false; +} + +/** + * 检查域名是否在白名单中(支持通配符) + * @param {string} hostname 要检查的域名 + * @returns {boolean} 如果在白名单中则返回 true + */ +function isDomainAllowed(hostname) { + if (ALLOWED_DOMAINS.size === 0) { + // 如果白名单为空,则允许所有域名 + return true; + } + for (const rule of ALLOWED_DOMAINS) { + if (rule.startsWith("*.")) { + if (hostname.endsWith(rule.slice(1)) || hostname === rule.slice(2)) { + return true; + } + } else if (hostname === rule) { + return true; + } + } + return false; +} + +/** + * 处理请求 + * @param {FetchEvent} event + */ +async function handleRequest(event) { const { request } = event; - - //请求头部、返回对象 - let reqHeaders = new Headers(request.headers), - outBody, outStatus = 200, outStatusText = 'OK', outCt = null, outHeaders = new Headers({ - "Access-Control-Allow-Origin": reqHeaders.get('Origin'), - "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", - "Access-Control-Allow-Headers": reqHeaders.get('Access-Control-Allow-Headers') || "Accept, Authorization, Cache-Control, Content-Type, DNT, If-Modified-Since, Keep-Alive, Origin, User-Agent, X-Requested-With, Token, x-access-token, Notion-Version" - }); - + + if (request.method === "OPTIONS") { + return new Response(null, PREFLIGHT_INIT); + } + + const reqHeaders = new Headers(request.headers); + const outHeaders = new Headers({ + "Access-Control-Allow-Origin": reqHeaders.get("Origin") || "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", + "Access-Control-Allow-Headers": reqHeaders.get("Access-Control-Allow-Headers") || "Accept, Authorization, Cache-Control, Content-Type, DNT, If-Modified-Since, Keep-Alive, Origin, User-Agent, X-Requested-With, Token, x-access-token, Notion-Version", + }); + try { - //取域名第一个斜杠后的所有信息为代理链接 - let url = request.url.substr(8); - url = decodeURIComponent(url.substr(url.indexOf('/') + 1)); - - //需要忽略的代理 - if (request.method == "OPTIONS" && reqHeaders.has('access-control-request-headers')) { - //输出提示 - return new Response(null, PREFLIGHT_INIT) + const urlString = decodeURIComponent(request.url.split('/').slice(3).join('/')); + + if (urlString.length < 3 || urlString.indexOf('.') === -1 || ["favicon.ico", "robots.txt"].includes(urlString)) { + return Response.redirect('https://baidu.com', 301); } - else if(url.length < 3 || url.indexOf('.') == -1 || url == "favicon.ico" || url == "robots.txt") { - return Response.redirect('https://baidu.com', 301) + + if (isUrlBlocked(urlString)) { + return Response.redirect('https://baidu.com', 301); } - //阻断 - else if (blocker.check(url)) { - return Response.redirect('https://baidu.com', 301) + + const url = new URL(urlString.startsWith('http') ? urlString : 'http://' + urlString); + + if (!isDomainAllowed(url.hostname)) { + return new Response(`Domain ${url.hostname} is not allowed.`, { status: 403 }); } - else { - //补上前缀 http:// - url = url.replace(/https:(\/)*/,'https://').replace(/http:(\/)*/, 'http://') - if (url.indexOf("://") == -1) { - url = "http://" + url; - } - //构建 fetch 参数 - let fp = { - method: request.method, - headers: {} - } - - //保留头部其它信息 - let he = reqHeaders.entries(); - for (let h of he) { - if (!['content-length'].includes(h[0])) { - fp.headers[h[0]] = h[1]; - } - } - // 是否带 body - if (["POST", "PUT", "PATCH", "DELETE"].indexOf(request.method) >= 0) { - const ct = (reqHeaders.get('content-type') || "").toLowerCase(); - if (ct.includes('application/json')) { - let requestJSON = await request.json() - console.log(typeof requestJSON) - fp.body = JSON.stringify(requestJSON); - } else if (ct.includes('application/text') || ct.includes('text/html')) { - fp.body = await request.text(); - } else if (ct.includes('form')) { - fp.body = await request.formData(); - } else { - fp.body = await request.blob(); - } - } - // 发起 fetch - let fr = (await fetch(new URL(url), fp)); - outCt = fr.headers.get('content-type'); - if(outCt && (outCt.includes('application/text') || outCt.includes('text/html'))) { - try { - // 添加base - let newFr = new HTMLRewriter() - .on("head", { - element(element) { - element.prepend(``, { - html: true - }) - }, - }) - .transform(fr) - fr = newFr - } catch(e) { - } - } - for (const [key, value] of fr.headers.entries()) { - outHeaders.set(key, value); - } + const newReqHeaders = new Headers(reqHeaders); + newReqHeaders.delete('content-length'); + + const fp = { + method: request.method, + headers: newReqHeaders, + body: ["POST", "PUT", "PATCH", "DELETE"].includes(request.method) ? request.body : null, + }; + + const fr = await fetch(new Request(url, fp)); - outStatus = fr.status; - outStatusText = fr.statusText; - outBody = fr.body; + for (const [key, value] of fr.headers.entries()) { + outHeaders.set(key, value); } + + const outCt = fr.headers.get('content-type') || ''; + let outBody = fr.body; + + if (outCt.includes('text/html')) { + try { + const rewriter = new HTMLRewriter().on("head", { + element(element) { + element.prepend(``, { html: true }); + }, + }); + return rewriter.transform(fr); + } catch (e) { + // 如果HTMLRewriter失败,则直接返回原始响应 + } + } + + return new Response(outBody, { + status: fr.status, + statusText: fr.statusText, + headers: outHeaders, + }); + } catch (err) { - outCt = "application/json"; - outBody = JSON.stringify({ + return new Response(JSON.stringify({ code: -1, - msg: JSON.stringify(err.stack) || err + msg: err.stack || String(err), + }), { + status: 500, + headers: { "content-type": "application/json" } }); } - - //设置类型 - if (outCt && outCt != "") { - outHeaders.set("content-type", outCt); - } - - let response = new Response(outBody, { - status: outStatus, - statusText: outStatusText, - headers: outHeaders - }) - - return response; - - // return new Response('OK', { status: 200 }) - } - - /** - * 阻断器 - */ - const blocker = { - keys: [".m3u8", ".ts", ".acc", ".m4s", "photocall.tv", "googlevideo.com", "xunleix.com"], - check: function (url) { - url = url.toLowerCase(); - let len = blocker.keys.filter(x => url.includes(x)).length; - return len != 0; - } - } +} + +addEventListener('fetch', event => { + event.passThroughOnException(); + event.respondWith(handleRequest(event)); +}); \ No newline at end of file diff --git a/src/frosted-glass.css b/src/frosted-glass.css new file mode 100644 index 000000000..12010f5d6 --- /dev/null +++ b/src/frosted-glass.css @@ -0,0 +1,81 @@ +/* --- Frosted Glass Theme for PikPak Web --- */ + +/* 1. Frosted Glass Components */ + +/* Sidebar */ +.n-layout-sider { + background-color: rgba(245, 245, 245, 0.6) !important; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-right: 1px solid rgba(255, 255, 255, 0.18) !important; +} + +/* Main Content Area Header */ +.header { + background-color: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 12px; +} + +/* Data Table */ +.n-data-table { + --merged-th-color: rgba(240, 240, 245, 0.7) !important; + --merged-td-color: rgba(255, 255, 255, 0.4) !important; + --merged-td-color-hover: rgba(240, 240, 245, 0.6) !important; + --merged-border-color: transparent !important; +} + +/* Cards, Modals, Popups, Dialogs */ +.n-card, .n-modal, .n-popover, .n-dialog, .n-collapse { + background-color: rgba(255, 255, 255, 0.75) !important; + backdrop-filter: blur(15px) !important; + -webkit-backdrop-filter: blur(15px) !important; + border: 1px solid rgba(255, 255, 255, 0.2) !important; + border-radius: 16px !important; +} + +/* Login Page Box */ +.login-page .login-box { + background: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(255, 255, 255, 0.18); + box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.1); +} + +/* Bottom Toolbar */ +.toolbar-wrapper { + background: rgba(255, 255, 255, 0.6); + backdrop-filter: blur(15px); + -webkit-backdrop-filter: blur(15px); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 16px; +} +.toolbar-item { + color: #333; +} + +/* 2. Unified Rounded Corners */ +.n-scrollbar, .n-row { + border-radius: 12px; +} +.n-row { + background-color: rgba(255, 255, 255, 0.7); + backdrop-filter: blur(10px); +} + +/* 3. General Adjustments */ +.n-layout, .login-page { + background: initial !important; +} +.sider-bottom { + background-color: transparent !important; +} +.sider-bottom.vip { + background-color: rgba(244, 237, 219, 0.4) !important; +} +.n-menu { + background: transparent !important; +} diff --git a/src/main.ts b/src/main.ts index 2767f5a2e..8c8c14e49 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ import router from './router' import http from './utils/axios' import elementResizeDetectorMaker from 'element-resize-detector' import cnzzAnalytics from 'vue-cnzz-analytics' +import './frosted-glass.css' const app = createApp(App) app.directive('resize', { diff --git a/src/views/login.vue b/src/views/login.vue index 8eb71e6d8..43a41cf0c 100644 --- a/src/views/login.vue +++ b/src/views/login.vue @@ -60,42 +60,83 @@ import { NForm, NFormItem, NInput, NButton, useMessage, NCheckbox, useDialog, NT import http from '../utils/axios' import { useRoute, useRouter } from 'vue-router' import { BrandGoogle, Phone } from '@vicons/tabler' + const loginData = ref({ username: '', - password: '' + password: '', + captcha_token: '' }) const route = useRoute() const loading = ref(false) const router = useRouter() const message = useMessage() + +// 32随机数 +const randomString = () => { + let len = 32; + let chars ='abcdefhijkmnprstwxyz2345678'; + let maxPos = chars.length; + let character = ''; + for (let i = 0; i < len; i++) { + character += chars.charAt(Math.floor(Math.random() * maxPos)) + } + return character; +} +const deviceId = randomString() + +const initCaptcha = () => { + return http.post('https://user.mypikpak.com/v1/shield/captcha/init?client_id=YNxT9w7GMdWvEOKa', { + action: "POST:/v1/auth/signin", + captcha_token: '', + client_id: "YNxT9w7GMdWvEOKa", + device_id: deviceId, + meta: { + "username": loginData.value.username, + }, + redirect_uri: "xlaccsdk01://xunlei.com/callback?state\u003dharbor" + }) + .then((res:any) => { + if(res.data && res.data.captcha_token) { + loginData.value.captcha_token = res.data.captcha_token + } + }) +} + const loginPost = () => { if(!loginData.value.password || !loginData.value.username) { return false } loading.value = true - http.post('https://user.mypikpak.com/v1/auth/signin', { - "captcha_token": "", - "client_id": "YNxT9w7GMdWvEOKa", - "client_secret": "dbw2OtmVEeuUvIptb1Coyg", - ...loginData.value - }) - .then((res:any) => { - if(res.data && res.data.access_token) { - window.localStorage.setItem('pikpakLogin', JSON.stringify(res.data)) - if(remember.value) { - window.localStorage.setItem('pikpakLoginData', JSON.stringify(loginData.value)) - } else { - window.localStorage.removeItem('pikpakLoginData') - } - message.success('登录成功') - router.push('/') - router.push((route.query.redirect || '/') + '') - } - }) - .catch(() => { - loading.value = false + initCaptcha().then(() => { + http.post('https://user.mypikpak.com/v1/auth/signin', { + "client_id": "YNxT9w7GMdWvEOKa", + "client_secret": "dbw2OtmVEeuUvIptb1Coyg", + ...loginData.value }) + .then((res:any) => { + if(res.data && res.data.access_token) { + window.localStorage.setItem('pikpakLogin', JSON.stringify(res.data)) + if(remember.value) { + window.localStorage.setItem('pikpakLoginData', JSON.stringify({username: loginData.value.username, password: loginData.value.password})) + } else { + window.localStorage.removeItem('pikpakLoginData') + } + message.success('登录成功') + router.push((route.query.redirect || '/') + '') + } + }) + .catch(() => { + loading.value = false + }) + .finally(() => { + // 不管成功与否,都重置token + loginData.value.captcha_token = ''; + }) + }).catch(() => { + loading.value = false + }) } + const remember = ref(false) const dialog = useDialog() const showMessage = () => { @@ -204,4 +245,4 @@ const getApk = () => { margin-top: 40px; } } - + \ No newline at end of file