├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── db-init ├── init_sqlite3.py ├── schema_mysql.sql └── schema_sqlite3.sql ├── demo ├── comment.php ├── comment_modify.php ├── index.html ├── login_status.php ├── main.js ├── oauth.php ├── style.css └── userinfo.php ├── doc ├── deploy-docker.md ├── deploy.md └── oauth.md └── server ├── bili ├── __init__.py ├── api.py ├── msg_handler.py ├── token_refresh.py └── utils.py ├── config.toml ├── credential.toml ├── main.py ├── misc ├── __init__.py ├── cookie.py ├── get_version.py ├── hmac_token.py ├── proxy_setup.py ├── requests_session.py └── selftest.py ├── model ├── __init__.py ├── application.py ├── execute_wrapper.py ├── session.py ├── user.py └── verify_request.py ├── requirements.txt ├── run.py ├── service ├── __init__.py ├── auth_middleware.py ├── oauth.py ├── user_info.py ├── verify.py └── view.py ├── static ├── authorize.css ├── authorize.js ├── base.css ├── bili-app-icon.png ├── bili-web-icon.ico ├── create_app.css ├── create_app.js ├── display_userinfo.js ├── icons │ └── levels │ │ ├── lv0.png │ │ ├── lv1.png │ │ ├── lv2.png │ │ ├── lv3.png │ │ ├── lv4.png │ │ ├── lv5.png │ │ └── lv6.png ├── nonlogin.svg ├── not-supported.png ├── qrscan-guide.jpg ├── ua-parser.js ├── ua_parse.js ├── user.css ├── user.js ├── verify.css └── verify.js └── templates ├── authorize.html ├── base.html ├── create_app.html ├── user.html └── verify.html /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | __pycache__ 3 | *.db3 4 | *.db 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.11.5-bullseye AS env-build 2 | COPY server/requirements.txt ./ 3 | RUN pip3 install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple 4 | 5 | FROM python:3.11.5-slim-bullseye 6 | COPY --from=env-build /usr/local/bin/ /usr/local/bin/ 7 | COPY --from=env-build /usr/local/lib/python3.11/site-packages/ /usr/local/lib/python3.11/site-packages/ 8 | WORKDIR /app/ 9 | COPY ./db-init/ /tmp/db-init/ 10 | COPY ./server/ ./db-init/schema_sqlite3.sql /app/ 11 | RUN find /app/ -name ".*" -maxdepth 1 -exec rm -rf {} \; && \ 12 | sed -i 's/host = "localhost"/host = "0.0.0.0"/g' config.toml && \ 13 | sed -i 's/port = 8080/port = 80/g' config.toml && \ 14 | python3 /tmp/db-init/init_sqlite3.py && \ 15 | rm -r /tmp/db-init 16 | 17 | ARG VERSION 18 | ENV VERSION=$VERSION 19 | CMD /usr/local/bin/python3 -u ./run.py 20 | EXPOSE 80 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bili-auth 2 | 3 | 第三方实现的哔哩哔哩 OAuth 2.0 API,无需通过官方平台申请成为开发者,任何用户都可使用。应用只需通过标准的 OAuth 2.0 流程,即可快速接入哔哩哔哩帐号登录。 4 | 5 | 视频展示:[【开源】让你的应用快速接入哔哩哔哩帐号登录 | bili-auth](https://www.bilibili.com/video/BV1iS4y1S7QB) 6 | 7 | ## 对于用户 8 | 9 | 基于私信鉴权,用户仅需按照指引发送一条私信即可完成鉴权。快捷简单,用户容易接受。 10 | 11 | 12 | ## 对于开发者 13 | 14 | 后端所有功能,包括 OAuth Service / 私信收发机器人 等,均使用 `Python` 编写,使用的 Web 框架为 `Flask`。向外提供通用的 OAuth 2.0 API,开发者可以直接调用。 15 | 16 | 完全基于普通用户使用的 API ,不需要申请其他接口,无门槛。开发者只需要一个可以正常收发私信的哔哩哔哩帐号即可搭建自己的 *bili-auth* 服务。 17 | 18 | 提供 Docker 镜像用于快速部署,参考 [Docker 部署](doc/deploy-docker.md)。 19 | 20 | 关于常规的部署流程,包括环境准备、配置填写、应用管理、运行等环节,参考[部署流程](doc/deploy.md)。 21 | 22 | 关于第四方应用(即您自己的应用)的 OAuth 接入,参考 [OAuth 应用管理与接入](doc/oauth.md)。 23 | 24 | 25 | ## Demo 26 | 27 | ~~在本项目的 "demo" 目录中是一个基于 `PHP` 的留言板,它需要用户通过 *bili-auth* 验证哔哩哔哩帐号后方可进行留言操作。您可自行在 PHP-FPM 环境下部署。~~ (此 Demo 已经过时,不适用于当前版本) 28 | 29 | 如果需要在线 Demo ,可以访问[我的博客](https://blog.icyu.me),文章评论区已经接入 *bili-auth* ,作为其中一项登录方式。 30 | 31 | 32 | ## 其他 33 | 34 | 关于此项目的博客文章,以及 bilibili 私信 API 的更多文档: 35 | 36 | 关于此项目的最初设想: 37 | 38 | 39 | ## 免责声明 40 | 41 | 本项目为第三方的开源项目,不属于哔哩哔哩官方提供的服务。 42 | 43 | 由于哔哩哔哩的 API 经常更新,本项目调用的接口存在失效的可能,不保证其长期运行的稳定性。如果您是企业开发者,可使用官方提供的[哔哩哔哩开放平台](https://openhome.bilibili.com/)。 44 | 45 | 本项目所使用到的哔哩哔哩平台的图标、名称、标识等内容仅便于用户识别,所有权归属于其原始所有者。 46 | -------------------------------------------------------------------------------- /db-init/init_sqlite3.py: -------------------------------------------------------------------------------- 1 | #!/bin/python3 2 | 3 | import sqlite3 4 | 5 | db = sqlite3.connect('./bili-auth.db3') 6 | with open('./schema_sqlite3.sql') as f: 7 | schema = f.read() 8 | db.executescript(schema) 9 | db.close() 10 | -------------------------------------------------------------------------------- /db-init/schema_mysql.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `app` ( 2 | `cid` CHAR(8) NOT NULL, 3 | `sec` VARCHAR(30) NOT NULL, 4 | `name` VARCHAR(30) NOT NULL, 5 | `createTs` BIGINT UNSIGNED, 6 | `ownerUid` BIGINT UNSIGNED, 7 | `link` VARCHAR(100), 8 | `prefix` VARCHAR(100), 9 | `desc` VARCHAR(100), 10 | `icon` VARCHAR(100), 11 | PRIMARY KEY(`cid`) 12 | ); 13 | 14 | CREATE TABLE `verify` ( 15 | `vid` CHAR(8) NOT NULL, 16 | `create` INTEGER NOT NULL, 17 | `expire` INTEGER NOT NULL, 18 | `ua` VARCHAR(80), 19 | `uid` BIGINT, 20 | PRIMARY KEY(`vid`) 21 | ); 22 | 23 | CREATE TABLE `session` ( 24 | `sid` INTEGER NOT NULL AUTO_INCREMENT, 25 | `vid` CHAR(8) NOT NULL, 26 | `uid` BIGINT NOT NULL, 27 | `cid` CHAR(8) NOT NULL, 28 | `create` INTEGER NOT NULL, 29 | `accCode` VARCHAR(30), 30 | `token` VARCHAR(32), 31 | PRIMARY KEY(`sid`) 32 | ); 33 | 34 | CREATE TABLE `users` ( 35 | `uid` BIGINT UNSIGNED NOT NULL, 36 | `name` VARCHAR(30), 37 | `avatar` VARCHAR(100), 38 | `raw_data` TEXT, 39 | `updateTs` BIGINT UNSIGNED NOT NULL, 40 | PRIMARY KEY (`uid`) 41 | ); 42 | -------------------------------------------------------------------------------- /db-init/schema_sqlite3.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "app" ( 2 | "cid" TEXT NOT NULL UNIQUE, 3 | "sec" TEXT NOT NULL, 4 | "name" TEXT, 5 | "createTs" BIGINT UNSIGNED, 6 | "ownerUid" BIGINT UNSIGNED, 7 | "link" TEXT, 8 | "prefix" TEXT, 9 | "desc" TEXT, 10 | "icon" TEXT, 11 | PRIMARY KEY("cid") 12 | ); 13 | 14 | CREATE TABLE "verify" ( 15 | "vid" TEXT NOT NULL UNIQUE, 16 | "create" INTEGER NOT NULL, 17 | "expire" INTEGER NOT NULL, 18 | "ua" TEXT, 19 | "uid" INTEGER, 20 | PRIMARY KEY("vid") 21 | ); 22 | 23 | CREATE TABLE "session" ( 24 | "sid" INTEGER NOT NULL UNIQUE, 25 | "vid" TEXT NOT NULL, 26 | "uid" INTEGER NOT NULL, 27 | "cid" INTEGER NOT NULL, 28 | "create" INTEGER NOT NULL, 29 | "accCode" TEXT, 30 | "token" TEXT, 31 | PRIMARY KEY("sid" AUTOINCREMENT) 32 | ); 33 | 34 | CREATE TABLE "users" ( 35 | "uid" INTEGER NOT NULL UNIQUE, 36 | "name" TEXT, 37 | "avatar" TEXT, 38 | "raw_data" TEXT, 39 | "updateTs" INTEGER NOT NULL, 40 | PRIMARY KEY("uid") 41 | ); 42 | -------------------------------------------------------------------------------- /demo/comment.php: -------------------------------------------------------------------------------- 1 | prepare('SELECT pid, sender, context, ts FROM comment ORDER BY ts DESC LIMIT :f, :c;'); 11 | $stmt->bindValue(':f', intval($from)); 12 | $stmt->bindValue(':c', intval($count)); 13 | $result = $stmt->execute(); 14 | 15 | header('Content-Type: application/json'); 16 | $comments = []; 17 | while ($row = $result->fetchArray(SQLITE3_ASSOC)) 18 | $comments[] = $row; 19 | 20 | echo json_encode($comments); 21 | -------------------------------------------------------------------------------- /demo/comment_modify.php: -------------------------------------------------------------------------------- 1 | prepare('INSERT INTO comment (sender, context, ts) VALUES (:sender, :context, :ts)'); 23 | $stmt->bindValue(':sender', $user['uid']); 24 | $stmt->bindValue(':context', $content); 25 | $stmt->bindValue(':ts', time()); 26 | $result = $stmt->execute(); 27 | if ($result === false) { 28 | http_response_code(500); 29 | echo 'database writing failed'; 30 | die; 31 | } 32 | else { 33 | http_response_code(201); 34 | die; 35 | } 36 | } 37 | else if ($method === 'DELETE' && isset($_GET['pid'])) { 38 | $stmt = $db->prepare('SELECT sender FROM comment WHERE pid=:pid'); 39 | $stmt->bindValue(':pid', intval($_GET['pid'])); 40 | $result = $stmt->execute(); 41 | $query = $result->fetchArray(SQLITE3_ASSOC); 42 | if (isset($query) && $query['sender'] === intval($user['uid'])) { 43 | $stmt = $db->prepare('DELETE FROM comment WHERE pid=:pid'); 44 | $stmt->bindValue(':pid', intval($_GET['pid'])); 45 | $result = $stmt->execute(); 46 | if ($result === false) { 47 | http_response_code(500); 48 | echo 'database writing failed'; 49 | die; 50 | } 51 | else { 52 | http_response_code(200); 53 | die; 54 | } 55 | } 56 | } 57 | 58 | http_response_code(405); 59 | -------------------------------------------------------------------------------- /demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | bili登录 demo 6 | 7 | 8 | 9 | 10 |
11 |
12 |
正在加载...
13 |
14 | 17 | 23 | 24 | 25 |

Tip: 提交之后可以删除,随便写点什么吧。

26 |
27 | 28 |
29 | 30 | 40 |
41 | 42 |
43 |
当前第...页(起始页为0,每页10条)
44 | 45 | 46 |
47 | 48 | -------------------------------------------------------------------------------- /demo/login_status.php: -------------------------------------------------------------------------------- 1 | $raw['uid'], 10 | 'name' => $raw['nickname'], 11 | 'bio' => $raw['bio'], 12 | 'avatar' => $raw['avatar'], 13 | ]; 14 | echo json_encode($info); 15 | } 16 | else { 17 | http_response_code(403); 18 | } 19 | -------------------------------------------------------------------------------- /demo/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const client_id = '1a1b4514'; 4 | const oauth_service = 'https://bili-auth.icyu.me:41259/oauth/authorize' 5 | var page = 0; 6 | const pageCommentCount = 10; 7 | var user; 8 | var userDetail = {}; 9 | var avatars = {}; 10 | var selfUid; 11 | 12 | async function init() { 13 | let resp = await fetch('login_status.php'); 14 | if (resp.status !== 200) { 15 | document.getElementById('not-login').hidden = false; 16 | let callback = location.origin + location.pathname +'oauth.php'; 17 | let oauthURL = `${oauth_service}?client_id=${client_id}&redirect_uri=${encodeURIComponent(callback)}`; 18 | document.getElementById('oauth-service').href = oauthURL; 19 | } 20 | else { 21 | document.getElementById('submit-comment').disabled = false; 22 | document.getElementById('submit-comment').innerText = '提交'; 23 | user = await resp.json(); 24 | selfUid = user['uid']; 25 | document.getElementById('self-name').innerText = user['nickname'] 26 | document.getElementById('self-avatar').src = await fetchAvatar(user['avatar']); 27 | document.getElementById('self-bio').innerText = user['bio']; 28 | document.getElementById('user-info').hidden = false; 29 | } 30 | document.getElementById('login-pending').hidden = true; 31 | 32 | await incPage(0); 33 | } 34 | 35 | function displayComments(comments, rmPrevCmt=true) { 36 | let cmtCtn = document.getElementById('comment-container'); 37 | if (rmPrevCmt) 38 | cmtCtn.innerHTML = ''; 39 | 40 | for (const cmt of comments) { 41 | let uid = cmt['sender']; 42 | let tpl = document.getElementById('cmt-template'); 43 | tpl.content.getElementById('nickname-link').href = `https://space.bilibili.com/${uid}`; 44 | tpl.content.getElementById('nickname').innerText = userDetail[uid]['name']; 45 | tpl.content.getElementById('avatar').src = avatars[userDetail[uid]['face']]; 46 | tpl.content.getElementById('bio').innerText = userDetail[uid]['sign']; 47 | tpl.content.getElementById('context').innerText = cmt['context']; 48 | let clone = document.importNode(tpl.content, true); 49 | if (uid === selfUid) { 50 | clone.getElementById('del').onclick = () => { 51 | if (confirm('确认删除评论?')) deleteComment(cmt['pid']); 52 | }; 53 | clone.getElementById('del').hidden = false; 54 | } 55 | cmtCtn.appendChild(clone); 56 | } 57 | } 58 | 59 | async function fetchAvatar(url) { 60 | let origin = url; 61 | url = url.replace('http://', 'https://'); 62 | if (avatars[origin]) 63 | return avatars[url]; 64 | 65 | let resp; 66 | try { 67 | resp = await fetch(`https://bili-auth.icyu.me:41259/proxy/avatar?url=${encodeURIComponent(url)}` + '@32w_32h_1c_1s.webp'); 68 | } 69 | catch (e) { 70 | return null; 71 | } 72 | let data = await resp.blob(); 73 | let srcURL = URL.createObjectURL(data); 74 | avatars[origin] = srcURL; 75 | return srcURL; 76 | } 77 | 78 | async function submitComment() { 79 | let content = document.getElementById('edit-comment').value; 80 | let resp = await fetch('comment_modify.php', { 81 | method: 'post', 82 | credentials: 'same-origin', 83 | headers: { 84 | 'Content-Type': 'application/x-www-form-urlencoded', 85 | }, 86 | body: `content=${encodeURIComponent(content)}`, 87 | }); 88 | if (resp.status == 201) { 89 | alert('提交成功,刷新后显示。'); 90 | } 91 | else { 92 | throw new Error(resp); 93 | alert('提交失败,控制台已输出异常。'); 94 | } 95 | } 96 | 97 | async function deleteComment(pid) { 98 | let resp = await fetch(`comment_modify.php?pid=${pid}`, { 99 | method: 'delete', 100 | credentials: 'same-origin', 101 | }); 102 | if (resp.status === 200) { 103 | alert('删除成功,刷新后消失。'); 104 | } 105 | else { 106 | throw new Error(resp); 107 | alert('删除失败,控制台已输出异常。'); 108 | } 109 | } 110 | 111 | function ts2date(ts){ 112 | var date = new Date(ts*1000); 113 | var Y = date.getFullYear() + '/'; 114 | var M = (date.getMonth()+1 < 10 ? '0'+(date.getMonth()+1) : date.getMonth()+1) + '/'; 115 | var D = (date.getDate() < 10 ? '0'+date.getDate() : date.getDate()) + ' '; 116 | var h = date.getHours() + ':'; 117 | var m = (date.getMinutes() < 10 ? '0'+date.getMinutes() : date.getMinutes())+ ':'; 118 | var s = (date.getSeconds() < 10 ? '0'+date.getSeconds() : date.getSeconds()); 119 | return ''.concat(Y, M, D, h, m, s); 120 | } 121 | 122 | async function incPage(delta) { 123 | page += delta; 124 | 125 | let resp = await fetch(`comment.php?from=${page*pageCommentCount}&count=${pageCommentCount}`); 126 | let comments = await resp.json(); 127 | if (comments.length === 0) { 128 | page -= delta; 129 | alert('没有评论了'); 130 | return; 131 | } 132 | 133 | for (const cmt of comments) { 134 | if (userDetail[cmt['sender']] === undefined) 135 | userDetail[cmt['sender']] = null; 136 | } 137 | 138 | let tasks = []; 139 | for (const uid in userDetail) { 140 | if (userDetail[uid] === null) 141 | tasks.push((async () => { 142 | let resp = await fetch(`userinfo.php?uid=${uid}`); 143 | if (resp.status !== 200) 144 | throw new Error(`fetching user(${uid}) info failed with status: ${resp.status}`); 145 | 146 | let info = await resp.json(); 147 | userDetail[uid] = info; 148 | await fetchAvatar(userDetail[uid]['face']); 149 | })()); 150 | } 151 | await Promise.all(tasks); 152 | 153 | displayComments(comments); 154 | document.getElementById('page-count').innerText = page; 155 | } 156 | 157 | init(); 158 | -------------------------------------------------------------------------------- /demo/oauth.php: -------------------------------------------------------------------------------- 1 | api_url.'?client_id='.client_id.'&client_secret='.client_secret.'&code='.$code, 21 | CURLOPT_RETURNTRANSFER => true, 22 | CURLOPT_ENCODING => '', 23 | CURLOPT_MAXREDIRS => 10, 24 | CURLOPT_TIMEOUT => 0, 25 | CURLOPT_FOLLOWLOCATION => true, 26 | CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, 27 | CURLOPT_CUSTOMREQUEST => 'POST', 28 | ]); 29 | $resp = curl_exec($curl); 30 | 31 | if (curl_getinfo($curl, CURLINFO_HTTP_CODE) === 200) { 32 | http_response_code(200); 33 | $json = json_decode($resp, true); 34 | $_SESSION['user'] = $json; 35 | $_SESSION['user']['next_check'] = time() + check_max_age; 36 | header('Location: '.redirect); 37 | } 38 | else { 39 | http_response_code(400); 40 | echo 'Failed to fetch access_token'; 41 | } 42 | -------------------------------------------------------------------------------- /demo/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | } 4 | 5 | .head-container { 6 | position: relative; 7 | margin: 1em auto; 8 | } 9 | 10 | textarea { 11 | margin: 1em; 12 | height: 10em; 13 | } 14 | 15 | #comment-tip { 16 | display: inline-block; 17 | font-size: 0.6em; 18 | } 19 | 20 | #user-info { 21 | padding: 1em 2em; 22 | border: dashed 1px #ccc; 23 | } 24 | 25 | #comment-container { 26 | margin: 0 8em; 27 | } 28 | 29 | .comment { 30 | position: relative; 31 | border: solid 2px #666; 32 | border-radius: 1em; 33 | margin: 0.5em auto; 34 | padding: 1em; 35 | } 36 | 37 | .comment .nickname { 38 | display: inline-block; 39 | } 40 | 41 | .bio { 42 | display: inline-block; 43 | margin-left: 1em; 44 | color: #444; 45 | font-style: italic; 46 | } 47 | 48 | .avatar { 49 | width: 1.4em; 50 | height: 1.4em; 51 | } 52 | 53 | .pagecount-display-container { 54 | margin: 2em auto; 55 | } 56 | 57 | .head-container, 58 | textarea, 59 | .comment, 60 | .pagecount-display-container { 61 | width: 30em; 62 | } 63 | -------------------------------------------------------------------------------- /demo/userinfo.php: -------------------------------------------------------------------------------- 1 | "https://api.bilibili.com/x/space/acc/info?mid={$uid}", 13 | CURLOPT_RETURNTRANSFER => true, 14 | CURLOPT_ENCODING => '', 15 | CURLOPT_MAXREDIRS => 10, 16 | CURLOPT_TIMEOUT => 0, 17 | CURLOPT_FOLLOWLOCATION => true, 18 | CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, 19 | CURLOPT_CUSTOMREQUEST => 'GET', 20 | )); 21 | 22 | $response = curl_exec($curl); 23 | if (curl_getinfo($curl, CURLINFO_HTTP_CODE) === 200){ 24 | $json = json_decode($response, true); 25 | if ($json['code'] === 0) { 26 | http_response_code(200); 27 | header('Cache-Control: max-age=1800'); 28 | header('Content-Type: application/json'); 29 | } 30 | else 31 | http_response_code(400); 32 | } 33 | else 34 | http_response_code(404); 35 | 36 | curl_close($curl); 37 | 38 | if (http_response_code() === 200) 39 | echo json_encode($json['data']); 40 | -------------------------------------------------------------------------------- /doc/deploy-docker.md: -------------------------------------------------------------------------------- 1 | # Docker 部署 2 | 3 | 本项目已支持通过 Docker 直接部署,但是尚处于**测试阶段**,如您在使用过程中遇到问题或有建议请通过 Issue 提出。 4 | 5 | ## 拉取镜像 6 | 7 | [Docker Hub: icyu/bili-auth](https://hub.docker.com/r/icyu/bili-auth) 8 | 9 | 此镜像包含 Python 运行环境、依赖包和 Selenium 环境。目前仅提供适用于 amd64 架构的镜像。请拉取最新版镜像。 10 | 11 | 您也可以通过项目提供的 Dockerfile 自行构建镜像。 12 | 13 | ## 配置 14 | 15 | 项目位于容器中的 `/app/` 目录,容器的 80 端口是 bili-auth 的 HTTP 服务端口。在构建时已初始化了一个基本的 SQLite3 数据库。 16 | 17 | 要在容器内正常运行服务,您至少需要手动配置以下项: 18 | 19 | - 时钟与时区设置(用于正常显示时间) 20 | - `credential.toml` 中的帐户凭据(参考[部署流程](deploy.md)的相关部分) 21 | 22 | ## 运行 23 | 24 | 直接运行容器即可。 25 | 26 | -------------------------------------------------------------------------------- /doc/deploy.md: -------------------------------------------------------------------------------- 1 | # 部署流程 2 | 3 | 本项目基于 Python 3。如果您不熟悉 Python,请在部署时注意 `python` 和 `python3` 的区别(`pip` 和 `pip3` 同理)。部分 Linux 发行版预装了 Python 2,此时您需要用 `python3` 来运行 `*.py` 文件而非 `python`。 4 | 5 | ## 依赖安装 6 | 7 | 在安装依赖之前,可以先使用 venv 配置虚拟环境。 8 | 9 | 通过依赖列表,安装项目所需依赖: 10 | 11 | ```sh 12 | pip3 install -r ./requirements.txt 13 | ``` 14 | 15 | ## 数据库 16 | 17 | 目前支持 SQLite3 和 MySQL (MariaDB) 两种类型的数据库。 18 | 19 | ### SQLite3(默认) 20 | 在配置文件 `config.toml` 中的 `database` 段填写如下配置: 21 | ```toml 22 | [database] 23 | # 数据库类型 24 | type = "sqlite3" 25 | # 数据库文件的路径 26 | path = "./bili-auth.db3" 27 | ``` 28 | 29 | 初始化数据库结构: 30 | ```sh 31 | # 如果需要自定义数据库名称,则在项目根目录运行以下命令: 32 | sqlite3 ./example.db3 < ./schema_sqlite3.sql 33 | 34 | # 若不改变数据库默认文件名,则直接使用初始化脚本即可: 35 | python3 ./init_sqlite3.py 36 | ``` 37 | 38 | ### MySQL 39 | 40 | 在配置文件 `config.toml` 中的 `database` 段填写如下配置: 41 | 42 | ```toml 43 | [database] 44 | # 数据库类型 45 | type = "mysql" 46 | # 数据库主机地址 47 | host = "localhost" 48 | # 数据库端口 49 | port = 3306 50 | # 库名称 51 | db = "db-name" 52 | # 登录用户名 53 | user = "user" 54 | # 登录密码 55 | pswd = "password" 56 | ``` 57 | 58 | 数据库结构文件为 `schema_mysql.sql`。根据您使用的数据库客户端软件的不同,在数据库控制台执行此文件内的所有 `CREATE` 语句即可。 59 | 60 | ## 填写配置文件 61 | 62 | 配置文件为 `config.toml` 。若没有额外需求,所有配置项均可以保持默认。 63 | 64 | ```toml 65 | [service] 66 | # Web 容器类型。可选"gunicorn"或"flask-default"。 67 | container = "gunicorn" 68 | # HTTP 监听地址。 69 | host = "localhost" 70 | # HTTP 监听端口 71 | port = 8080 72 | 73 | [database] 74 | # 数据库配置。参考上一节的内容。 75 | type = "..." 76 | 77 | [bili] 78 | # 请求 API 使用的 User-Agent。通常不需要修改,使用项目默认的即可。 79 | # 如果您要修改(例如遇到风控无法访问的情况),建议将其替换为主流浏览器最新版的 UA。 80 | user_agent = "..." 81 | 82 | [proxy] 83 | # 代理开关。代理将会在调用需要鉴权的接口时启用。 84 | enable = false 85 | # 代理类型。目前仅支持 HTTP CONNECT 代理,因此不应更改。 86 | type = "http" 87 | # 代理地址。 88 | addr = "host:port" 89 | 90 | # 全局代理。启用后所有请求(包括请求无需鉴权的用户头像与基本信息接口),都通过代理完成。 91 | # 为优化 API 响应速度,默认禁用。即使禁用此项,用户归属地也不会与真实 IP 关联。 92 | globalProxy = false 93 | 94 | # 保留原始响应体。启用后用户信息 API 将返回来自B站 API 的原始响应体,包含用户等级、个性签名等额外信息。 95 | saveRawUserInfo = false 96 | 97 | [oauth_service] 98 | # HMAC 密钥,base64 编码。用于保存用户鉴权信息。默认留空,每次运行时随机生成。 99 | # 如果需要手动指定,可以在终端运行此命令生成: 100 | # head -c 64 /dev/random | base64 101 | hmac_key = "" 102 | 103 | [log] 104 | # 日志格式。参见 105 | format = "..." 106 | 107 | # 以下为调试选项,您可以忽略。 108 | [debug] 109 | # 启动时运行 API 自检,用于检查能否正常访问B站 API 且不被反爬虫屏蔽,结果在日志中显示。 110 | biliApiTest = true 111 | 112 | ``` 113 | 114 | ## 鉴权凭据生成 115 | 116 | 帐户凭据用于在访问哔哩哔哩 API 时验证身份。您可以使用以下任意一种方式配置凭据。 117 | 118 | ### ~~使用脚本~~ 119 | 120 | > 由于 Selenium 依赖暂时从项目中被移除,该方法目前暂不适用。 121 | 122 | ~~项目根目录中的 `create_credential.py` 用于快速生成凭据。此脚本会通过 Selenium 打开浏览器窗口并且跳转到 `bilibili.com`。您需要根据指示登录账号,然后凭据就会被自动保存到 `credential.toml`。~~ 123 | 124 | ~~由于需要您手动操作,此脚本需要在有图形界面,并且配置好 Selenium 的环境运行。如果运行 Web 应用的环境没有图形界面,您也可以在其他符合要求的机器上克隆此项目,配置 Selenium 路径及代理(可选)之后运行脚本,然后将生成的凭据导入 Web 应用的目录。~~ 125 | 126 | ### 手动配置 127 | 128 | `credential.toml` 中一共需要配置 `cookies` 和 `refresh_token` 两项,您也可以手动填入。首先在浏览器上登录您的账号,打开开发者工具,然后执行以下操作: 129 | 130 | 1. 随意选择一个发往 `*.bilibili.com` 的请求,复制请求头中 `Cookie` 对应的值,格式形如 `a=b; c=d`。将这个值填入凭据文件中的 `cookies`。 131 | 2. 查看 `www.bilibili.com` 对应的本地存储中 `ac_time_value` 对应的值,可以在浏览器控制台输入 `localStorage['ac_time_value']` 来获取。将这个值填入凭据文件中的 `refresh_token` 。 132 | 3. 清除 `www.bilibili.com` 的 Cookies。若刷新网页后为未登录状态则操作成功。由于凭据在浏览器端自动刷新后原先凭据将失效,因此上述操作获取到的凭据不应同时在 bili-auth 和您的浏览器中使用,否则 bili-auth 中的凭据将失效。直接退出帐号同样会使当前凭据失效。您可以在执行清除站点 Cookies 的操作后,在浏览器中重新登录帐号。 133 | 134 | ## 运行 135 | 136 | 切换到 `server` 目录,运行 `run.py` 即可启动服务。 137 | -------------------------------------------------------------------------------- /doc/oauth.md: -------------------------------------------------------------------------------- 1 | # 添加 OAuth 应用 2 | 3 | 打开 bili-auth > 点击右上角头像 > 点击“创建的应用” > 点击 “创建新应用”,或者直接访问 `/oauth/application/new`,在创建页面填入信息即可。 4 | 5 | # OAuth 2.0 接入 6 | 7 | 授权页面:`/oauth/authorize?client_id=<应用编号>&redirect_uri=<回调地址>` 8 | 9 | 授权完成后,用户会被重定向到您预设的回调地址,访问码(Access Code)将以 Query param 的形式返回。 10 | 例如您要求重定向至 `https://example.com/oauth/callback`, 11 | 则用户将被重定向至 `https://example.com/oauth/callback?code=ExampleCode`。 12 | 13 | ## API 14 | 15 | ### 获取访问令牌 16 | 17 | ```http 18 | POST /oauth/access_token 19 | ?client_id=<应用编号> 20 | &client_secret=<应用密钥> 21 | &code=<访问码> 22 | ``` 23 | 24 | 成功响应的格式: 25 | ```http 26 | HTTP/1.1 200 OK 27 | Content-Type: application/json 28 | 29 | { 30 | "token": <访问令牌>, 31 | "user": { 32 | "uid": <用户 UID>, 33 | "name": <用户昵称>, 34 | "avatar": <用户头像 URL>, 35 | "raw_data": 36 | } 37 | } 38 | ``` 39 | 40 | ### 获取用户信息 41 | 42 | 43 | 可以通过访问令牌获取: 44 | ```http 45 | GET /api/user 46 | Authorization: Bearer <访问令牌> 47 | ``` 48 | 49 | 或者通过应用凭据获取已授权用户信息: 50 | ```http 51 | GET /api/user 52 | ?uid=<用户 UID> 53 | &client_id=<应用编号> 54 | &client_secret=<应用密钥> 55 | ``` 56 | 57 | 成功响应的格式: 58 | ```http 59 | HTTP/1.1 200 OK 60 | Content-Type: application/json 61 | 62 | { 63 | "uid": <用户 UID>, 64 | "name": <用户昵称>, 65 | "avatar": <用户头像 URL>, 66 | "raw_data": " 67 | } 68 | ``` 69 | -------------------------------------------------------------------------------- /server/bili/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | import sys 4 | import toml 5 | import uuid 6 | 7 | from bili import api 8 | from misc.requests_session import session as rs 9 | from misc.requests_session import noAuthSession as rnas 10 | from misc.cookie import dumpCookies, loadCookies 11 | import misc 12 | 13 | 14 | CREDENTIAL_PATH = 'credential.toml' 15 | 16 | 17 | selfUid = '' 18 | selfName = '' 19 | selfDevId = '' 20 | ua = '' 21 | csrf = '' 22 | cookies = '' 23 | refreshTkn = '' 24 | authedHeader = None 25 | unauthedHeader = None 26 | 27 | 28 | def init(): 29 | global selfUid, selfName, selfDevId, ua 30 | 31 | cfg = misc.config['bili'] 32 | selfDevId = str(uuid.uuid4()).upper() 33 | ua = cfg['user_agent'] 34 | loadCredential() 35 | 36 | try: 37 | resp = api.request( 38 | path='/x/web-interface/nav', 39 | credential=True, 40 | headers={ 41 | 'Origin': 'https://www.bilibili.com', 42 | 'Referer': 'https://www.bilibili.com/', 43 | } 44 | ) 45 | selfUid = resp['mid'] 46 | selfName = resp['uname'] 47 | logging.info(f'logged in as @{selfName} (uid: {selfUid})') 48 | 49 | except api.BiliApiError as e: 50 | if e.code == -101: 51 | logging.error('Session expired. Log in again and refresh the credentials') 52 | sys.exit(1) 53 | else: 54 | raise e 55 | 56 | 57 | def loadCredential(): 58 | cred = toml.load(CREDENTIAL_PATH) 59 | updateCredential(cred['cookies'], cred['refresh_token'], overwrite=False) 60 | 61 | 62 | def updateCredential(newCookies, newRefreshTkn, overwrite=True): 63 | global csrf, cookies, authedHeader, unauthedHeader, refreshTkn 64 | cookies = loadCookies(newCookies) 65 | refreshTkn = newRefreshTkn 66 | 67 | csrf = cookies.get('bili_jct') 68 | if csrf is None: 69 | errInfo = 'Missing "bili_jct" in the cookies' 70 | raise ValueError(errInfo) 71 | 72 | authedHeader = { 73 | 'User-Agent': ua, 74 | 'Origin': 'https://message.bilibili.com', 75 | 'Referer': 'https://message.bilibili.com/', 76 | } 77 | rs.headers = authedHeader 78 | rs.cookies.update(cookies) 79 | unauthedHeader = {'User-Agent': ua} 80 | rnas.headers = unauthedHeader 81 | 82 | if overwrite: 83 | payload = { 84 | 'cookies': dumpCookies(cookies), 85 | 'refresh_token': refreshTkn 86 | } 87 | with open(CREDENTIAL_PATH, 'w') as f: 88 | toml.dump(payload, f) 89 | -------------------------------------------------------------------------------- /server/bili/api.py: -------------------------------------------------------------------------------- 1 | from hashlib import md5 2 | import time 3 | import urllib.parse 4 | 5 | from misc.requests_session import session as rs 6 | from misc.requests_session import noAuthSession as rnas 7 | from requests import HTTPError 8 | 9 | 10 | wbiKey = None 11 | wbiKeyExp = 0 12 | maxAge = 3600 13 | 14 | 15 | # todo: raise api error 16 | class BiliApiError(Exception): 17 | def __init__(self, url, code, msg): 18 | self.code = code 19 | self.msg = msg 20 | self.url = url 21 | 22 | def __repr__(self): 23 | return f'BiliApiError({self.code}, {self.msg})' 24 | 25 | def __str__(self): 26 | return repr(self) 27 | 28 | 29 | def request(*, method='GET', sub='api', path, params=None, headers=None, data=None, timeout=None, wbi=False, credential=False, json_response=True): 30 | assert sub in ['api', 'api.vc', 'passport', 'space', 'www'] 31 | if params is None: 32 | qs = '' 33 | elif wbi: 34 | qs = '?' + wbiSign(params) 35 | else: 36 | qs = '?' + encodeParams(params) 37 | 38 | 39 | url = f'https://{sub}.bilibili.com{path}{qs}' 40 | 41 | session = rs if credential else rnas 42 | resp = session.request(method, url, data=data, headers=headers, timeout=timeout) 43 | resp.raise_for_status() 44 | 45 | if json_response: 46 | body = resp.json() 47 | if body['code'] != 0: 48 | raise BiliApiError(url, body['code'], body['message']) 49 | 50 | data = body.get('data') 51 | if data is not None and data.get('v_voucher') is not None: 52 | raise BiliApiError(url, -3520, 'silent risk control triggered: "v_voucher" detected in response') 53 | 54 | return data 55 | 56 | else: 57 | return resp.text 58 | 59 | 60 | def wbiSign(params): 61 | params['wts'] = int(time.time()) 62 | qs = encodeParams(params) 63 | wbiKey = getWbiKey() 64 | sign = md5((qs + wbiKey).encode()).hexdigest() 65 | return f'{qs}&w_rid={sign}' 66 | 67 | 68 | # reference: 69 | def getWbiKey(): 70 | global wbiKey, wbiKeyExp 71 | curTs = int(time.time()) 72 | if curTs > wbiKeyExp: 73 | # refresh wbi key 74 | resp = rnas.get('https://api.bilibili.com/x/web-interface/nav') 75 | resp.raise_for_status() 76 | data = resp.json() 77 | k1 = data['data']['wbi_img']['img_url'].rsplit('/', 1)[1].split('.')[0] 78 | k2 = data['data']['wbi_img']['sub_url'].rsplit('/', 1)[1].split('.')[0] 79 | k = k1 + k2 80 | mixinKeyEncTab = [ 81 | 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 82 | 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40, 83 | 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11, 84 | 36, 20, 34, 44, 52 85 | ] 86 | wbiKey = ''.join([k[i] for i in mixinKeyEncTab[:32]]) 87 | wbiKeyExp = curTs + maxAge 88 | 89 | assert wbiKey is not None 90 | return wbiKey 91 | 92 | 93 | def encodeParams(params): 94 | params = dict(sorted(params.items())) 95 | query = urllib.parse.urlencode(params) 96 | return query 97 | 98 | -------------------------------------------------------------------------------- /server/bili/msg_handler.py: -------------------------------------------------------------------------------- 1 | import queue; tasks = queue.Queue() # initialization tasks queue 2 | 3 | from random import random 4 | import logging 5 | import re 6 | import requests 7 | import time 8 | 9 | from bili import utils as bu 10 | from misc import config 11 | from model import verify_request as vr 12 | 13 | sendCD = 1 14 | maxErrCnt = 3 15 | 16 | commandPattern = re.compile(r'^/\s*(\S+)(?:\s+(\S.*)$|$)', re.IGNORECASE) 17 | claimPattern = re.compile(r'^确认授权\s*(\S+)$') 18 | ackMts = int(time.time() * 1000000) 19 | lastSendTs = 0 20 | 21 | aboutText = '''【 bili-auth 】 是一个第三方实现的 Bili OAuth API,基于私信验证用户对帐号的所有权。 22 | 它可以让用户使用哔哩哔哩帐号,完成第四方应用鉴权。 23 | 提供通用的 OAuth 2.0 API,应用可快速接入。 24 | 代码开源,开发者可本地部署。 25 | 您可以在 GitHub 查看 icyux/bili-auth 以了解更多。 26 | ''' 27 | 28 | def checkMsg(): 29 | global ackMts 30 | msgList = bu.getNewMsg(ackMts) 31 | for m in msgList: 32 | uid = m['uid'] 33 | content = m['content'].strip() 34 | ts = m['ts'] 35 | logging.info(f'recv<-{uid}: {content}') 36 | 37 | matchResult = claimPattern.search(content) 38 | if matchResult is not None: 39 | vid = matchResult.group(1).lower() 40 | verifyClaimHandler(uid, vid) 41 | 42 | matchResult = commandPattern.search(content) 43 | if matchResult: 44 | action = matchResult.group(1).lower() 45 | arg = matchResult.group(2) 46 | cmdHandler(uid, action, arg) 47 | 48 | 49 | def verifyClaimHandler(uid, vid): 50 | isRespRequired = config['bili']['verifyResultResp'] 51 | isSucc = vr.checkVerify(vid=vid, uid=uid) 52 | if isSucc and isRespRequired: 53 | info = vr.getVerifyInfo(vid) 54 | assert info is not None 55 | 56 | ua = info.get('ua', '未知') 57 | dt = time.strftime('%Y-%m-%d %H:%M:%S (UTC%z)', time.localtime(info['create'])) 58 | reply = '\n'.join(( 59 | '【 bili-auth 】 验证完成,以下为详细信息。', 60 | f'请求来源:{ua}', 61 | f'验证代码:{vid}', 62 | f'创建时间:{dt}', 63 | '您发送的消息是一条验证请求,可用于登录第三方应用。系统自动回复此消息以告知您验证结果。', 64 | f'如果您在不知情的情况下意外发送了此次请求,请回复"/revoke {vid}"以撤销此次验证。', 65 | '如果您对本项目有兴趣,可以发送"/about"进一步了解。' 66 | )) 67 | 68 | else: 69 | reply = '【 bili-auth 】 未找到此验证请求, 可能是此验证信息已过期。请尝试重新发起验证。' 70 | 71 | sendText(uid, reply) 72 | 73 | 74 | def cmdHandler(uid, action, arg): 75 | if action == 'revoke' and arg is not None: 76 | vid = arg.lower() 77 | if vr.revokeVerify(vid=vid, uid=uid) is not None: 78 | reply = '【 bili-auth 】 撤销成功。\n验证代码: {}\n对应的应用授权已立即被全部撤销,但生效时间取决于第四方应用的实现。' 79 | reply = reply.format(arg) 80 | else: 81 | reply = '【 bili-auth 】 未找到此验证代码对应的信息。' 82 | sendText(uid, reply) 83 | elif action == 'about': 84 | sendText(uid, aboutText) 85 | 86 | 87 | def sendText(uid, content): 88 | global lastSendTs 89 | logging.info(f'send->{uid}: {content}') 90 | sleepTime = lastSendTs + sendCD - time.time() 91 | if sleepTime > 0: 92 | time.sleep(sleepTime) 93 | bu.sendMsg(uid, content) 94 | lastSendTs = time.time() 95 | 96 | 97 | def periodicWakeup(): 98 | while True: 99 | expire = int(time.time()) + 1 100 | tasks.put(expire) 101 | time.sleep(5*60) 102 | 103 | 104 | def mainLoop(): 105 | maxExpire = 0 106 | curErrCnt = 0 107 | 108 | while True: 109 | 110 | while maxExpire < time.time(): 111 | curExpire = tasks.get() 112 | if curExpire > maxExpire: 113 | maxExpire = curExpire 114 | 115 | try: 116 | checkMsg() 117 | curErrCnt = 0 118 | except requests.exceptions.RequestException as e: 119 | curErrCnt += 1 120 | if curErrCnt >= maxErrCnt: 121 | logging.warn(f'max requesting error rate reached: {repr(e)}') 122 | 123 | time.sleep(4 + random() * 2) 124 | 125 | -------------------------------------------------------------------------------- /server/bili/token_refresh.py: -------------------------------------------------------------------------------- 1 | from Crypto.Cipher import PKCS1_OAEP 2 | from Crypto.Hash import SHA256 3 | from Crypto.PublicKey import RSA 4 | import binascii 5 | import logging 6 | import re 7 | import time 8 | 9 | from misc.cookie import dumpCookies, loadCookies 10 | from misc.requests_session import session 11 | from bili import api 12 | import bili 13 | 14 | def isRefreshPreferred(): 15 | data = api.request( 16 | method='GET', 17 | sub='passport', 18 | path='/x/passport-login/web/cookie/info', 19 | params={ 20 | 'csrf': bili.csrf, 21 | }, 22 | credential=True, 23 | ) 24 | return data['refresh'] 25 | 26 | 27 | # 28 | def fetchNewCookie(): 29 | pubKey = RSA.importKey('''\ 30 | -----BEGIN PUBLIC KEY----- 31 | MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDLgd2OAkcGVtoE3ThUREbio0Eg 32 | Uc/prcajMKXvkCKFCWhJYJcLkcM2DKKcSeFpD/j6Boy538YXnR6VhcuUJOhH2x71 33 | nzPjfdTcqMz7djHum0qSZA0AyCBDABUqCrfNgCiJ00Ra7GmRj+YCK1NJEuewlb40 34 | JNrRuoEUXpabUzGB8QIDAQAB 35 | -----END PUBLIC KEY-----'''.replace(' ', '').replace('\t', '')) 36 | cipher = PKCS1_OAEP.new(pubKey, SHA256) 37 | 38 | ts = int(time.time() * 1000) 39 | encrypted = cipher.encrypt(f'refresh_{ts}'.encode()) 40 | payload = binascii.b2a_hex(encrypted).decode() 41 | raw_data = api.request( 42 | sub='www', 43 | path=f'/correspond/1/{payload}', 44 | credential=True, 45 | json_response=False, 46 | ) 47 | match = re.search('
(.+)
', raw_data) 48 | if match is None: 49 | raise ValueError('unexpected response format') 50 | refreshCsrf = match.group(1) 51 | 52 | resp = api.request( 53 | method='POST', 54 | sub='passport', 55 | path='/x/passport-login/web/cookie/refresh', 56 | data={ 57 | 'csrf': bili.csrf, 58 | 'refresh_csrf': refreshCsrf, 59 | 'source': 'main_web', 60 | 'refresh_token': bili.refreshTkn, 61 | }, 62 | credential=True, 63 | ) 64 | newRefreshTkn = resp['refresh_token'] 65 | newCsrf = session.cookies.get_dict 66 | newCookies = session.cookies.get_dict() 67 | newCookieStr = dumpCookies(newCookies) 68 | 69 | resp = api.request( 70 | method='POST', 71 | sub='passport', 72 | path='/x/passport-login/web/confirm/refresh', 73 | data={ 74 | 'csrf': newCookies['bili_jct'], 75 | 'refresh_token': bili.refreshTkn, 76 | }, 77 | credential=True, 78 | ) 79 | 80 | return newCookieStr, newRefreshTkn 81 | 82 | 83 | def autoRefreshLoop(): 84 | while True: 85 | try: 86 | isExpired = isRefreshPreferred() 87 | except Exception as e: 88 | logging.warn(f'checking cookie status failed: {repr(e)}') 89 | time.sleep(5 * 60) # 5 minutes interval 90 | continue 91 | 92 | if isExpired: 93 | logging.info('cookie expired. refreshing...') 94 | newCookies, newRefreshTkn = fetchNewCookie() 95 | bili.updateCredential(newCookies, newRefreshTkn) 96 | logging.info('cookie refreshed') 97 | 98 | else: 99 | logging.info('cookie alive') 100 | time.sleep(5 * 3600) # 5 hours interval 101 | -------------------------------------------------------------------------------- /server/bili/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import re 4 | import time 5 | import urllib.parse 6 | 7 | from bili import api 8 | import bili 9 | 10 | 11 | def getNewMsg(beginMts: int, *, recvType: tuple = (1,)): 12 | data = api.request( 13 | method='GET', 14 | sub='api.vc', 15 | path='/session_svr/v1/session_svr/new_sessions', 16 | params={ 17 | 'begin_ts': beginMts, 18 | }, 19 | credential=True, 20 | timeout=10, 21 | ) 22 | if data.get('session_list'): 23 | sessionList = [] 24 | else: 25 | return [] 26 | 27 | ackRequired = False 28 | for session in data['session_list']: 29 | sessionTs = session['session_ts'] 30 | if sessionTs > beginMts: 31 | ackRequired = True 32 | beginMts = sessionTs 33 | if session['unread_count'] == 0: 34 | continue 35 | sessionList.append({ 36 | 'talkerid': session['talker_id'], 37 | 'beginSeq': session['ack_seqno'], 38 | 'endSeq': session['max_seqno'], 39 | }) 40 | 41 | # ack sessions 42 | if ackRequired: 43 | api.request( 44 | method='GET', 45 | sub='api.vc', 46 | path='/session_svr/v1/session_svr/ack_sessions', 47 | params={ 48 | 'begin_ts': beginMts, 49 | }, 50 | credential=True, 51 | ) 52 | 53 | msgList = [] 54 | for s in sessionList: 55 | data = api.request( 56 | method='GET', 57 | sub='api.vc', 58 | path='/svr_sync/v1/svr_sync/fetch_session_msgs', 59 | params={ 60 | 'talker_id': s["talkerid"], 61 | 'begin_seqno': s["beginSeq"], 62 | 'size': 50, 63 | 'session_type': 1, 64 | }, 65 | credential=True, 66 | ) 67 | for m in data['messages']: 68 | if not m['msg_type'] in recvType: 69 | continue 70 | if m['sender_uid'] == bili.selfUid: 71 | continue 72 | if m['msg_type'] == 1: 73 | content = json.loads(m['content'])['content'] 74 | else: 75 | content = m['content'] 76 | msgList.append({ 77 | 'uid': m['sender_uid'], 78 | 'msgType': m['msg_type'], 79 | 'content': content, 80 | 'ts': m['timestamp'], 81 | 'seq': m['msg_seqno'], 82 | }) 83 | 84 | # update session ack 85 | api.request( 86 | method='POST', 87 | sub='api.vc', 88 | path='/session_svr/v1/session_svr/update_ack', 89 | data={ 90 | 'talker_id': s['talkerid'], 91 | 'session_type': 1, 92 | 'ack_seqno': s['endSeq'], 93 | 'csrf_token': bili.csrf, 94 | 'csrf': bili.csrf, 95 | }, 96 | credential=True, 97 | ) 98 | 99 | return msgList 100 | 101 | 102 | def sendMsg(recver: int, content: str, *, msgType: int = 1): 103 | if msgType == 1: 104 | content = content.replace('"', '\\"').replace('\n', '\\n') 105 | content = '{{"content":"{}"}}'.format(content) 106 | 107 | data = api.request( 108 | method='POST', 109 | sub='api.vc', 110 | path='/web_im/v1/web_im/send_msg', 111 | params={ 112 | 'w_sender_uid': bili.selfUid, 113 | 'w_receiver_id': recver, 114 | 'w_dev_id': bili.selfDevId, 115 | }, 116 | data={ 117 | 'msg[sender_uid]': bili.selfUid, 118 | 'msg[receiver_id]': recver, 119 | 'msg[receiver_type]': 1, 120 | 'msg[msg_type]': msgType, 121 | 'msg[msg_status]': 0, 122 | 'msg[content]': content, 123 | 'msg[timestamp]': int(time.time()), 124 | 'msg[new_face_version]': 0, 125 | 'msg[dev_id]': bili.selfDevId, 126 | 'from_firework': 0, 127 | 'build': 0, 128 | 'mobi_app': 'web', 129 | 'csrf_token': bili.csrf, 130 | 'csrf': bili.csrf, 131 | }, 132 | credential=True, 133 | wbi=True, 134 | ) 135 | return data['msg_key'] 136 | 137 | 138 | def getWebId(uid: int): 139 | data = api.request( 140 | method='GET', 141 | sub='space', 142 | path=f'/{uid}', 143 | headers={ 144 | 'Referer': 'https://www.bilibili.com/', 145 | }, 146 | json_response=False, 147 | ) 148 | match = re.search('', data) 149 | if match is None: 150 | raise ValueError('access_id not found in the response') 151 | 152 | payload = json.loads(urllib.parse.unquote(match.group(1))) 153 | webId = payload['access_id'] 154 | return webId 155 | 156 | 157 | def getUserInfo(uid: int): 158 | try: 159 | webId = getWebId(uid) 160 | data = api.request( 161 | method='GET', 162 | path='/x/space/wbi/acc/info', 163 | params={ 164 | 'mid': uid, 165 | 'token': '', 166 | 'platform': 'web', 167 | 'web_location': '1550101', 168 | 'dm_img_list': '[]', 169 | # base64("WebGL 1.0 (OpenGL ES 2.0 Chromium)") 170 | 'dm_img_str': 'V2ViR0wgMS4wIChPcGVuR0wgRVMgMi4wIENocm9taXVtKQ', 171 | # base64("ANGLE (Intel, Intel(R) HD Graphics 4600 (0x00000416) Direct3D11 vs_5_0 ps_5_0, D3D11)Google Inc. (Intel") 172 | 'dm_cover_img_str': 'QU5HTEUgKEludGVsLCBJbnRlbChSKSBIRCBHcmFwaGljcyA0NjAwICgweDAwMDAwNDE2KSBEaXJlY3QzRDExIHZzXzVfMCBwc181XzAsIEQzRDExKUdvb2dsZSBJbmMuIChJbnRlbC', 173 | 'dm_img_inter': '{"ds":[],"wh":[3874,3583,8],"of":[98,196,98]}', 174 | 'w_webid': webId, 175 | }, 176 | headers={ 177 | 'Origin': 'https://space.bilibili.com', 178 | 'Referer': f'https://space.bilibili.com/{uid}', 179 | }, 180 | wbi=True, 181 | ) 182 | return { 183 | 'uid': data['mid'], 184 | 'name': data['name'], 185 | 'avatar': data['face'].replace('http://', 'https://'), 186 | 'raw_data': data, 187 | } 188 | 189 | except api.BiliApiError as e: 190 | # deleted account 191 | if e.code == -404: 192 | return { 193 | 'uid': uid, 194 | 'name': None, 195 | 'avatar': None, 196 | 'raw_data': None, 197 | } 198 | else: 199 | raise e 200 | -------------------------------------------------------------------------------- /server/config.toml: -------------------------------------------------------------------------------- 1 | # for detailed configure instruction, see doc/deploy.md 2 | 3 | [service] 4 | container = "gunicorn" 5 | host = "localhost" 6 | port = 8080 7 | 8 | [database] 9 | # SQLite3 config 10 | 11 | type = "sqlite3" 12 | path = "./bili-auth.db3" 13 | 14 | # MySQL config 15 | 16 | #type = "mysql" 17 | #host = "localhost" 18 | #port = 3306 19 | #db = "db-name" 20 | #user = "user" 21 | #pswd = "password" 22 | 23 | [bili] 24 | user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36" 25 | 26 | # respond after accepting verify request 27 | verifyResultResp = true 28 | 29 | saveRawUserInfo = false 30 | 31 | [proxy] 32 | # proxy for requests 33 | enable = false 34 | # allow HTTP proxy only 35 | type = "http" 36 | addr = "host:port" 37 | 38 | # set if proxy is enabled for all requests including which bearing NO credentials 39 | globalProxy = false 40 | 41 | [oauth_service] 42 | # base64 encoded HMAC key, randomly generated if not specified. 43 | hmac_key = "" 44 | 45 | [log] 46 | format = "[%(module)s] %(levelname)s: %(message)s" 47 | 48 | [debug] 49 | biliApiTest = true 50 | -------------------------------------------------------------------------------- /server/credential.toml: -------------------------------------------------------------------------------- 1 | cookies = "" 2 | refresh_token = "" 3 | -------------------------------------------------------------------------------- /server/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S python3 -u 2 | 3 | import base64 4 | import logging 5 | import secrets 6 | import sqlite3 7 | import threading 8 | import toml 9 | 10 | from misc import proxy_setup 11 | from model import execute_wrapper 12 | import misc 13 | from service import app 14 | import service 15 | import bili 16 | import bili.token_refresh 17 | import bili.utils 18 | import model 19 | 20 | 21 | # init bili utils 22 | bili.init() 23 | 24 | # init proxy 25 | proxy_setup.init() 26 | 27 | # generate global HMAC key 28 | hmacKey = base64.b64decode(misc.config['oauth_service']['hmac_key']) 29 | if hmacKey == b'': 30 | hmacKey = secrets.token_bytes(64) 31 | 32 | service.hmacKey = hmacKey 33 | 34 | # connect database 35 | dbCfg = misc.config['database'] 36 | dbType = dbCfg['type'] 37 | 38 | if dbType == 'sqlite3': 39 | model.initDB(sqlite3.connect(dbCfg['path'], check_same_thread=False)) 40 | 41 | elif dbType == 'mysql': 42 | model.initDB(execute_wrapper.WrappedMysqlConn( 43 | host=dbCfg['host'], 44 | port=dbCfg['port'], 45 | db=dbCfg['db'], 46 | user=dbCfg['user'], 47 | passwd=dbCfg['pswd'], 48 | )) 49 | 50 | else: 51 | raise ValueError('unsupported database type') 52 | 53 | # run message listener 54 | msgThread = threading.Thread(target=bili.msg_handler.mainLoop) 55 | msgThread.daemon = True 56 | msgThread.start() 57 | 58 | # enable periodic wakeup 59 | wakerThread = threading.Thread(target=bili.msg_handler.periodicWakeup) 60 | wakerThread.daemon = True 61 | wakerThread.start() 62 | 63 | # token auto refresh 64 | refreshThread = threading.Thread(target=bili.token_refresh.autoRefreshLoop) 65 | refreshThread.daemon = True 66 | refreshThread.start() 67 | 68 | if __name__ == '__main__': 69 | host = misc.config['service']['host'] 70 | port = misc.config['service']['port'] 71 | app.run(host=host, port=port) 72 | -------------------------------------------------------------------------------- /server/misc/__init__.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import toml 3 | 4 | from misc.get_version import get_version 5 | 6 | 7 | version = get_version() 8 | if version is not None: 9 | print(f'bili-auth {version}') 10 | 11 | # read config 12 | config = toml.load('config.toml') 13 | 14 | # set up logger 15 | logging.basicConfig( 16 | format=config['log']['format'], 17 | level=logging.INFO, 18 | ) 19 | -------------------------------------------------------------------------------- /server/misc/cookie.py: -------------------------------------------------------------------------------- 1 | import urllib.parse 2 | 3 | 4 | def dumpCookies(cookieDict): 5 | return '; '.join([f'{urllib.parse.quote(k)}={urllib.parse.quote(v)}' for k, v in cookieDict.items()]) 6 | 7 | 8 | def loadCookies(cookieStr): 9 | return { 10 | urllib.parse.unquote(parsed[0]): urllib.parse.unquote(parsed[1]) 11 | for parsed in [s.split('=') for s in cookieStr.split('; ')] 12 | } 13 | -------------------------------------------------------------------------------- /server/misc/get_version.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | def get_version(): 5 | version = os.getenv("VERSION") 6 | if version is not None: 7 | return version 8 | 9 | try: 10 | version = subprocess.check_output(["git", "describe", "--tags", "--abbrev=0"], stderr=subprocess.DEVNULL, text=True).strip() 11 | return version 12 | except (FileNotFoundError, subprocess.CalledProcessError): 13 | pass 14 | 15 | return None 16 | -------------------------------------------------------------------------------- /server/misc/hmac_token.py: -------------------------------------------------------------------------------- 1 | import hmac 2 | import secrets 3 | import time 4 | 5 | import service 6 | 7 | 8 | def calcSign(uid, vid, expire): 9 | if uid is None: 10 | body = f'{uid}.{vid}.{expire}' 11 | else: 12 | body = f'{vid}.{expire}' 13 | h = hmac.new(service.hmacKey, body.encode(), 'sha1') 14 | return h.hexdigest() 15 | 16 | 17 | def checkToken(token): 18 | curTs = int(time.time()) 19 | data = token.split('.') 20 | 21 | if not (3 <= len(data) <= 4): 22 | return None 23 | 24 | vid, exp, sign = data[-3:] 25 | try: 26 | uid = int(data[0]) if len(data) == 4 else None 27 | except ValueError: 28 | return None 29 | 30 | trueSign = calcSign(uid, vid, exp) 31 | 32 | if not secrets.compare_digest(sign, trueSign) or curTs > int(exp): 33 | return None 34 | 35 | return { 36 | 'uid': uid, 37 | 'vid': vid, 38 | } 39 | -------------------------------------------------------------------------------- /server/misc/proxy_setup.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import misc 4 | from misc import requests_session 5 | 6 | def init(): 7 | proxyPref = misc.config['proxy'] 8 | isEnabled = proxyPref['enable'] 9 | 10 | if isEnabled: 11 | typ = proxyPref['type'] 12 | addr = proxyPref['addr'] 13 | addrUrl = f'{typ}://{addr}' 14 | isGlobal = proxyPref['globalProxy'] 15 | 16 | # requests proxy 17 | requests_session.session.proxies = { 18 | 'all': addrUrl, 19 | } 20 | 21 | if isGlobal: 22 | requests_session.noAuthSession = requests_session.session 23 | logging.info('proxy enabled (global)') 24 | else: 25 | logging.info('proxy enabled') 26 | -------------------------------------------------------------------------------- /server/misc/requests_session.py: -------------------------------------------------------------------------------- 1 | import misc 2 | import requests 3 | 4 | 5 | session = requests.Session() 6 | noAuthSession = requests.Session() 7 | noAuthSession.headers = { 8 | 'User-Agent': misc.config['bili']['user_agent'], 9 | } 10 | -------------------------------------------------------------------------------- /server/misc/selftest.py: -------------------------------------------------------------------------------- 1 | import bili.utils 2 | 3 | 4 | def biliApiSelfTest(): 5 | try: 6 | bili.utils.getUserInfo(uid=362062895) 7 | print('bili api self-test ok') 8 | return True 9 | 10 | except Exception as e: 11 | print(f'bili api self-test FAILED: {repr(e)}') 12 | return False 13 | -------------------------------------------------------------------------------- /server/model/__init__.py: -------------------------------------------------------------------------------- 1 | from model import application, session, verify_request 2 | 3 | 4 | db = None 5 | 6 | 7 | def initDB(mainDB): 8 | global db 9 | db = mainDB 10 | application.initDB() 11 | session.initDB() 12 | user.initDB() 13 | verify_request.initDB() 14 | -------------------------------------------------------------------------------- /server/model/application.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | import time 3 | 4 | import model 5 | 6 | 7 | def initDB(): 8 | global db 9 | db = model.db 10 | 11 | 12 | def query(cid): 13 | cur = db.cursor() 14 | cur.execute( 15 | 'SELECT * FROM app WHERE cid=?', 16 | (cid, ), 17 | ) 18 | try: 19 | cols = [desc[0] for desc in cur.description] 20 | row = cur.fetchone() 21 | if row is None: 22 | return None 23 | 24 | result = {cols[i]:row[i] for i in range(len(cols))} 25 | return result 26 | 27 | except IndexError: 28 | return None 29 | finally: 30 | cur.close() 31 | 32 | 33 | def updateApp(*, uid, name, icon, link, desc, prefix): 34 | curTs = int(time.time()) 35 | 36 | # retry at most 3 times 37 | for _ in range(3): 38 | cid = secrets.token_hex(4) 39 | if query(cid) is None: 40 | break 41 | else: 42 | return None 43 | 44 | csec = secrets.token_urlsafe(18) 45 | 46 | cur = db.cursor() 47 | cur.execute( 48 | 'REPLACE INTO app \ 49 | (cid, sec, name, ownerUid, createTs, link, prefix, `desc`, icon) \ 50 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', 51 | (cid, csec, name, uid, curTs, link, prefix, desc, icon), 52 | ) 53 | affected = cur.rowcount 54 | cur.close() 55 | db.commit() 56 | 57 | if affected == 1: 58 | return { 59 | 'cid': cid, 60 | 'csec': csec, 61 | } 62 | else: 63 | return None 64 | 65 | 66 | def getAuthorizedApps(uid): 67 | cur = db.cursor() 68 | cur.execute( 69 | 'SELECT cid, name, link, `desc`, icon FROM app WHERE cid = ANY(SELECT cid FROM session WHERE uid = ?)', 70 | (uid, ), 71 | ) 72 | appsInfo = cur.fetchall() 73 | cur.close() 74 | 75 | result = [ 76 | { 77 | 'cid': info[0], 78 | 'name': info[1], 79 | 'link': info[2], 80 | 'desc': info[3], 81 | 'icon': info[4], 82 | } 83 | for info in appsInfo 84 | ] 85 | 86 | return result 87 | 88 | 89 | def getCreatedApps(uid): 90 | cur = db.cursor() 91 | cur.execute( 92 | 'SELECT cid, name, link, `desc`, icon FROM app WHERE ownerUid = ?', 93 | (uid, ), 94 | ) 95 | appsInfo = cur.fetchall() 96 | cur.close() 97 | 98 | result = [ 99 | { 100 | 'cid': info[0], 101 | 'name': info[1], 102 | 'link': info[2], 103 | 'desc': info[3], 104 | 'icon': info[4], 105 | } 106 | for info in appsInfo 107 | ] 108 | 109 | return result 110 | 111 | 112 | def revokeAuthorization(*, cid, uid): 113 | cur = db.cursor() 114 | cur.execute( 115 | 'DELETE FROM session WHERE uid = ? AND cid = ?', 116 | (uid, cid), 117 | ) 118 | affected = cur.rowcount 119 | cur.close() 120 | db.commit() 121 | 122 | return affected > 0 123 | 124 | 125 | def deleteApplication(cid): 126 | cur = db.cursor() 127 | cur.execute( 128 | 'DELETE FROM app WHERE cid = ?', 129 | (cid, ), 130 | ) 131 | affected = cur.rowcount 132 | cur.close() 133 | db.commit() 134 | 135 | return affected > 0 136 | -------------------------------------------------------------------------------- /server/model/execute_wrapper.py: -------------------------------------------------------------------------------- 1 | from pymysql.connections import Connection 2 | from pymysql.cursors import Cursor 3 | 4 | 5 | # convert "?" in SQL statement to "%s", in order to be compatible with pymysql 6 | class WrappedMysqlConn(Connection): 7 | def __init__(self, **kw): 8 | super().__init__(**kw) 9 | 10 | def cursor(self): 11 | # reconnect if conn has been closed 12 | self.ping(reconnect=True) 13 | return WrappedCursor(self) 14 | 15 | 16 | class WrappedCursor(Cursor): 17 | def execute(self, stmt, args=None): 18 | return super().execute(stmt.replace('?', '%s'), args) 19 | -------------------------------------------------------------------------------- /server/model/session.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | import time 3 | 4 | from model import verify_request as vr 5 | import model 6 | 7 | 8 | accCodeLen = 16 9 | tokenLen = 24 10 | 11 | 12 | class VerificationRevokedException(Exception): 13 | pass 14 | 15 | def initDB(): 16 | global db 17 | db = model.db 18 | 19 | 20 | def createSession(*, vid, cid): 21 | createTs = int(time.time()) 22 | accessCode = secrets.token_urlsafe(accCodeLen) 23 | vInfo = vr.getVerifyInfo(vid) 24 | if vInfo is None: 25 | raise VerificationRevokedException() 26 | 27 | uid = vInfo['uid'] 28 | assert vInfo['isAuthed'] 29 | cur = db.cursor() 30 | try: 31 | cur.execute( 32 | 'INSERT INTO session (`vid`, `uid`, `cid`, `create`, `accCode`) VALUES (?,?,?,?,?)', 33 | (vid, uid, cid, createTs, accessCode), 34 | ) 35 | affected = cur.rowcount 36 | assert affected == 1 37 | cur.execute('SELECT sid FROM session ORDER BY sid DESC LIMIT 1') 38 | sid = cur.fetchone()[0] 39 | finally: 40 | cur.close() 41 | db.commit() 42 | 43 | return sid, accessCode 44 | 45 | 46 | def generateAccessToken(*, cid, accCode): 47 | token = secrets.token_urlsafe(tokenLen) 48 | newAccCode = secrets.token_urlsafe(accCodeLen) 49 | cur = db.cursor() 50 | cur.execute( 51 | 'UPDATE session SET accCode=?, token=? WHERE cid=? AND accCode=?', 52 | (newAccCode, token, cid, accCode), 53 | ) 54 | affected = cur.rowcount 55 | cur.close() 56 | db.commit() 57 | if affected == 1: 58 | return token 59 | else: 60 | return None 61 | 62 | def getSessionInfo(key, value): 63 | assert key in ('accCode', 'sid', 'token') 64 | cur = db.cursor() 65 | cur.execute( 66 | f'SELECT * FROM session WHERE {key}=?', 67 | (value, ), 68 | ) 69 | data = cur.fetchone() 70 | columnTitles = [desc[0] for desc in cur.description] 71 | cur.close() 72 | if data is None: 73 | return None 74 | 75 | result = {} 76 | for i in range(len(columnTitles)): 77 | result[columnTitles[i]] = data[i] 78 | 79 | return result 80 | 81 | 82 | def getSessionsByUid(uid, cid=None): 83 | cur = db.cursor() 84 | if cid is None: 85 | cur.execute( 86 | 'SELECT * FROM session WHERE uid=?', 87 | (uid, ), 88 | ) 89 | else: 90 | cur.execute( 91 | 'SELECT * FROM session WHERE uid=? AND cid=?', 92 | (uid, cid), 93 | ) 94 | 95 | cols = [desc[0] for desc in cur.description] 96 | rows = cur.fetchall() 97 | result = [{cols[i]:row[i] for i in range(len(cols))} for row in rows] 98 | cur.close() 99 | return result 100 | 101 | 102 | def revokeSessionByVid(*, vid): 103 | cur = db.cursor() 104 | cur.execute( 105 | 'DELETE FROM session WHERE vid=?', 106 | (vid, ), 107 | ) 108 | affected = cur.rowcount 109 | cur.close() 110 | db.commit() 111 | return affected 112 | -------------------------------------------------------------------------------- /server/model/user.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import time 4 | 5 | from bili import api 6 | from bili import utils as bu 7 | import model 8 | 9 | 10 | def initDB(): 11 | global db 12 | db = model.db 13 | 14 | 15 | def queryUserInfo(uid, *, maxAge=86400*7): 16 | cur = db.cursor() 17 | cur.execute( 18 | 'SELECT uid, name, avatar, raw_data, updateTs FROM users WHERE uid=?', 19 | (uid, ), 20 | ) 21 | data = cur.fetchone() 22 | cur.close() 23 | 24 | if data is None: 25 | return None 26 | 27 | expireTs = data[4] + maxAge 28 | if int(time.time()) > expireTs: 29 | return None 30 | 31 | return { 32 | 'uid': data[0], 33 | 'name': data[1], 34 | 'avatar': data[2], 35 | 'raw_data': json.loads(data[3]) if data[3] is not None else None, 36 | # update time could be hidden 37 | # 'updateTs': data[4], 38 | } 39 | 40 | 41 | def updateUserInfo(uid, data): 42 | cur = db.cursor() 43 | try: 44 | cur.execute( 45 | 'REPLACE INTO users (uid, name, avatar, raw_data, updateTs) VALUES (?, ?, ?, ?, ?)', 46 | (uid, data['name'], data['avatar'], json.dumps(data['raw_data']) if data['raw_data'] is not None else None, int(time.time())), 47 | ) 48 | return cur.rowcount >= 1 49 | 50 | finally: 51 | cur.close() 52 | db.commit() 53 | 54 | 55 | def mustQueryUserInfo(uid): 56 | # query from DB 57 | cachedUserInfo = queryUserInfo(uid) 58 | if cachedUserInfo is not None: 59 | return cachedUserInfo 60 | 61 | # refresh user info 62 | try: 63 | userInfo = bu.getUserInfo(uid) 64 | 65 | except api.BiliApiError as e: 66 | logging.warn(f'failed to fetch "{e.url}": {repr(e)}') 67 | raise e 68 | 69 | isSucc = updateUserInfo(uid, userInfo) 70 | if isSucc: 71 | return userInfo 72 | else: 73 | raise Exception('failed to update DB') 74 | -------------------------------------------------------------------------------- /server/model/verify_request.py: -------------------------------------------------------------------------------- 1 | import secrets 2 | import time 3 | 4 | import model 5 | from model import session 6 | from bili.msg_handler import tasks 7 | 8 | 9 | vidLen = 8 10 | vidCharset = '23456789abcdefghjkmnpqrstvwxyz' 11 | verifyReqMaxAge = 6 * 60 12 | verifySuccMaxAge = 30 * 86400 13 | 14 | 15 | def initDB(): 16 | global db 17 | db = model.db 18 | 19 | 20 | def isVidExisted(vid): 21 | cur = db.cursor() 22 | cur.execute( 23 | 'SELECT 1 FROM verify WHERE vid=?', 24 | (vid, ), 25 | ) 26 | result = cur.fetchone() 27 | cur.close() 28 | return (result is None) 29 | 30 | 31 | def randVid(): 32 | return ''.join([secrets.choice(vidCharset) for _ in range(vidLen)]) 33 | 34 | 35 | def generateVid(): 36 | vid = randVid() 37 | while not isVidExisted(vid): 38 | vid = randVid() 39 | return vid 40 | 41 | 42 | def createVerify(*, userAgent=None): 43 | vid = generateVid() 44 | create = int(time.time()) 45 | expire = create + verifyReqMaxAge 46 | cur = db.cursor() 47 | cur.execute( 48 | 'INSERT INTO verify (`vid`, `create`, `expire`, `ua`) VALUES (?,?,?,?)', 49 | (vid, create, expire, userAgent), 50 | ) 51 | affected = cur.rowcount 52 | cur.close() 53 | db.commit() 54 | if affected == 1: 55 | tasks.put(expire) 56 | return vid, expire 57 | else: 58 | return None 59 | 60 | 61 | def getVerifyInfo(vid): 62 | cur = db.cursor() 63 | cur.execute( 64 | 'SELECT `create`, `expire`, `ua`, `uid` FROM verify WHERE vid=?', 65 | (vid, ), 66 | ) 67 | result = cur.fetchone() 68 | cur.close() 69 | if result is None: 70 | return None 71 | else: 72 | return { 73 | 'vid': vid, 74 | 'create': result[0], 75 | 'expire': result[1], 76 | 'ua': result[2], 77 | 'isAuthed': result[3] is not None, 78 | 'uid': result[3], 79 | } 80 | 81 | 82 | def checkVerify(*, vid, uid): 83 | ts = int(time.time()) 84 | expire = ts + verifySuccMaxAge 85 | cur = db.cursor() 86 | cur.execute( 87 | 'UPDATE verify SET uid=?, expire=? WHERE vid=? AND expire>?', 88 | (uid, expire, vid, ts), 89 | ) 90 | affected = cur.rowcount 91 | cur.close() 92 | db.commit() 93 | return (affected == 1) 94 | 95 | 96 | def revokeVerify(*, vid, uid): 97 | cur = db.cursor() 98 | cur.execute( 99 | 'DELETE FROM verify WHERE vid=? AND uid=?', 100 | (vid, uid), 101 | ) 102 | affected = cur.rowcount 103 | cur.close() 104 | if affected == 1: 105 | return session.revokeSessionByVid(vid=vid) 106 | else: 107 | return None 108 | -------------------------------------------------------------------------------- /server/requirements.txt: -------------------------------------------------------------------------------- 1 | Flask >= 2.0.1 2 | requests >= 2.21.0 3 | toml >= 0.10.1 4 | gunicorn >= 19.10.0 5 | qrcode >= 7.4.2 6 | pillow >= 8.1.0 7 | pymysql >= 1.0.2 8 | pycryptodome >= 3.21.0 9 | -------------------------------------------------------------------------------- /server/run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env -S python3 -u 2 | 3 | import subprocess 4 | 5 | import misc 6 | import misc.selftest 7 | 8 | 9 | config = misc.config 10 | containerType = config['service']['container'] 11 | 12 | # self-test 13 | if config['debug']['biliApiTest']: 14 | print('Performing bili api self-test') 15 | succ = misc.selftest.biliApiSelfTest() 16 | if not succ: 17 | print('Self-test failed, quit.') 18 | exit(1) 19 | 20 | # run web server 21 | if containerType == 'gunicorn': 22 | print('Starting gunicorn') 23 | host = config['service']['host'] 24 | port = config['service']['port'] 25 | proc = subprocess.Popen(f'gunicorn -w 1 -b {host}:{port} --access-logformat "[web] %(s)s %(m)s %(U)s" main:app', shell=True) 26 | proc.wait() 27 | 28 | elif containerType == 'flask-default': 29 | print('Starting flask server') 30 | proc = subprocess.Popen(f'python main.py') 31 | proc.wait() 32 | 33 | else: 34 | print(f'Unknown container type: {containerType}') 35 | exit(1) 36 | -------------------------------------------------------------------------------- /server/service/__init__.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | import logging 3 | 4 | import misc 5 | 6 | app = Flask(__name__, 7 | static_folder='../static', 8 | static_url_path='/static', 9 | template_folder='../templates', 10 | ) 11 | app.debug = False 12 | 13 | 14 | # set app version 15 | @app.context_processor 16 | def inject_globals(): 17 | return { 18 | 'version': misc.version, 19 | } 20 | 21 | 22 | import service.auth_middleware 23 | import service.oauth 24 | import service.user_info 25 | import service.verify 26 | import service.view 27 | 28 | 29 | hmacKey = None 30 | -------------------------------------------------------------------------------- /server/service/auth_middleware.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | import secrets 3 | import time 4 | 5 | from misc.hmac_token import checkToken 6 | 7 | 8 | def authRequired(uidRequired=True): 9 | def middleware(handler): 10 | def wrapper(*args, **kw): 11 | try: 12 | userToken = request.headers['Authorization'][6:] 13 | checkResult = checkToken(userToken) 14 | 15 | if checkResult is None or (uidRequired and checkResult['uid'] is None): 16 | return 'invalid or expired token', 403 17 | 18 | if type(kw) != dict: 19 | kw = {} 20 | 21 | if uidRequired: 22 | kw['uid'] = checkResult['uid'] 23 | kw['vid'] = checkResult['vid'] 24 | 25 | return handler(*args, **kw) 26 | 27 | except (KeyError, IndexError, ValueError): 28 | return 'Invalid token', 400 29 | 30 | # rename wrapper name to prevent duplicated handler name 31 | wrapper.__name__ = handler.__name__ 32 | return wrapper 33 | 34 | return middleware -------------------------------------------------------------------------------- /server/service/oauth.py: -------------------------------------------------------------------------------- 1 | from flask import request, jsonify 2 | import secrets 3 | 4 | from bili import utils as bu 5 | from model import application, user 6 | from model import session 7 | from model.session import VerificationRevokedException 8 | from service import app 9 | from service.auth_middleware import authRequired 10 | 11 | 12 | tokenMaxAge = 86400 13 | 14 | 15 | @app.route('/oauth/application/') 16 | def getApp(cid): 17 | info = application.query(cid) 18 | if info is None: 19 | return '', 404 20 | 21 | rtn = {} 22 | fieldList = ('cid', 'name', 'link', 'desc', 'icon', 'prefix') 23 | for field in fieldList: 24 | rtn[field] = info[field] 25 | 26 | ownerInfo = user.mustQueryUserInfo(info['ownerUid']) 27 | rtn['owner'] = { 28 | 'uid': info['ownerUid'], 29 | 'name': ownerInfo['name'], 30 | 'avatar': ownerInfo['avatar'], 31 | } 32 | 33 | return rtn, 200 34 | 35 | 36 | @app.route('/oauth/application', methods=('POST', )) 37 | @authRequired() 38 | def createApp(*, uid, vid): 39 | appInfo = {} 40 | try: 41 | appInfo['name'] = request.form['name'] 42 | appInfo['icon'] = request.form['icon'] 43 | appInfo['link'] = request.form['link'] 44 | appInfo['desc'] = request.form['desc'] 45 | appInfo['prefix'] = request.form['prefix'] 46 | 47 | except KeyError: 48 | return '', 400 49 | 50 | result = application.updateApp(uid=uid, **appInfo) 51 | if result is None: 52 | return '', 500 53 | else: 54 | return result 55 | 56 | 57 | @app.route('/api/session') 58 | @authRequired() 59 | def querySession(*, uid, vid): 60 | cid = request.args.get('client_id') 61 | origSessions = session.getSessionsByUid(uid, cid) 62 | finalSessions = [] 63 | fieldList = ('sid', 'vid', 'cid', 'create', 'accCode') 64 | for origSess in origSessions: 65 | sess = {} 66 | for field in fieldList: 67 | sess[field] = origSess[field] 68 | finalSessions.append(sess) 69 | return jsonify(finalSessions) 70 | 71 | 72 | @app.route('/api/session', methods=('POST', )) 73 | @authRequired() 74 | def createSession(*, uid, vid): 75 | cid = request.args['client_id'] 76 | try: 77 | sid, accCode = session.createSession( 78 | vid=vid, 79 | cid=cid, 80 | ) 81 | return { 82 | 'sessionId': sid, 83 | 'accessCode': accCode, 84 | }, 200 85 | 86 | except VerificationRevokedException: 87 | return 'verification has been revoked', 403 88 | 89 | 90 | 91 | @app.route('/oauth/access_token', methods=('POST', )) 92 | def createAccessToken(): 93 | try: 94 | cid = request.args['client_id'] 95 | csec = request.args['client_secret'] 96 | code = request.args['code'] 97 | except IndexError: 98 | return '', 400 99 | 100 | expectSec = application.query(cid).get('sec') 101 | if csec == '' or not secrets.compare_digest(expectSec, csec): 102 | return 'Invalid client id or client secret', 403 103 | 104 | tkn = session.generateAccessToken( 105 | cid=cid, 106 | accCode=code, 107 | ) 108 | if tkn is None: 109 | return 'Invalid access code', 403 110 | 111 | sessionInfo = session.getSessionInfo('token', tkn) 112 | userInfo = user.queryUserInfo(sessionInfo['uid']) 113 | return { 114 | 'token': tkn, 115 | 'user': userInfo, 116 | } 117 | 118 | 119 | @app.route('/api/user/apps/authorized', methods=('DELETE', )) 120 | @authRequired() 121 | def revokeAuthorization(*, uid, vid): 122 | cid = request.args.get('cid') 123 | if cid is None: 124 | return '', 400 125 | 126 | result = application.revokeAuthorization(uid=uid, cid=cid) 127 | if result is True: 128 | return '', 200 129 | else: 130 | return '', 404 131 | 132 | 133 | @app.route('/oauth/application/', methods=('DELETE', )) 134 | @authRequired() 135 | def deleteApplication(cid, *, uid, vid): 136 | appInfo = application.query(cid) 137 | if appInfo is None: 138 | return '', 404 139 | 140 | if appInfo['ownerUid'] != uid: 141 | return '', 403 142 | 143 | result = application.deleteApplication(cid) 144 | if result is True: 145 | return '', 200 146 | else: 147 | return '', 500 148 | -------------------------------------------------------------------------------- /server/service/user_info.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | import logging 3 | import secrets 4 | 5 | from bili import api 6 | from bili import utils as bu 7 | from misc.hmac_token import checkToken 8 | from model import application, session, user 9 | from service import app 10 | from service.auth_middleware import authRequired 11 | import misc 12 | 13 | 14 | @app.route('/api/user') 15 | def getCurUserInfo(): 16 | # an OAuth app is querying user info via client credentials 17 | if request.args.get('uid') is not None: 18 | try: 19 | uid = int(request.args['uid']) 20 | clientId = request.args['client_id'] 21 | clientSecret = request.args['client_secret'] 22 | except (KeyError, ValueError): 23 | return 'illegal params', 400 24 | 25 | # check client secret 26 | # todo: integrated client auth 27 | expectSec = application.query(clientId).get('sec') 28 | if clientSecret == '' or not secrets.compare_digest(expectSec, clientSecret): 29 | return 'unauthorized client', 403 30 | 31 | # todo: check permission with one SQL query 32 | sessions = session.getSessionsByUid(uid) 33 | for s in sessions: 34 | if s['cid'] == clientId: 35 | break 36 | else: 37 | return 'not authorized user', 403 38 | 39 | # an authentified user is querying self info 40 | elif request.headers.get('Authorization', '')[:5] == 'BUTKN': 41 | tkn = request.headers['Authorization'][6:] 42 | tknInfo = checkToken(tkn) 43 | if tknInfo is None or tknInfo.get('uid') is None: 44 | return 'authentication required', 403 45 | 46 | uid = tknInfo['uid'] 47 | 48 | # an OAuth app is querying user info via OAuth token 49 | elif request.headers.get('Authorization', '')[:6] == 'Bearer': 50 | oauthTkn = request.headers['Authorization'][7:] 51 | sessInfo = session.getSessionInfo('token', oauthTkn) 52 | if sessInfo is not None: 53 | uid = sessInfo['uid'] 54 | else: 55 | return 'illegal OAuth token', 403 56 | 57 | # illegal request 58 | else: 59 | return 'credentials required', 401 60 | 61 | # return user info 62 | try: 63 | userInfo = user.mustQueryUserInfo(uid) 64 | if not (misc.config['bili']['saveRawUserInfo'] == True): 65 | userInfo.pop('raw_data') 66 | return userInfo 67 | 68 | except api.BiliApiError: 69 | return 'failed to fetch user info', 502 70 | 71 | 72 | @app.route('/api/user/apps/authorized') 73 | @authRequired(uidRequired=True) 74 | def getAuthorizedApps(*, uid, vid): 75 | return application.getAuthorizedApps(uid) 76 | 77 | 78 | @app.route('/api/user/apps/created') 79 | @authRequired(uidRequired=True) 80 | def getCreatedApps(*, uid, vid): 81 | return application.getCreatedApps(uid) 82 | -------------------------------------------------------------------------------- /server/service/verify.py: -------------------------------------------------------------------------------- 1 | from flask import request 2 | import io 3 | import qrcode 4 | 5 | from misc.hmac_token import calcSign 6 | from model import verify_request as vr 7 | from service import app 8 | from service.auth_middleware import authRequired 9 | import bili 10 | 11 | 12 | @app.route('/api/verify/', methods=('GET',)) 13 | @authRequired(uidRequired=False) 14 | def queryVerifyInfo(vidParam, *, vid): 15 | assert vidParam == vid 16 | result = vr.getVerifyInfo(vid) 17 | if result is None: 18 | return '', 404 19 | elif result['isAuthed'] == False: 20 | return result, 202 21 | else: 22 | uid = result['uid'] 23 | expire = result['expire'] 24 | sign = calcSign(uid, vid, expire) 25 | finalToken = f'{uid}.{vid}.{expire}.{sign}' 26 | result['token'] = finalToken 27 | return result, 200 28 | 29 | 30 | @app.route('/api/verify', methods=('POST',)) 31 | def createVerify(): 32 | try: 33 | data = request.get_json() 34 | ua = data['ua'] 35 | except Exception: 36 | ua = None 37 | 38 | vid, expire = vr.createVerify(userAgent=ua) 39 | sign = calcSign(None, vid, expire) 40 | token = f'{vid}.{expire}.{sign}' 41 | 42 | return { 43 | 'vid': vid, 44 | 'token': token, 45 | 'expire': expire, 46 | 'botInfo': { 47 | 'name': bili.selfName, 48 | 'uid': bili.selfUid, 49 | 'qrcode': '/bot-qrcode', 50 | }, 51 | }, 201 52 | 53 | 54 | @app.route('/api/verify/', methods=('DELETE',)) 55 | def delVerify(vid): 56 | return 'deleting verify is currently unavailable', 501 57 | if not uid: 58 | return '', 400 59 | try: 60 | result = auth_handler.revokeVerify(code, int(uid)) 61 | if result: 62 | return '', 200 63 | else: 64 | return '', 404 65 | except ValueError: 66 | return '', 400 67 | 68 | 69 | @app.route('/bot-qrcode') 70 | def botQrcode(): 71 | img = qrcode.make(f'bilibili://space/{bili.selfUid}', border=1) 72 | buf = io.BytesIO() 73 | img.save(buf, format='PNG') 74 | data = buf.getvalue() 75 | return data, 200, ( 76 | ('Content-Type', 'image/png'), 77 | ) 78 | -------------------------------------------------------------------------------- /server/service/view.py: -------------------------------------------------------------------------------- 1 | from flask import render_template 2 | 3 | from service import app 4 | import bili 5 | 6 | 7 | @app.route('/') 8 | def mainPage(): 9 | return render_template('base.html', homepage=True) 10 | 11 | 12 | @app.route('/verify') 13 | def verifyPage(): 14 | return render_template('verify.html', **{ 15 | 'botUid': bili.selfUid, 16 | 'botName': bili.selfName, 17 | }) 18 | 19 | 20 | @app.route('/oauth/authorize') 21 | def oauthAuthorizePage(): 22 | return render_template('authorize.html') 23 | 24 | 25 | @app.route('/user') 26 | def userPage(): 27 | return render_template('user.html') 28 | 29 | 30 | @app.route('/oauth/application/new') 31 | def appCreatePage(): 32 | return render_template('create_app.html') 33 | -------------------------------------------------------------------------------- /server/static/authorize.css: -------------------------------------------------------------------------------- 1 | img.icon { 2 | width: 5em; 3 | height: 5em; 4 | border-radius: 6px; 5 | } 6 | 7 | .info-display { 8 | display: flex; 9 | align-items: center; 10 | justify-content: center; 11 | } 12 | 13 | .info-banner { 14 | display: inline-flex; 15 | margin: 3em 0; 16 | align-items: center; 17 | justify-content: center; 18 | } 19 | 20 | .info-item { 21 | display: inline-block; 22 | } 23 | 24 | .info-item div { 25 | text-align: center; 26 | } 27 | 28 | #middle-arrow { 29 | display: inline-block; 30 | font-size: 3em; 31 | margin: 0 1em; 32 | } 33 | 34 | .app-detail { 35 | display: inline-block; 36 | padding: 1em 3em; 37 | border-radius: 1em; 38 | width: max-content; 39 | margin: 0 3em; 40 | border-style: solid; 41 | border-color: #333; 42 | } 43 | 44 | .app-detail h4 { 45 | margin-top: 0; 46 | margin-bottom: 0.5em; 47 | } 48 | 49 | #authorize { 50 | display: block; 51 | margin: 1em auto; 52 | background-color: #3a3; 53 | color: #fff; 54 | font-size: 1.2em; 55 | padding: 0.4em 1.5em; 56 | border: solid #333 2px; 57 | border-radius: 0.5em; 58 | transition: background-color 0.3s; 59 | } 60 | 61 | #authorize:hover { 62 | background-color: #37cc37; 63 | } 64 | 65 | #authorize:disabled { 66 | background-color: #686; 67 | } 68 | 69 | @media screen and (max-width: 700px) { 70 | img.icon { 71 | width: 3em; 72 | height: 3em; 73 | } 74 | #middle-arrow { 75 | font-size: 2rem; 76 | margin: 0 0.5em; 77 | } 78 | .info-display { 79 | display: initial; 80 | width: 100%; 81 | height: max-content; 82 | } 83 | .info-banner { 84 | display: flex; 85 | margin: 1em 0; 86 | } 87 | .app-detail { 88 | display: block; 89 | margin: 1em auto; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /server/static/authorize.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var cid; 4 | var redirect; 5 | var vid; 6 | var code; 7 | var authHeader; 8 | var step = 0; 9 | const finalStep = 2; 10 | 11 | async function getApplication(cid) { 12 | let req = await fetch(`/oauth/application/${cid}`); 13 | if (req.status != 200) 14 | return null; 15 | 16 | let resp = await req.json(); 17 | return resp; 18 | } 19 | 20 | async function init() { 21 | const tkn = localStorage['verifyToken']; 22 | if (tkn === undefined) { 23 | verifyRedirect(); 24 | return; 25 | } 26 | const [uid, vid, expire, sign] = tkn.split('.'); 27 | let currTs = Math.floor(new Date().getTime() / 1000); 28 | if (expire < currTs) { 29 | localStorage.removeItem('verifyToken'); 30 | verifyRedirect(); 31 | return; 32 | } 33 | authHeader = {'Authorization': `BUTKN ${tkn}`}; 34 | 35 | await setUserInfo(uid); 36 | 37 | let arg = {}; 38 | try { 39 | let queryArgs = window.location.href.split('?')[1].split('&'); 40 | for (let kv of queryArgs) { 41 | let [k, v] = kv.split('=').map(decodeURIComponent); 42 | arg[k] = v; 43 | } 44 | console.log(arg); 45 | } 46 | catch (TypeError) { 47 | // pass 48 | } 49 | 50 | let appInfo = await getApplication(arg['client_id']); 51 | if (appInfo === null) { 52 | document.getElementById('pending').innerText = '此应用不存在,请咨询应用管理者。'; 53 | return; 54 | } 55 | 56 | redirect = arg['redirect_uri']; 57 | if (redirect === undefined) { 58 | document.getElementById('pending').innerText = '回调 URL 未指定,请咨询应用管理者。'; 59 | return; 60 | } 61 | 62 | if (redirect.indexOf(appInfo.prefix) !== 0) { 63 | document.getElementById('pending').innerText = '回调 URL 与预设前缀不匹配,请咨询应用管理者。'; 64 | return; 65 | } 66 | 67 | cid = appInfo['cid']; 68 | 69 | document.getElementById('app-id').innerText = cid; 70 | document.getElementById('app-name').innerText = appInfo['name']; 71 | document.getElementById('app-url').href = appInfo['link']; 72 | document.getElementById('app-url').innerText = appInfo['link']; 73 | document.getElementById('app-icon').src = appInfo['icon']; 74 | document.getElementById('app-desc').innerText = appInfo['desc']; 75 | document.getElementById('app-creator').innerText = `@${appInfo.owner.name}`; 76 | document.getElementById('app-creator').href = `https://space.bilibili.com/${appInfo.owner.uid}`; 77 | document.getElementById('redirect-uri').innerText = redirect; 78 | nextStep(); 79 | } 80 | 81 | function nextStep() { 82 | step++; 83 | document.getElementById(`step-${step-1}`).hidden = true; 84 | document.getElementById(`step-${step}`).hidden = false; 85 | } 86 | 87 | function setButtonDisable(state) { 88 | for (let e of document.getElementsByClassName('next-step')) 89 | e.disabled = state; 90 | } 91 | 92 | async function queryExistedAccCode() { 93 | let resp = await fetch(`/api/session?client_id=${cid}`, { 94 | headers: authHeader, 95 | }); 96 | if (resp.status === 403) { 97 | throw new Error('Invalid token'); 98 | } 99 | 100 | let sessions = await resp.json(); 101 | if (sessions.length > 0) { 102 | return sessions[0].accCode; 103 | } 104 | else { 105 | return null; 106 | } 107 | } 108 | 109 | async function createSession() { 110 | const resp = await fetch(`/api/session?client_id=${cid}`, { 111 | method: 'post', 112 | headers: authHeader, 113 | }); 114 | if (resp.status === 403) { 115 | alert('当前验证信息已被撤销,请重新验证。'); 116 | verifyRedirect(); 117 | return; 118 | } 119 | const result = await resp.json(); 120 | return result['accessCode']; 121 | } 122 | 123 | async function authorizeApp() { 124 | document.getElementById('authorize').disabled = true; 125 | document.getElementById('authorize').innerText = '正在授权...'; 126 | var existedCode; 127 | try { 128 | existedCode = await queryExistedAccCode(); 129 | } 130 | catch (e) { 131 | alert('无效的 Token。请尝试重新验证。'); 132 | verifyRedirect(); 133 | return; 134 | } 135 | if (existedCode) { 136 | code = existedCode; 137 | } 138 | else { 139 | code = await createSession(); 140 | } 141 | redirectCallback(); 142 | } 143 | 144 | function redirectCallback() { 145 | if (/=/.test(redirect)) 146 | redirect += '&'; 147 | if (!/\?/.test(redirect)) 148 | redirect += '?'; 149 | 150 | window.location.href = redirect + `code=${code}`; 151 | } 152 | 153 | function verifyRedirect() { 154 | const callback = window.location.pathname + window.location.search; 155 | const callbackEncoded = encodeURIComponent(callback); 156 | window.location.href = `/verify?redirect=${callbackEncoded}`; 157 | } 158 | 159 | init(); 160 | -------------------------------------------------------------------------------- /server/static/base.css: -------------------------------------------------------------------------------- 1 | [hidden] { 2 | display: none !important; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | background-color: #eee 8 | } 9 | 10 | header { 11 | display: flex; 12 | align-items: center; 13 | width: 100%; 14 | background-color: #6ae; 15 | box-shadow: 0 0 0.5em; 16 | color: #fff; 17 | } 18 | 19 | header span.title { 20 | display: inline-block; 21 | font-size: 1.5em; 22 | color: inherit; 23 | margin: 0.5em 1em; 24 | } 25 | 26 | header #user-display { 27 | display: inline-flex; 28 | align-items: center; 29 | margin-left: auto; 30 | margin-right: 1em; 31 | padding: 0.6em; 32 | font-size: 1rem; 33 | border-radius: 0.5em; 34 | transition: background-color 0.5s; 35 | } 36 | 37 | header #user-display:hover { 38 | background-color: rgba(255,255,255,0.2); 39 | } 40 | 41 | #header-avatar { 42 | margin-right: 0.6em; 43 | width: 1.6em; 44 | height: 1.6em; 45 | border-radius: calc(50%); 46 | } 47 | 48 | main { 49 | width: 80vw; 50 | max-width: 60em; 51 | min-height: 50vh; 52 | margin: 3em auto 2em; 53 | padding: 3em; 54 | background-color: #fff; 55 | border-radius: 1em; 56 | box-shadow: 0 0 1em #aaa; 57 | } 58 | 59 | footer { 60 | position: absolute; 61 | left: 0; 62 | right: 0; 63 | padding: 2em 0; 64 | width: 50%; 65 | margin: auto; 66 | font-size: 0.8rem; 67 | } 68 | 69 | footer div { 70 | padding: 0.2em; 71 | text-align: center; 72 | } 73 | 74 | .url { 75 | color: #88f; 76 | } 77 | 78 | @media screen and (max-width: 600px) { 79 | main { 80 | margin-left: 0.6em; 81 | margin-right: 0.6em; 82 | padding: 1.5em; 83 | width: auto; 84 | } 85 | } 86 | 87 | @media (prefers-color-scheme: dark) { 88 | body { 89 | background-color: #111; 90 | } 91 | 92 | header { 93 | background-color: #369; 94 | box-shadow: none; 95 | color: #eee; 96 | } 97 | 98 | main { 99 | background-color: #222; 100 | color: #ddd; 101 | box-shadow: none; 102 | } 103 | 104 | footer { 105 | color: #ccc; 106 | } 107 | 108 | a, 109 | .url { 110 | color: #3af; 111 | } 112 | } 113 | 114 | @media screen and (max-width: 700px) { 115 | header span { 116 | font-size: 1.2rem; 117 | color: #fff; 118 | } 119 | footer { 120 | width: 90%; 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /server/static/bili-app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icyux/bili-auth/0859f3b338f36924dd9ce3da32c3fcd93a48c6d6/server/static/bili-app-icon.png -------------------------------------------------------------------------------- /server/static/bili-web-icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icyux/bili-auth/0859f3b338f36924dd9ce3da32c3fcd93a48c6d6/server/static/bili-web-icon.ico -------------------------------------------------------------------------------- /server/static/create_app.css: -------------------------------------------------------------------------------- 1 | /* input area */ 2 | 3 | label { 4 | display: block; 5 | } 6 | 7 | input { 8 | display: block; 9 | width: 30em; 10 | max-width: 90%; 11 | font-size: 1.2em; 12 | line-height: 1.3em; 13 | background-color: #0000; 14 | border: none; 15 | border-bottom: solid 1px #777; 16 | outline: none; 17 | } 18 | 19 | .comment { 20 | font-size: 0.8em; 21 | color: #333; 22 | margin: 1em 0 2em; 23 | } 24 | 25 | input:focus { 26 | border-color: #6ae; 27 | } 28 | 29 | @media (prefers-color-scheme: dark) { 30 | input { 31 | color: #fff; 32 | } 33 | .comment { 34 | color: #ccc; 35 | } 36 | } 37 | 38 | /* button */ 39 | 40 | .next-step { 41 | background-color: #0000; 42 | padding: 0.5em 1.5em; 43 | border: solid 1px #6ae; 44 | border-radius: 0.3em; 45 | color: #6ae; 46 | transition: background-color 0.3s, color 0.3s; 47 | } 48 | 49 | .next-step:hover { 50 | background-color: #6ae; 51 | color: #fff; 52 | } 53 | 54 | .next-step:disabled { 55 | border-color: #777; 56 | color: #777; 57 | } 58 | 59 | /* credential container */ 60 | 61 | .value-field { 62 | padding: 0.1em 0.3em; 63 | background-color: #def; 64 | } 65 | 66 | @media (prefers-color-scheme: dark) { 67 | .value-field { 68 | background: none; 69 | border-bottom: dashed 2px #6ae; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /server/static/create_app.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | async function submitAppInfo() { 4 | document.getElementById('submit-appinfo').disabled = true 5 | const name = document.getElementById('name').value 6 | const icon = document.getElementById('icon-url').value 7 | const desc = document.getElementById('description').value 8 | const link = document.getElementById('link').value 9 | const prefix = document.getElementById('callback-prefix').value 10 | 11 | const vt = localStorage.verifyToken 12 | 13 | let resp = await fetch('/oauth/application',{ 14 | method: 'POST', 15 | headers: { 16 | 'Authorization': `BUTKN ${vt}`, 17 | }, 18 | body: new URLSearchParams({ 19 | 'name': name, 20 | 'icon': icon, 21 | 'desc': desc, 22 | 'link': link, 23 | 'prefix': prefix, 24 | }), 25 | }) 26 | 27 | if (resp.status === 200) { 28 | const data = await resp.json() 29 | document.getElementById('client-id').innerText = data['cid'] 30 | document.getElementById('client-secret').innerText = data['csec'] 31 | document.getElementById('create-app').hidden = true 32 | document.getElementById('display-info').hidden = false 33 | } 34 | else { 35 | alert('提交应用信息失败。') 36 | document.getElementById('submit-appinfo').disabled = false 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /server/static/display_userinfo.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | async function fetchUserInfo() { 4 | const vt = localStorage['verifyToken'] 5 | let resp = await fetch(`/api/user`,{ 6 | headers: { 7 | 'Authorization': `BUTKN ${vt}`, 8 | }, 9 | }) 10 | if (resp.status === 403) { 11 | return null 12 | } 13 | 14 | let userInfo = await resp.json() 15 | 16 | let origAvatarURL = userInfo['avatar'] 17 | if (/\.jpg$/.test(origAvatarURL)) 18 | userInfo['avatar'] = `${origAvatarURL}@60w_60h_1c_1s.webp` 19 | 20 | return userInfo 21 | } 22 | 23 | async function setUserInfo() { 24 | const user = await fetchUserInfo(); 25 | try { 26 | document.getElementById('user-name').innerText = user.name; 27 | document.getElementById('user-avatar').src = user.avatar; 28 | 29 | const rawData = user.raw_data 30 | if (rawData !== null) { 31 | const bio = rawData.sign === '' ? '(未设置个性签名)' : user.sign; 32 | document.getElementById('user-bio').innerText = bio; 33 | 34 | const lv = rawData.level 35 | const lvImg = document.getElementById('user-level') 36 | lvImg.title = `Lv.${lv}` 37 | lvImg.src = `/static/icons/levels/lv${lv}.png` 38 | lvImg.hidden = false 39 | } 40 | else { 41 | document.getElementById('user-bio').innerText = '(无法展示个性签名,实例未提供完整用户信息)'; 42 | } 43 | 44 | document.getElementById('user-uid').innerText = user.uid 45 | } 46 | catch (TypeError) { 47 | // pass 48 | } 49 | } 50 | 51 | async function headerUserDisplay() { 52 | const showDisplay = () => document.getElementById('user-display').hidden = false 53 | const vt = localStorage['verifyToken'] 54 | if (vt === undefined) { 55 | showDisplay() 56 | return 57 | } 58 | 59 | const userInfo = await fetchUserInfo() 60 | if (userInfo === null) { 61 | localStorage.removeItem('verifyToken') 62 | } 63 | else { 64 | document.getElementById('header-avatar').src = userInfo['avatar'] 65 | document.getElementById('header-username').innerText = userInfo['name'] 66 | document.getElementById('user-display').onclick = () => location.href = '/user' 67 | } 68 | showDisplay() 69 | } 70 | 71 | function loginRedirect() { 72 | window.location.href = '/verify' 73 | } 74 | 75 | if (!/\/(verify|user)$/.test(window.location.pathname)) 76 | headerUserDisplay() 77 | -------------------------------------------------------------------------------- /server/static/icons/levels/lv0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icyux/bili-auth/0859f3b338f36924dd9ce3da32c3fcd93a48c6d6/server/static/icons/levels/lv0.png -------------------------------------------------------------------------------- /server/static/icons/levels/lv1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icyux/bili-auth/0859f3b338f36924dd9ce3da32c3fcd93a48c6d6/server/static/icons/levels/lv1.png -------------------------------------------------------------------------------- /server/static/icons/levels/lv2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icyux/bili-auth/0859f3b338f36924dd9ce3da32c3fcd93a48c6d6/server/static/icons/levels/lv2.png -------------------------------------------------------------------------------- /server/static/icons/levels/lv3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icyux/bili-auth/0859f3b338f36924dd9ce3da32c3fcd93a48c6d6/server/static/icons/levels/lv3.png -------------------------------------------------------------------------------- /server/static/icons/levels/lv4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icyux/bili-auth/0859f3b338f36924dd9ce3da32c3fcd93a48c6d6/server/static/icons/levels/lv4.png -------------------------------------------------------------------------------- /server/static/icons/levels/lv5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icyux/bili-auth/0859f3b338f36924dd9ce3da32c3fcd93a48c6d6/server/static/icons/levels/lv5.png -------------------------------------------------------------------------------- /server/static/icons/levels/lv6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icyux/bili-auth/0859f3b338f36924dd9ce3da32c3fcd93a48c6d6/server/static/icons/levels/lv6.png -------------------------------------------------------------------------------- /server/static/nonlogin.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/static/not-supported.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icyux/bili-auth/0859f3b338f36924dd9ce3da32c3fcd93a48c6d6/server/static/not-supported.png -------------------------------------------------------------------------------- /server/static/qrscan-guide.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icyux/bili-auth/0859f3b338f36924dd9ce3da32c3fcd93a48c6d6/server/static/qrscan-guide.jpg -------------------------------------------------------------------------------- /server/static/ua-parser.js: -------------------------------------------------------------------------------- 1 | ///////////////////////////////////////////////////////////////////////////////// 2 | /* UAParser.js v1.0.37 3 | Copyright © 2012-2021 Faisal Salman 4 | MIT License *//* 5 | Detect Browser, Engine, OS, CPU, and Device type/model from User-Agent data. 6 | Supports browser & node.js environment. 7 | Demo : https://faisalman.github.io/ua-parser-js 8 | Source : https://github.com/faisalman/ua-parser-js */ 9 | ///////////////////////////////////////////////////////////////////////////////// 10 | 11 | (function (window, undefined) { 12 | 13 | 'use strict'; 14 | 15 | ////////////// 16 | // Constants 17 | ///////////// 18 | 19 | 20 | var LIBVERSION = '1.0.37', 21 | EMPTY = '', 22 | UNKNOWN = '?', 23 | FUNC_TYPE = 'function', 24 | UNDEF_TYPE = 'undefined', 25 | OBJ_TYPE = 'object', 26 | STR_TYPE = 'string', 27 | MAJOR = 'major', 28 | MODEL = 'model', 29 | NAME = 'name', 30 | TYPE = 'type', 31 | VENDOR = 'vendor', 32 | VERSION = 'version', 33 | ARCHITECTURE= 'architecture', 34 | CONSOLE = 'console', 35 | MOBILE = 'mobile', 36 | TABLET = 'tablet', 37 | SMARTTV = 'smarttv', 38 | WEARABLE = 'wearable', 39 | EMBEDDED = 'embedded', 40 | UA_MAX_LENGTH = 500; 41 | 42 | var AMAZON = 'Amazon', 43 | APPLE = 'Apple', 44 | ASUS = 'ASUS', 45 | BLACKBERRY = 'BlackBerry', 46 | BROWSER = 'Browser', 47 | CHROME = 'Chrome', 48 | EDGE = 'Edge', 49 | FIREFOX = 'Firefox', 50 | GOOGLE = 'Google', 51 | HUAWEI = 'Huawei', 52 | LG = 'LG', 53 | MICROSOFT = 'Microsoft', 54 | MOTOROLA = 'Motorola', 55 | OPERA = 'Opera', 56 | SAMSUNG = 'Samsung', 57 | SHARP = 'Sharp', 58 | SONY = 'Sony', 59 | XIAOMI = 'Xiaomi', 60 | ZEBRA = 'Zebra', 61 | FACEBOOK = 'Facebook', 62 | CHROMIUM_OS = 'Chromium OS', 63 | MAC_OS = 'Mac OS'; 64 | 65 | /////////// 66 | // Helper 67 | ////////// 68 | 69 | var extend = function (regexes, extensions) { 70 | var mergedRegexes = {}; 71 | for (var i in regexes) { 72 | if (extensions[i] && extensions[i].length % 2 === 0) { 73 | mergedRegexes[i] = extensions[i].concat(regexes[i]); 74 | } else { 75 | mergedRegexes[i] = regexes[i]; 76 | } 77 | } 78 | return mergedRegexes; 79 | }, 80 | enumerize = function (arr) { 81 | var enums = {}; 82 | for (var i=0; i 0) { 130 | if (q.length === 2) { 131 | if (typeof q[1] == FUNC_TYPE) { 132 | // assign modified match 133 | this[q[0]] = q[1].call(this, match); 134 | } else { 135 | // assign given value, ignore regex match 136 | this[q[0]] = q[1]; 137 | } 138 | } else if (q.length === 3) { 139 | // check whether function or regex 140 | if (typeof q[1] === FUNC_TYPE && !(q[1].exec && q[1].test)) { 141 | // call function (usually string mapper) 142 | this[q[0]] = match ? q[1].call(this, match, q[2]) : undefined; 143 | } else { 144 | // sanitize match using given regex 145 | this[q[0]] = match ? match.replace(q[1], q[2]) : undefined; 146 | } 147 | } else if (q.length === 4) { 148 | this[q[0]] = match ? q[3].call(this, match.replace(q[1], q[2])) : undefined; 149 | } 150 | } else { 151 | this[q] = match ? match : undefined; 152 | } 153 | } 154 | } 155 | } 156 | i += 2; 157 | } 158 | }, 159 | 160 | strMapper = function (str, map) { 161 | 162 | for (var i in map) { 163 | // check if current value is array 164 | if (typeof map[i] === OBJ_TYPE && map[i].length > 0) { 165 | for (var j = 0; j < map[i].length; j++) { 166 | if (has(map[i][j], str)) { 167 | return (i === UNKNOWN) ? undefined : i; 168 | } 169 | } 170 | } else if (has(map[i], str)) { 171 | return (i === UNKNOWN) ? undefined : i; 172 | } 173 | } 174 | return str; 175 | }; 176 | 177 | /////////////// 178 | // String map 179 | ////////////// 180 | 181 | // Safari < 3.0 182 | var oldSafariMap = { 183 | '1.0' : '/8', 184 | '1.2' : '/1', 185 | '1.3' : '/3', 186 | '2.0' : '/412', 187 | '2.0.2' : '/416', 188 | '2.0.3' : '/417', 189 | '2.0.4' : '/419', 190 | '?' : '/' 191 | }, 192 | windowsVersionMap = { 193 | 'ME' : '4.90', 194 | 'NT 3.11' : 'NT3.51', 195 | 'NT 4.0' : 'NT4.0', 196 | '2000' : 'NT 5.0', 197 | 'XP' : ['NT 5.1', 'NT 5.2'], 198 | 'Vista' : 'NT 6.0', 199 | '7' : 'NT 6.1', 200 | '8' : 'NT 6.2', 201 | '8.1' : 'NT 6.3', 202 | '10' : ['NT 6.4', 'NT 10.0'], 203 | 'RT' : 'ARM' 204 | }; 205 | 206 | ////////////// 207 | // Regex map 208 | ///////////// 209 | 210 | var regexes = { 211 | 212 | browser : [[ 213 | 214 | /\b(?:crmo|crios)\/([\w\.]+)/i // Chrome for Android/iOS 215 | ], [VERSION, [NAME, 'Chrome']], [ 216 | /edg(?:e|ios|a)?\/([\w\.]+)/i // Microsoft Edge 217 | ], [VERSION, [NAME, 'Edge']], [ 218 | 219 | // Presto based 220 | /(opera mini)\/([-\w\.]+)/i, // Opera Mini 221 | /(opera [mobiletab]{3,6})\b.+version\/([-\w\.]+)/i, // Opera Mobi/Tablet 222 | /(opera)(?:.+version\/|[\/ ]+)([\w\.]+)/i // Opera 223 | ], [NAME, VERSION], [ 224 | /opios[\/ ]+([\w\.]+)/i // Opera mini on iphone >= 8.0 225 | ], [VERSION, [NAME, OPERA+' Mini']], [ 226 | /\bopr\/([\w\.]+)/i // Opera Webkit 227 | ], [VERSION, [NAME, OPERA]], [ 228 | 229 | // Mixed 230 | /\bb[ai]*d(?:uhd|[ub]*[aekoprswx]{5,6})[\/ ]?([\w\.]+)/i // Baidu 231 | ], [VERSION, [NAME, 'Baidu']], [ 232 | /(kindle)\/([\w\.]+)/i, // Kindle 233 | /(lunascape|maxthon|netfront|jasmine|blazer)[\/ ]?([\w\.]*)/i, // Lunascape/Maxthon/Netfront/Jasmine/Blazer 234 | // Trident based 235 | /(avant|iemobile|slim)\s?(?:browser)?[\/ ]?([\w\.]*)/i, // Avant/IEMobile/SlimBrowser 236 | /(?:ms|\()(ie) ([\w\.]+)/i, // Internet Explorer 237 | 238 | // Webkit/KHTML based // Flock/RockMelt/Midori/Epiphany/Silk/Skyfire/Bolt/Iron/Iridium/PhantomJS/Bowser/QupZilla/Falkon 239 | /(flock|rockmelt|midori|epiphany|silk|skyfire|bolt|iron|vivaldi|iridium|phantomjs|bowser|quark|qupzilla|falkon|rekonq|puffin|brave|whale(?!.+naver)|qqbrowserlite|qq|duckduckgo)\/([-\w\.]+)/i, 240 | // Rekonq/Puffin/Brave/Whale/QQBrowserLite/QQ, aka ShouQ 241 | /(heytap|ovi)browser\/([\d\.]+)/i, // Heytap/Ovi 242 | /(weibo)__([\d\.]+)/i // Weibo 243 | ], [NAME, VERSION], [ 244 | /(?:\buc? ?browser|(?:juc.+)ucweb)[\/ ]?([\w\.]+)/i // UCBrowser 245 | ], [VERSION, [NAME, 'UC'+BROWSER]], [ 246 | /microm.+\bqbcore\/([\w\.]+)/i, // WeChat Desktop for Windows Built-in Browser 247 | /\bqbcore\/([\w\.]+).+microm/i, 248 | /micromessenger\/([\w\.]+)/i // WeChat 249 | ], [VERSION, [NAME, 'WeChat']], [ 250 | /konqueror\/([\w\.]+)/i // Konqueror 251 | ], [VERSION, [NAME, 'Konqueror']], [ 252 | /trident.+rv[: ]([\w\.]{1,9})\b.+like gecko/i // IE11 253 | ], [VERSION, [NAME, 'IE']], [ 254 | /ya(?:search)?browser\/([\w\.]+)/i // Yandex 255 | ], [VERSION, [NAME, 'Yandex']], [ 256 | /slbrowser\/([\w\.]+)/i // Smart Lenovo Browser 257 | ], [VERSION, [NAME, 'Smart Lenovo '+BROWSER]], [ 258 | /(avast|avg)\/([\w\.]+)/i // Avast/AVG Secure Browser 259 | ], [[NAME, /(.+)/, '$1 Secure '+BROWSER], VERSION], [ 260 | /\bfocus\/([\w\.]+)/i // Firefox Focus 261 | ], [VERSION, [NAME, FIREFOX+' Focus']], [ 262 | /\bopt\/([\w\.]+)/i // Opera Touch 263 | ], [VERSION, [NAME, OPERA+' Touch']], [ 264 | /coc_coc\w+\/([\w\.]+)/i // Coc Coc Browser 265 | ], [VERSION, [NAME, 'Coc Coc']], [ 266 | /dolfin\/([\w\.]+)/i // Dolphin 267 | ], [VERSION, [NAME, 'Dolphin']], [ 268 | /coast\/([\w\.]+)/i // Opera Coast 269 | ], [VERSION, [NAME, OPERA+' Coast']], [ 270 | /miuibrowser\/([\w\.]+)/i // MIUI Browser 271 | ], [VERSION, [NAME, 'MIUI '+BROWSER]], [ 272 | /fxios\/([-\w\.]+)/i // Firefox for iOS 273 | ], [VERSION, [NAME, FIREFOX]], [ 274 | /\bqihu|(qi?ho?o?|360)browser/i // 360 275 | ], [[NAME, '360 ' + BROWSER]], [ 276 | /(oculus|sailfish|huawei|vivo)browser\/([\w\.]+)/i 277 | ], [[NAME, /(.+)/, '$1 ' + BROWSER], VERSION], [ // Oculus/Sailfish/HuaweiBrowser/VivoBrowser 278 | /samsungbrowser\/([\w\.]+)/i // Samsung Internet 279 | ], [VERSION, [NAME, SAMSUNG + ' Internet']], [ 280 | /(comodo_dragon)\/([\w\.]+)/i // Comodo Dragon 281 | ], [[NAME, /_/g, ' '], VERSION], [ 282 | /metasr[\/ ]?([\d\.]+)/i // Sogou Explorer 283 | ], [VERSION, [NAME, 'Sogou Explorer']], [ 284 | /(sogou)mo\w+\/([\d\.]+)/i // Sogou Mobile 285 | ], [[NAME, 'Sogou Mobile'], VERSION], [ 286 | /(electron)\/([\w\.]+) safari/i, // Electron-based App 287 | /(tesla)(?: qtcarbrowser|\/(20\d\d\.[-\w\.]+))/i, // Tesla 288 | /m?(qqbrowser|2345Explorer)[\/ ]?([\w\.]+)/i // QQBrowser/2345 Browser 289 | ], [NAME, VERSION], [ 290 | /(lbbrowser)/i, // LieBao Browser 291 | /\[(linkedin)app\]/i // LinkedIn App for iOS & Android 292 | ], [NAME], [ 293 | 294 | // WebView 295 | /((?:fban\/fbios|fb_iab\/fb4a)(?!.+fbav)|;fbav\/([\w\.]+);)/i // Facebook App for iOS & Android 296 | ], [[NAME, FACEBOOK], VERSION], [ 297 | /(Klarna)\/([\w\.]+)/i, // Klarna Shopping Browser for iOS & Android 298 | /(kakao(?:talk|story))[\/ ]([\w\.]+)/i, // Kakao App 299 | /(naver)\(.*?(\d+\.[\w\.]+).*\)/i, // Naver InApp 300 | /safari (line)\/([\w\.]+)/i, // Line App for iOS 301 | /\b(line)\/([\w\.]+)\/iab/i, // Line App for Android 302 | /(alipay)client\/([\w\.]+)/i, // Alipay 303 | /(chromium|instagram|snapchat)[\/ ]([-\w\.]+)/i // Chromium/Instagram/Snapchat 304 | ], [NAME, VERSION], [ 305 | /\bgsa\/([\w\.]+) .*safari\//i // Google Search Appliance on iOS 306 | ], [VERSION, [NAME, 'GSA']], [ 307 | /musical_ly(?:.+app_?version\/|_)([\w\.]+)/i // TikTok 308 | ], [VERSION, [NAME, 'TikTok']], [ 309 | 310 | /headlesschrome(?:\/([\w\.]+)| )/i // Chrome Headless 311 | ], [VERSION, [NAME, CHROME+' Headless']], [ 312 | 313 | / wv\).+(chrome)\/([\w\.]+)/i // Chrome WebView 314 | ], [[NAME, CHROME+' WebView'], VERSION], [ 315 | 316 | /droid.+ version\/([\w\.]+)\b.+(?:mobile safari|safari)/i // Android Browser 317 | ], [VERSION, [NAME, 'Android '+BROWSER]], [ 318 | 319 | /(chrome|omniweb|arora|[tizenoka]{5} ?browser)\/v?([\w\.]+)/i // Chrome/OmniWeb/Arora/Tizen/Nokia 320 | ], [NAME, VERSION], [ 321 | 322 | /version\/([\w\.\,]+) .*mobile\/\w+ (safari)/i // Mobile Safari 323 | ], [VERSION, [NAME, 'Mobile Safari']], [ 324 | /version\/([\w(\.|\,)]+) .*(mobile ?safari|safari)/i // Safari & Safari Mobile 325 | ], [VERSION, NAME], [ 326 | /webkit.+?(mobile ?safari|safari)(\/[\w\.]+)/i // Safari < 3.0 327 | ], [NAME, [VERSION, strMapper, oldSafariMap]], [ 328 | 329 | /(webkit|khtml)\/([\w\.]+)/i 330 | ], [NAME, VERSION], [ 331 | 332 | // Gecko based 333 | /(navigator|netscape\d?)\/([-\w\.]+)/i // Netscape 334 | ], [[NAME, 'Netscape'], VERSION], [ 335 | /mobile vr; rv:([\w\.]+)\).+firefox/i // Firefox Reality 336 | ], [VERSION, [NAME, FIREFOX+' Reality']], [ 337 | /ekiohf.+(flow)\/([\w\.]+)/i, // Flow 338 | /(swiftfox)/i, // Swiftfox 339 | /(icedragon|iceweasel|camino|chimera|fennec|maemo browser|minimo|conkeror|klar)[\/ ]?([\w\.\+]+)/i, 340 | // IceDragon/Iceweasel/Camino/Chimera/Fennec/Maemo/Minimo/Conkeror/Klar 341 | /(seamonkey|k-meleon|icecat|iceape|firebird|phoenix|palemoon|basilisk|waterfox)\/([-\w\.]+)$/i, 342 | // Firefox/SeaMonkey/K-Meleon/IceCat/IceApe/Firebird/Phoenix 343 | /(firefox)\/([\w\.]+)/i, // Other Firefox-based 344 | /(mozilla)\/([\w\.]+) .+rv\:.+gecko\/\d+/i, // Mozilla 345 | 346 | // Other 347 | /(polaris|lynx|dillo|icab|doris|amaya|w3m|netsurf|sleipnir|obigo|mosaic|(?:go|ice|up)[\. ]?browser)[-\/ ]?v?([\w\.]+)/i, 348 | // Polaris/Lynx/Dillo/iCab/Doris/Amaya/w3m/NetSurf/Sleipnir/Obigo/Mosaic/Go/ICE/UP.Browser 349 | /(links) \(([\w\.]+)/i, // Links 350 | /panasonic;(viera)/i // Panasonic Viera 351 | ], [NAME, VERSION], [ 352 | 353 | /(cobalt)\/([\w\.]+)/i // Cobalt 354 | ], [NAME, [VERSION, /master.|lts./, ""]] 355 | ], 356 | 357 | cpu : [[ 358 | 359 | /(?:(amd|x(?:(?:86|64)[-_])?|wow|win)64)[;\)]/i // AMD64 (x64) 360 | ], [[ARCHITECTURE, 'amd64']], [ 361 | 362 | /(ia32(?=;))/i // IA32 (quicktime) 363 | ], [[ARCHITECTURE, lowerize]], [ 364 | 365 | /((?:i[346]|x)86)[;\)]/i // IA32 (x86) 366 | ], [[ARCHITECTURE, 'ia32']], [ 367 | 368 | /\b(aarch64|arm(v?8e?l?|_?64))\b/i // ARM64 369 | ], [[ARCHITECTURE, 'arm64']], [ 370 | 371 | /\b(arm(?:v[67])?ht?n?[fl]p?)\b/i // ARMHF 372 | ], [[ARCHITECTURE, 'armhf']], [ 373 | 374 | // PocketPC mistakenly identified as PowerPC 375 | /windows (ce|mobile); ppc;/i 376 | ], [[ARCHITECTURE, 'arm']], [ 377 | 378 | /((?:ppc|powerpc)(?:64)?)(?: mac|;|\))/i // PowerPC 379 | ], [[ARCHITECTURE, /ower/, EMPTY, lowerize]], [ 380 | 381 | /(sun4\w)[;\)]/i // SPARC 382 | ], [[ARCHITECTURE, 'sparc']], [ 383 | 384 | /((?:avr32|ia64(?=;))|68k(?=\))|\barm(?=v(?:[1-7]|[5-7]1)l?|;|eabi)|(?=atmel )avr|(?:irix|mips|sparc)(?:64)?\b|pa-risc)/i 385 | // IA64, 68K, ARM/64, AVR/32, IRIX/64, MIPS/64, SPARC/64, PA-RISC 386 | ], [[ARCHITECTURE, lowerize]] 387 | ], 388 | 389 | device : [[ 390 | 391 | ////////////////////////// 392 | // MOBILES & TABLETS 393 | ///////////////////////// 394 | 395 | // Samsung 396 | /\b(sch-i[89]0\d|shw-m380s|sm-[ptx]\w{2,4}|gt-[pn]\d{2,4}|sgh-t8[56]9|nexus 10)/i 397 | ], [MODEL, [VENDOR, SAMSUNG], [TYPE, TABLET]], [ 398 | /\b((?:s[cgp]h|gt|sm)-\w+|sc[g-]?[\d]+a?|galaxy nexus)/i, 399 | /samsung[- ]([-\w]+)/i, 400 | /sec-(sgh\w+)/i 401 | ], [MODEL, [VENDOR, SAMSUNG], [TYPE, MOBILE]], [ 402 | 403 | // Apple 404 | /(?:\/|\()(ip(?:hone|od)[\w, ]*)(?:\/|;)/i // iPod/iPhone 405 | ], [MODEL, [VENDOR, APPLE], [TYPE, MOBILE]], [ 406 | /\((ipad);[-\w\),; ]+apple/i, // iPad 407 | /applecoremedia\/[\w\.]+ \((ipad)/i, 408 | /\b(ipad)\d\d?,\d\d?[;\]].+ios/i 409 | ], [MODEL, [VENDOR, APPLE], [TYPE, TABLET]], [ 410 | /(macintosh);/i 411 | ], [MODEL, [VENDOR, APPLE]], [ 412 | 413 | // Sharp 414 | /\b(sh-?[altvz]?\d\d[a-ekm]?)/i 415 | ], [MODEL, [VENDOR, SHARP], [TYPE, MOBILE]], [ 416 | 417 | // Huawei 418 | /\b((?:ag[rs][23]?|bah2?|sht?|btv)-a?[lw]\d{2})\b(?!.+d\/s)/i 419 | ], [MODEL, [VENDOR, HUAWEI], [TYPE, TABLET]], [ 420 | /(?:huawei|honor)([-\w ]+)[;\)]/i, 421 | /\b(nexus 6p|\w{2,4}e?-[atu]?[ln][\dx][012359c][adn]?)\b(?!.+d\/s)/i 422 | ], [MODEL, [VENDOR, HUAWEI], [TYPE, MOBILE]], [ 423 | 424 | // Xiaomi 425 | /\b(poco[\w ]+|m2\d{3}j\d\d[a-z]{2})(?: bui|\))/i, // Xiaomi POCO 426 | /\b; (\w+) build\/hm\1/i, // Xiaomi Hongmi 'numeric' models 427 | /\b(hm[-_ ]?note?[_ ]?(?:\d\w)?) bui/i, // Xiaomi Hongmi 428 | /\b(redmi[\-_ ]?(?:note|k)?[\w_ ]+)(?: bui|\))/i, // Xiaomi Redmi 429 | /oid[^\)]+; (m?[12][0-389][01]\w{3,6}[c-y])( bui|; wv|\))/i, // Xiaomi Redmi 'numeric' models 430 | /\b(mi[-_ ]?(?:a\d|one|one[_ ]plus|note lte|max|cc)?[_ ]?(?:\d?\w?)[_ ]?(?:plus|se|lite)?)(?: bui|\))/i // Xiaomi Mi 431 | ], [[MODEL, /_/g, ' '], [VENDOR, XIAOMI], [TYPE, MOBILE]], [ 432 | /oid[^\)]+; (2\d{4}(283|rpbf)[cgl])( bui|\))/i, // Redmi Pad 433 | /\b(mi[-_ ]?(?:pad)(?:[\w_ ]+))(?: bui|\))/i // Mi Pad tablets 434 | ],[[MODEL, /_/g, ' '], [VENDOR, XIAOMI], [TYPE, TABLET]], [ 435 | 436 | // OPPO 437 | /; (\w+) bui.+ oppo/i, 438 | /\b(cph[12]\d{3}|p(?:af|c[al]|d\w|e[ar])[mt]\d0|x9007|a101op)\b/i 439 | ], [MODEL, [VENDOR, 'OPPO'], [TYPE, MOBILE]], [ 440 | 441 | // Vivo 442 | /vivo (\w+)(?: bui|\))/i, 443 | /\b(v[12]\d{3}\w?[at])(?: bui|;)/i 444 | ], [MODEL, [VENDOR, 'Vivo'], [TYPE, MOBILE]], [ 445 | 446 | // Realme 447 | /\b(rmx[1-3]\d{3})(?: bui|;|\))/i 448 | ], [MODEL, [VENDOR, 'Realme'], [TYPE, MOBILE]], [ 449 | 450 | // Motorola 451 | /\b(milestone|droid(?:[2-4x]| (?:bionic|x2|pro|razr))?:?( 4g)?)\b[\w ]+build\//i, 452 | /\bmot(?:orola)?[- ](\w*)/i, 453 | /((?:moto[\w\(\) ]+|xt\d{3,4}|nexus 6)(?= bui|\)))/i 454 | ], [MODEL, [VENDOR, MOTOROLA], [TYPE, MOBILE]], [ 455 | /\b(mz60\d|xoom[2 ]{0,2}) build\//i 456 | ], [MODEL, [VENDOR, MOTOROLA], [TYPE, TABLET]], [ 457 | 458 | // LG 459 | /((?=lg)?[vl]k\-?\d{3}) bui| 3\.[-\w; ]{10}lg?-([06cv9]{3,4})/i 460 | ], [MODEL, [VENDOR, LG], [TYPE, TABLET]], [ 461 | /(lm(?:-?f100[nv]?|-[\w\.]+)(?= bui|\))|nexus [45])/i, 462 | /\blg[-e;\/ ]+((?!browser|netcast|android tv)\w+)/i, 463 | /\blg-?([\d\w]+) bui/i 464 | ], [MODEL, [VENDOR, LG], [TYPE, MOBILE]], [ 465 | 466 | // Lenovo 467 | /(ideatab[-\w ]+)/i, 468 | /lenovo ?(s[56]000[-\w]+|tab(?:[\w ]+)|yt[-\d\w]{6}|tb[-\d\w]{6})/i 469 | ], [MODEL, [VENDOR, 'Lenovo'], [TYPE, TABLET]], [ 470 | 471 | // Nokia 472 | /(?:maemo|nokia).*(n900|lumia \d+)/i, 473 | /nokia[-_ ]?([-\w\.]*)/i 474 | ], [[MODEL, /_/g, ' '], [VENDOR, 'Nokia'], [TYPE, MOBILE]], [ 475 | 476 | // Google 477 | /(pixel c)\b/i // Google Pixel C 478 | ], [MODEL, [VENDOR, GOOGLE], [TYPE, TABLET]], [ 479 | /droid.+; (pixel[\daxl ]{0,6})(?: bui|\))/i // Google Pixel 480 | ], [MODEL, [VENDOR, GOOGLE], [TYPE, MOBILE]], [ 481 | 482 | // Sony 483 | /droid.+ (a?\d[0-2]{2}so|[c-g]\d{4}|so[-gl]\w+|xq-a\w[4-7][12])(?= bui|\).+chrome\/(?![1-6]{0,1}\d\.))/i 484 | ], [MODEL, [VENDOR, SONY], [TYPE, MOBILE]], [ 485 | /sony tablet [ps]/i, 486 | /\b(?:sony)?sgp\w+(?: bui|\))/i 487 | ], [[MODEL, 'Xperia Tablet'], [VENDOR, SONY], [TYPE, TABLET]], [ 488 | 489 | // OnePlus 490 | / (kb2005|in20[12]5|be20[12][59])\b/i, 491 | /(?:one)?(?:plus)? (a\d0\d\d)(?: b|\))/i 492 | ], [MODEL, [VENDOR, 'OnePlus'], [TYPE, MOBILE]], [ 493 | 494 | // Amazon 495 | /(alexa)webm/i, 496 | /(kf[a-z]{2}wi|aeo[c-r]{2})( bui|\))/i, // Kindle Fire without Silk / Echo Show 497 | /(kf[a-z]+)( bui|\)).+silk\//i // Kindle Fire HD 498 | ], [MODEL, [VENDOR, AMAZON], [TYPE, TABLET]], [ 499 | /((?:sd|kf)[0349hijorstuw]+)( bui|\)).+silk\//i // Fire Phone 500 | ], [[MODEL, /(.+)/g, 'Fire Phone $1'], [VENDOR, AMAZON], [TYPE, MOBILE]], [ 501 | 502 | // BlackBerry 503 | /(playbook);[-\w\),; ]+(rim)/i // BlackBerry PlayBook 504 | ], [MODEL, VENDOR, [TYPE, TABLET]], [ 505 | /\b((?:bb[a-f]|st[hv])100-\d)/i, 506 | /\(bb10; (\w+)/i // BlackBerry 10 507 | ], [MODEL, [VENDOR, BLACKBERRY], [TYPE, MOBILE]], [ 508 | 509 | // Asus 510 | /(?:\b|asus_)(transfo[prime ]{4,10} \w+|eeepc|slider \w+|nexus 7|padfone|p00[cj])/i 511 | ], [MODEL, [VENDOR, ASUS], [TYPE, TABLET]], [ 512 | / (z[bes]6[027][012][km][ls]|zenfone \d\w?)\b/i 513 | ], [MODEL, [VENDOR, ASUS], [TYPE, MOBILE]], [ 514 | 515 | // HTC 516 | /(nexus 9)/i // HTC Nexus 9 517 | ], [MODEL, [VENDOR, 'HTC'], [TYPE, TABLET]], [ 518 | /(htc)[-;_ ]{1,2}([\w ]+(?=\)| bui)|\w+)/i, // HTC 519 | 520 | // ZTE 521 | /(zte)[- ]([\w ]+?)(?: bui|\/|\))/i, 522 | /(alcatel|geeksphone|nexian|panasonic(?!(?:;|\.))|sony(?!-bra))[-_ ]?([-\w]*)/i // Alcatel/GeeksPhone/Nexian/Panasonic/Sony 523 | ], [VENDOR, [MODEL, /_/g, ' '], [TYPE, MOBILE]], [ 524 | 525 | // Acer 526 | /droid.+; ([ab][1-7]-?[0178a]\d\d?)/i 527 | ], [MODEL, [VENDOR, 'Acer'], [TYPE, TABLET]], [ 528 | 529 | // Meizu 530 | /droid.+; (m[1-5] note) bui/i, 531 | /\bmz-([-\w]{2,})/i 532 | ], [MODEL, [VENDOR, 'Meizu'], [TYPE, MOBILE]], [ 533 | 534 | // Ulefone 535 | /; ((?:power )?armor(?:[\w ]{0,8}))(?: bui|\))/i 536 | ], [MODEL, [VENDOR, 'Ulefone'], [TYPE, MOBILE]], [ 537 | 538 | // MIXED 539 | /(blackberry|benq|palm(?=\-)|sonyericsson|acer|asus|dell|meizu|motorola|polytron|infinix|tecno)[-_ ]?([-\w]*)/i, 540 | // BlackBerry/BenQ/Palm/Sony-Ericsson/Acer/Asus/Dell/Meizu/Motorola/Polytron 541 | /(hp) ([\w ]+\w)/i, // HP iPAQ 542 | /(asus)-?(\w+)/i, // Asus 543 | /(microsoft); (lumia[\w ]+)/i, // Microsoft Lumia 544 | /(lenovo)[-_ ]?([-\w]+)/i, // Lenovo 545 | /(jolla)/i, // Jolla 546 | /(oppo) ?([\w ]+) bui/i // OPPO 547 | ], [VENDOR, MODEL, [TYPE, MOBILE]], [ 548 | 549 | /(kobo)\s(ereader|touch)/i, // Kobo 550 | /(archos) (gamepad2?)/i, // Archos 551 | /(hp).+(touchpad(?!.+tablet)|tablet)/i, // HP TouchPad 552 | /(kindle)\/([\w\.]+)/i, // Kindle 553 | /(nook)[\w ]+build\/(\w+)/i, // Nook 554 | /(dell) (strea[kpr\d ]*[\dko])/i, // Dell Streak 555 | /(le[- ]+pan)[- ]+(\w{1,9}) bui/i, // Le Pan Tablets 556 | /(trinity)[- ]*(t\d{3}) bui/i, // Trinity Tablets 557 | /(gigaset)[- ]+(q\w{1,9}) bui/i, // Gigaset Tablets 558 | /(vodafone) ([\w ]+)(?:\)| bui)/i // Vodafone 559 | ], [VENDOR, MODEL, [TYPE, TABLET]], [ 560 | 561 | /(surface duo)/i // Surface Duo 562 | ], [MODEL, [VENDOR, MICROSOFT], [TYPE, TABLET]], [ 563 | /droid [\d\.]+; (fp\du?)(?: b|\))/i // Fairphone 564 | ], [MODEL, [VENDOR, 'Fairphone'], [TYPE, MOBILE]], [ 565 | /(u304aa)/i // AT&T 566 | ], [MODEL, [VENDOR, 'AT&T'], [TYPE, MOBILE]], [ 567 | /\bsie-(\w*)/i // Siemens 568 | ], [MODEL, [VENDOR, 'Siemens'], [TYPE, MOBILE]], [ 569 | /\b(rct\w+) b/i // RCA Tablets 570 | ], [MODEL, [VENDOR, 'RCA'], [TYPE, TABLET]], [ 571 | /\b(venue[\d ]{2,7}) b/i // Dell Venue Tablets 572 | ], [MODEL, [VENDOR, 'Dell'], [TYPE, TABLET]], [ 573 | /\b(q(?:mv|ta)\w+) b/i // Verizon Tablet 574 | ], [MODEL, [VENDOR, 'Verizon'], [TYPE, TABLET]], [ 575 | /\b(?:barnes[& ]+noble |bn[rt])([\w\+ ]*) b/i // Barnes & Noble Tablet 576 | ], [MODEL, [VENDOR, 'Barnes & Noble'], [TYPE, TABLET]], [ 577 | /\b(tm\d{3}\w+) b/i 578 | ], [MODEL, [VENDOR, 'NuVision'], [TYPE, TABLET]], [ 579 | /\b(k88) b/i // ZTE K Series Tablet 580 | ], [MODEL, [VENDOR, 'ZTE'], [TYPE, TABLET]], [ 581 | /\b(nx\d{3}j) b/i // ZTE Nubia 582 | ], [MODEL, [VENDOR, 'ZTE'], [TYPE, MOBILE]], [ 583 | /\b(gen\d{3}) b.+49h/i // Swiss GEN Mobile 584 | ], [MODEL, [VENDOR, 'Swiss'], [TYPE, MOBILE]], [ 585 | /\b(zur\d{3}) b/i // Swiss ZUR Tablet 586 | ], [MODEL, [VENDOR, 'Swiss'], [TYPE, TABLET]], [ 587 | /\b((zeki)?tb.*\b) b/i // Zeki Tablets 588 | ], [MODEL, [VENDOR, 'Zeki'], [TYPE, TABLET]], [ 589 | /\b([yr]\d{2}) b/i, 590 | /\b(dragon[- ]+touch |dt)(\w{5}) b/i // Dragon Touch Tablet 591 | ], [[VENDOR, 'Dragon Touch'], MODEL, [TYPE, TABLET]], [ 592 | /\b(ns-?\w{0,9}) b/i // Insignia Tablets 593 | ], [MODEL, [VENDOR, 'Insignia'], [TYPE, TABLET]], [ 594 | /\b((nxa|next)-?\w{0,9}) b/i // NextBook Tablets 595 | ], [MODEL, [VENDOR, 'NextBook'], [TYPE, TABLET]], [ 596 | /\b(xtreme\_)?(v(1[045]|2[015]|[3469]0|7[05])) b/i // Voice Xtreme Phones 597 | ], [[VENDOR, 'Voice'], MODEL, [TYPE, MOBILE]], [ 598 | /\b(lvtel\-)?(v1[12]) b/i // LvTel Phones 599 | ], [[VENDOR, 'LvTel'], MODEL, [TYPE, MOBILE]], [ 600 | /\b(ph-1) /i // Essential PH-1 601 | ], [MODEL, [VENDOR, 'Essential'], [TYPE, MOBILE]], [ 602 | /\b(v(100md|700na|7011|917g).*\b) b/i // Envizen Tablets 603 | ], [MODEL, [VENDOR, 'Envizen'], [TYPE, TABLET]], [ 604 | /\b(trio[-\w\. ]+) b/i // MachSpeed Tablets 605 | ], [MODEL, [VENDOR, 'MachSpeed'], [TYPE, TABLET]], [ 606 | /\btu_(1491) b/i // Rotor Tablets 607 | ], [MODEL, [VENDOR, 'Rotor'], [TYPE, TABLET]], [ 608 | /(shield[\w ]+) b/i // Nvidia Shield Tablets 609 | ], [MODEL, [VENDOR, 'Nvidia'], [TYPE, TABLET]], [ 610 | /(sprint) (\w+)/i // Sprint Phones 611 | ], [VENDOR, MODEL, [TYPE, MOBILE]], [ 612 | /(kin\.[onetw]{3})/i // Microsoft Kin 613 | ], [[MODEL, /\./g, ' '], [VENDOR, MICROSOFT], [TYPE, MOBILE]], [ 614 | /droid.+; (cc6666?|et5[16]|mc[239][23]x?|vc8[03]x?)\)/i // Zebra 615 | ], [MODEL, [VENDOR, ZEBRA], [TYPE, TABLET]], [ 616 | /droid.+; (ec30|ps20|tc[2-8]\d[kx])\)/i 617 | ], [MODEL, [VENDOR, ZEBRA], [TYPE, MOBILE]], [ 618 | 619 | /////////////////// 620 | // SMARTTVS 621 | /////////////////// 622 | 623 | /smart-tv.+(samsung)/i // Samsung 624 | ], [VENDOR, [TYPE, SMARTTV]], [ 625 | /hbbtv.+maple;(\d+)/i 626 | ], [[MODEL, /^/, 'SmartTV'], [VENDOR, SAMSUNG], [TYPE, SMARTTV]], [ 627 | /(nux; netcast.+smarttv|lg (netcast\.tv-201\d|android tv))/i // LG SmartTV 628 | ], [[VENDOR, LG], [TYPE, SMARTTV]], [ 629 | /(apple) ?tv/i // Apple TV 630 | ], [VENDOR, [MODEL, APPLE+' TV'], [TYPE, SMARTTV]], [ 631 | /crkey/i // Google Chromecast 632 | ], [[MODEL, CHROME+'cast'], [VENDOR, GOOGLE], [TYPE, SMARTTV]], [ 633 | /droid.+aft(\w+)( bui|\))/i // Fire TV 634 | ], [MODEL, [VENDOR, AMAZON], [TYPE, SMARTTV]], [ 635 | /\(dtv[\);].+(aquos)/i, 636 | /(aquos-tv[\w ]+)\)/i // Sharp 637 | ], [MODEL, [VENDOR, SHARP], [TYPE, SMARTTV]],[ 638 | /(bravia[\w ]+)( bui|\))/i // Sony 639 | ], [MODEL, [VENDOR, SONY], [TYPE, SMARTTV]], [ 640 | /(mitv-\w{5}) bui/i // Xiaomi 641 | ], [MODEL, [VENDOR, XIAOMI], [TYPE, SMARTTV]], [ 642 | /Hbbtv.*(technisat) (.*);/i // TechniSAT 643 | ], [VENDOR, MODEL, [TYPE, SMARTTV]], [ 644 | /\b(roku)[\dx]*[\)\/]((?:dvp-)?[\d\.]*)/i, // Roku 645 | /hbbtv\/\d+\.\d+\.\d+ +\([\w\+ ]*; *([\w\d][^;]*);([^;]*)/i // HbbTV devices 646 | ], [[VENDOR, trim], [MODEL, trim], [TYPE, SMARTTV]], [ 647 | /\b(android tv|smart[- ]?tv|opera tv|tv; rv:)\b/i // SmartTV from Unidentified Vendors 648 | ], [[TYPE, SMARTTV]], [ 649 | 650 | /////////////////// 651 | // CONSOLES 652 | /////////////////// 653 | 654 | /(ouya)/i, // Ouya 655 | /(nintendo) ([wids3utch]+)/i // Nintendo 656 | ], [VENDOR, MODEL, [TYPE, CONSOLE]], [ 657 | /droid.+; (shield) bui/i // Nvidia 658 | ], [MODEL, [VENDOR, 'Nvidia'], [TYPE, CONSOLE]], [ 659 | /(playstation [345portablevi]+)/i // Playstation 660 | ], [MODEL, [VENDOR, SONY], [TYPE, CONSOLE]], [ 661 | /\b(xbox(?: one)?(?!; xbox))[\); ]/i // Microsoft Xbox 662 | ], [MODEL, [VENDOR, MICROSOFT], [TYPE, CONSOLE]], [ 663 | 664 | /////////////////// 665 | // WEARABLES 666 | /////////////////// 667 | 668 | /((pebble))app/i // Pebble 669 | ], [VENDOR, MODEL, [TYPE, WEARABLE]], [ 670 | /(watch)(?: ?os[,\/]|\d,\d\/)[\d\.]+/i // Apple Watch 671 | ], [MODEL, [VENDOR, APPLE], [TYPE, WEARABLE]], [ 672 | /droid.+; (glass) \d/i // Google Glass 673 | ], [MODEL, [VENDOR, GOOGLE], [TYPE, WEARABLE]], [ 674 | /droid.+; (wt63?0{2,3})\)/i 675 | ], [MODEL, [VENDOR, ZEBRA], [TYPE, WEARABLE]], [ 676 | /(quest( 2| pro)?)/i // Oculus Quest 677 | ], [MODEL, [VENDOR, FACEBOOK], [TYPE, WEARABLE]], [ 678 | 679 | /////////////////// 680 | // EMBEDDED 681 | /////////////////// 682 | 683 | /(tesla)(?: qtcarbrowser|\/[-\w\.]+)/i // Tesla 684 | ], [VENDOR, [TYPE, EMBEDDED]], [ 685 | /(aeobc)\b/i // Echo Dot 686 | ], [MODEL, [VENDOR, AMAZON], [TYPE, EMBEDDED]], [ 687 | 688 | //////////////////// 689 | // MIXED (GENERIC) 690 | /////////////////// 691 | 692 | /droid .+?; ([^;]+?)(?: bui|; wv\)|\) applew).+? mobile safari/i // Android Phones from Unidentified Vendors 693 | ], [MODEL, [TYPE, MOBILE]], [ 694 | /droid .+?; ([^;]+?)(?: bui|\) applew).+?(?! mobile) safari/i // Android Tablets from Unidentified Vendors 695 | ], [MODEL, [TYPE, TABLET]], [ 696 | /\b((tablet|tab)[;\/]|focus\/\d(?!.+mobile))/i // Unidentifiable Tablet 697 | ], [[TYPE, TABLET]], [ 698 | /(phone|mobile(?:[;\/]| [ \w\/\.]*safari)|pda(?=.+windows ce))/i // Unidentifiable Mobile 699 | ], [[TYPE, MOBILE]], [ 700 | /(android[-\w\. ]{0,9});.+buil/i // Generic Android Device 701 | ], [MODEL, [VENDOR, 'Generic']] 702 | ], 703 | 704 | engine : [[ 705 | 706 | /windows.+ edge\/([\w\.]+)/i // EdgeHTML 707 | ], [VERSION, [NAME, EDGE+'HTML']], [ 708 | 709 | /webkit\/537\.36.+chrome\/(?!27)([\w\.]+)/i // Blink 710 | ], [VERSION, [NAME, 'Blink']], [ 711 | 712 | /(presto)\/([\w\.]+)/i, // Presto 713 | /(webkit|trident|netfront|netsurf|amaya|lynx|w3m|goanna)\/([\w\.]+)/i, // WebKit/Trident/NetFront/NetSurf/Amaya/Lynx/w3m/Goanna 714 | /ekioh(flow)\/([\w\.]+)/i, // Flow 715 | /(khtml|tasman|links)[\/ ]\(?([\w\.]+)/i, // KHTML/Tasman/Links 716 | /(icab)[\/ ]([23]\.[\d\.]+)/i, // iCab 717 | /\b(libweb)/i 718 | ], [NAME, VERSION], [ 719 | 720 | /rv\:([\w\.]{1,9})\b.+(gecko)/i // Gecko 721 | ], [VERSION, NAME] 722 | ], 723 | 724 | os : [[ 725 | 726 | // Windows 727 | /microsoft (windows) (vista|xp)/i // Windows (iTunes) 728 | ], [NAME, VERSION], [ 729 | /(windows (?:phone(?: os)?|mobile))[\/ ]?([\d\.\w ]*)/i // Windows Phone 730 | ], [NAME, [VERSION, strMapper, windowsVersionMap]], [ 731 | /windows nt 6\.2; (arm)/i, // Windows RT 732 | /windows[\/ ]?([ntce\d\. ]+\w)(?!.+xbox)/i, 733 | /(?:win(?=3|9|n)|win 9x )([nt\d\.]+)/i 734 | ], [[VERSION, strMapper, windowsVersionMap], [NAME, 'Windows']], [ 735 | 736 | // iOS/macOS 737 | /ip[honead]{2,4}\b(?:.*os ([\w]+) like mac|; opera)/i, // iOS 738 | /(?:ios;fbsv\/|iphone.+ios[\/ ])([\d\.]+)/i, 739 | /cfnetwork\/.+darwin/i 740 | ], [[VERSION, /_/g, '.'], [NAME, 'iOS']], [ 741 | /(mac os x) ?([\w\. ]*)/i, 742 | /(macintosh|mac_powerpc\b)(?!.+haiku)/i // Mac OS 743 | ], [[NAME, MAC_OS], [VERSION, /_/g, '.']], [ 744 | 745 | // Mobile OSes 746 | /droid ([\w\.]+)\b.+(android[- ]x86|harmonyos)/i // Android-x86/HarmonyOS 747 | ], [VERSION, NAME], [ // Android/WebOS/QNX/Bada/RIM/Maemo/MeeGo/Sailfish OS 748 | /(android|webos|qnx|bada|rim tablet os|maemo|meego|sailfish)[-\/ ]?([\w\.]*)/i, 749 | /(blackberry)\w*\/([\w\.]*)/i, // Blackberry 750 | /(tizen|kaios)[\/ ]([\w\.]+)/i, // Tizen/KaiOS 751 | /\((series40);/i // Series 40 752 | ], [NAME, VERSION], [ 753 | /\(bb(10);/i // BlackBerry 10 754 | ], [VERSION, [NAME, BLACKBERRY]], [ 755 | /(?:symbian ?os|symbos|s60(?=;)|series60)[-\/ ]?([\w\.]*)/i // Symbian 756 | ], [VERSION, [NAME, 'Symbian']], [ 757 | /mozilla\/[\d\.]+ \((?:mobile|tablet|tv|mobile; [\w ]+); rv:.+ gecko\/([\w\.]+)/i // Firefox OS 758 | ], [VERSION, [NAME, FIREFOX+' OS']], [ 759 | /web0s;.+rt(tv)/i, 760 | /\b(?:hp)?wos(?:browser)?\/([\w\.]+)/i // WebOS 761 | ], [VERSION, [NAME, 'webOS']], [ 762 | /watch(?: ?os[,\/]|\d,\d\/)([\d\.]+)/i // watchOS 763 | ], [VERSION, [NAME, 'watchOS']], [ 764 | 765 | // Google Chromecast 766 | /crkey\/([\d\.]+)/i // Google Chromecast 767 | ], [VERSION, [NAME, CHROME+'cast']], [ 768 | /(cros) [\w]+(?:\)| ([\w\.]+)\b)/i // Chromium OS 769 | ], [[NAME, CHROMIUM_OS], VERSION],[ 770 | 771 | // Smart TVs 772 | /panasonic;(viera)/i, // Panasonic Viera 773 | /(netrange)mmh/i, // Netrange 774 | /(nettv)\/(\d+\.[\w\.]+)/i, // NetTV 775 | 776 | // Console 777 | /(nintendo|playstation) ([wids345portablevuch]+)/i, // Nintendo/Playstation 778 | /(xbox); +xbox ([^\);]+)/i, // Microsoft Xbox (360, One, X, S, Series X, Series S) 779 | 780 | // Other 781 | /\b(joli|palm)\b ?(?:os)?\/?([\w\.]*)/i, // Joli/Palm 782 | /(mint)[\/\(\) ]?(\w*)/i, // Mint 783 | /(mageia|vectorlinux)[; ]/i, // Mageia/VectorLinux 784 | /([kxln]?ubuntu|debian|suse|opensuse|gentoo|arch(?= linux)|slackware|fedora|mandriva|centos|pclinuxos|red ?hat|zenwalk|linpus|raspbian|plan 9|minix|risc os|contiki|deepin|manjaro|elementary os|sabayon|linspire)(?: gnu\/linux)?(?: enterprise)?(?:[- ]linux)?(?:-gnu)?[-\/ ]?(?!chrom|package)([-\w\.]*)/i, 785 | // Ubuntu/Debian/SUSE/Gentoo/Arch/Slackware/Fedora/Mandriva/CentOS/PCLinuxOS/RedHat/Zenwalk/Linpus/Raspbian/Plan9/Minix/RISCOS/Contiki/Deepin/Manjaro/elementary/Sabayon/Linspire 786 | /(hurd|linux) ?([\w\.]*)/i, // Hurd/Linux 787 | /(gnu) ?([\w\.]*)/i, // GNU 788 | /\b([-frentopcghs]{0,5}bsd|dragonfly)[\/ ]?(?!amd|[ix346]{1,2}86)([\w\.]*)/i, // FreeBSD/NetBSD/OpenBSD/PC-BSD/GhostBSD/DragonFly 789 | /(haiku) (\w+)/i // Haiku 790 | ], [NAME, VERSION], [ 791 | /(sunos) ?([\w\.\d]*)/i // Solaris 792 | ], [[NAME, 'Solaris'], VERSION], [ 793 | /((?:open)?solaris)[-\/ ]?([\w\.]*)/i, // Solaris 794 | /(aix) ((\d)(?=\.|\)| )[\w\.])*/i, // AIX 795 | /\b(beos|os\/2|amigaos|morphos|openvms|fuchsia|hp-ux|serenityos)/i, // BeOS/OS2/AmigaOS/MorphOS/OpenVMS/Fuchsia/HP-UX/SerenityOS 796 | /(unix) ?([\w\.]*)/i // UNIX 797 | ], [NAME, VERSION] 798 | ] 799 | }; 800 | 801 | ///////////////// 802 | // Constructor 803 | //////////////// 804 | 805 | var UAParser = function (ua, extensions) { 806 | 807 | if (typeof ua === OBJ_TYPE) { 808 | extensions = ua; 809 | ua = undefined; 810 | } 811 | 812 | if (!(this instanceof UAParser)) { 813 | return new UAParser(ua, extensions).getResult(); 814 | } 815 | 816 | var _navigator = (typeof window !== UNDEF_TYPE && window.navigator) ? window.navigator : undefined; 817 | var _ua = ua || ((_navigator && _navigator.userAgent) ? _navigator.userAgent : EMPTY); 818 | var _uach = (_navigator && _navigator.userAgentData) ? _navigator.userAgentData : undefined; 819 | var _rgxmap = extensions ? extend(regexes, extensions) : regexes; 820 | var _isSelfNav = _navigator && _navigator.userAgent == _ua; 821 | 822 | this.getBrowser = function () { 823 | var _browser = {}; 824 | _browser[NAME] = undefined; 825 | _browser[VERSION] = undefined; 826 | rgxMapper.call(_browser, _ua, _rgxmap.browser); 827 | _browser[MAJOR] = majorize(_browser[VERSION]); 828 | // Brave-specific detection 829 | if (_isSelfNav && _navigator && _navigator.brave && typeof _navigator.brave.isBrave == FUNC_TYPE) { 830 | _browser[NAME] = 'Brave'; 831 | } 832 | return _browser; 833 | }; 834 | this.getCPU = function () { 835 | var _cpu = {}; 836 | _cpu[ARCHITECTURE] = undefined; 837 | rgxMapper.call(_cpu, _ua, _rgxmap.cpu); 838 | return _cpu; 839 | }; 840 | this.getDevice = function () { 841 | var _device = {}; 842 | _device[VENDOR] = undefined; 843 | _device[MODEL] = undefined; 844 | _device[TYPE] = undefined; 845 | rgxMapper.call(_device, _ua, _rgxmap.device); 846 | if (_isSelfNav && !_device[TYPE] && _uach && _uach.mobile) { 847 | _device[TYPE] = MOBILE; 848 | } 849 | // iPadOS-specific detection: identified as Mac, but has some iOS-only properties 850 | if (_isSelfNav && _device[MODEL] == 'Macintosh' && _navigator && typeof _navigator.standalone !== UNDEF_TYPE && _navigator.maxTouchPoints && _navigator.maxTouchPoints > 2) { 851 | _device[MODEL] = 'iPad'; 852 | _device[TYPE] = TABLET; 853 | } 854 | return _device; 855 | }; 856 | this.getEngine = function () { 857 | var _engine = {}; 858 | _engine[NAME] = undefined; 859 | _engine[VERSION] = undefined; 860 | rgxMapper.call(_engine, _ua, _rgxmap.engine); 861 | return _engine; 862 | }; 863 | this.getOS = function () { 864 | var _os = {}; 865 | _os[NAME] = undefined; 866 | _os[VERSION] = undefined; 867 | rgxMapper.call(_os, _ua, _rgxmap.os); 868 | if (_isSelfNav && !_os[NAME] && _uach && _uach.platform != 'Unknown') { 869 | _os[NAME] = _uach.platform 870 | .replace(/chrome os/i, CHROMIUM_OS) 871 | .replace(/macos/i, MAC_OS); // backward compatibility 872 | } 873 | return _os; 874 | }; 875 | this.getResult = function () { 876 | return { 877 | ua : this.getUA(), 878 | browser : this.getBrowser(), 879 | engine : this.getEngine(), 880 | os : this.getOS(), 881 | device : this.getDevice(), 882 | cpu : this.getCPU() 883 | }; 884 | }; 885 | this.getUA = function () { 886 | return _ua; 887 | }; 888 | this.setUA = function (ua) { 889 | _ua = (typeof ua === STR_TYPE && ua.length > UA_MAX_LENGTH) ? trim(ua, UA_MAX_LENGTH) : ua; 890 | return this; 891 | }; 892 | this.setUA(_ua); 893 | return this; 894 | }; 895 | 896 | UAParser.VERSION = LIBVERSION; 897 | UAParser.BROWSER = enumerize([NAME, VERSION, MAJOR]); 898 | UAParser.CPU = enumerize([ARCHITECTURE]); 899 | UAParser.DEVICE = enumerize([MODEL, VENDOR, TYPE, CONSOLE, MOBILE, SMARTTV, TABLET, WEARABLE, EMBEDDED]); 900 | UAParser.ENGINE = UAParser.OS = enumerize([NAME, VERSION]); 901 | 902 | /////////// 903 | // Export 904 | ////////// 905 | 906 | // check js environment 907 | if (typeof(exports) !== UNDEF_TYPE) { 908 | // nodejs env 909 | if (typeof module !== UNDEF_TYPE && module.exports) { 910 | exports = module.exports = UAParser; 911 | } 912 | exports.UAParser = UAParser; 913 | } else { 914 | // requirejs env (optional) 915 | if (typeof(define) === FUNC_TYPE && define.amd) { 916 | define(function () { 917 | return UAParser; 918 | }); 919 | } else if (typeof window !== UNDEF_TYPE) { 920 | // browser env 921 | window.UAParser = UAParser; 922 | } 923 | } 924 | 925 | // jQuery/Zepto specific (optional) 926 | // Note: 927 | // In AMD env the global scope should be kept clean, but jQuery is an exception. 928 | // jQuery always exports to global scope, unless jQuery.noConflict(true) is used, 929 | // and we should catch that. 930 | var $ = typeof window !== UNDEF_TYPE && (window.jQuery || window.Zepto); 931 | if ($ && !$.ua) { 932 | var parser = new UAParser(); 933 | $.ua = parser.getResult(); 934 | $.ua.get = function () { 935 | return parser.getUA(); 936 | }; 937 | $.ua.set = function (ua) { 938 | parser.setUA(ua); 939 | var result = parser.getResult(); 940 | for (var prop in result) { 941 | $.ua[prop] = result[prop]; 942 | } 943 | }; 944 | } 945 | 946 | })(typeof window === 'object' ? window : this); 947 | -------------------------------------------------------------------------------- /server/static/ua_parse.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | async function parseUserAgent() { 4 | const parser = new UAParser(navigator.userAgent) 5 | const result = parser.getResult(); 6 | let browserName = result.browser.name 7 | let browserVer = result.browser.version 8 | let osName = result.os.name 9 | let osVer = result.os.version 10 | let deviceVendor = result.device.vendor 11 | let deviceModel = result.device.model 12 | 13 | // detect Windows 10/11 14 | if (osName === 'Windows' && osVer === '10') { 15 | if (navigator.userAgentData === undefined) { 16 | // unable to determine 17 | osVer = '10/11' 18 | } 19 | else { 20 | const platformInfo = await navigator.userAgentData.getHighEntropyValues(["platformVersion"]) 21 | if (navigator.userAgentData.platform === "Windows") { 22 | const majorPlatformVersion = parseInt(platformInfo.platformVersion.split('.')[0]); 23 | if (majorPlatformVersion >= 13) { 24 | osVer = '11' 25 | } 26 | } 27 | } 28 | } 29 | 30 | const browserInfo = browserVer === undefined ? browserName : `${browserName} ${browserVer}` 31 | const osInfo = osVer === undefined ? osName : `${osName} ${osVer}` 32 | let parsedResult = `${browserInfo}, ${osInfo}` 33 | if (deviceVendor !== undefined && deviceModel !== undefined) 34 | parsedResult += `, ${deviceVendor} ${deviceModel}` 35 | 36 | return parsedResult 37 | } 38 | 39 | function isMobile() { 40 | return (/Mobile/.test(navigator.userAgent)) 41 | } 42 | -------------------------------------------------------------------------------- /server/static/user.css: -------------------------------------------------------------------------------- 1 | /* user info */ 2 | 3 | .user-info { 4 | margin-bottom: 1em; 5 | padding: 1em; 6 | display: flex; 7 | } 8 | 9 | #user-avatar { 10 | width: 4em; 11 | height: 4em; 12 | margin: 0 1em; 13 | border-radius: 2em; 14 | } 15 | 16 | .name-bio-container { 17 | display: inline-block; 18 | } 19 | 20 | #user-name { 21 | font-weight: bold; 22 | font-size: 1.1em; 23 | line-height: 1.8em; 24 | } 25 | 26 | /* navigator */ 27 | 28 | #navigator { 29 | width: 100%; 30 | display: flex; 31 | justify-content: space-around; 32 | } 33 | 34 | .option { 35 | padding: 0.5em 2em; 36 | transition: background-color 0.3s; 37 | } 38 | 39 | .option:hover { 40 | background-color: #ddda; 41 | } 42 | 43 | .option.selected { 44 | color: #6ae; 45 | border-bottom: dotted #6ae 2px; 46 | } 47 | 48 | @media (prefers-color-scheme: dark) { 49 | .option:hover { 50 | background-color: #777a; 51 | } 52 | .option.selected { 53 | color: #9cf; 54 | border-bottom: dotted #9cf 2px; 55 | } 56 | } 57 | 58 | /* app list */ 59 | 60 | .app-info { 61 | display: flex; 62 | align-items: center; 63 | } 64 | 65 | .app-icon { 66 | width: 2em; 67 | height: 2em; 68 | border-radius: 0.3em; 69 | } 70 | 71 | .app-name { 72 | margin: 0 2em; 73 | } 74 | 75 | .app-info button { 76 | margin-left: auto; 77 | padding: 0.3em 1em; 78 | background-color: #0000; 79 | color: #e67; 80 | border: solid #e67 1px; 81 | border-radius: 3px; 82 | transition: background-color 0.3s, color 0.3s; 83 | } 84 | 85 | .app-info button:hover { 86 | color: #fff; 87 | background-color: #e67; 88 | } 89 | 90 | #create-new-app { 91 | width: 80%; 92 | height: 5em; 93 | margin: 1em 10%; 94 | text-align: center; 95 | vertical-align: center; 96 | border: dashed #777 1px; 97 | border-radius: 10px; 98 | background-color: #0000; 99 | color: #777; 100 | transition: background-color 0.3s, color 0.3s; 101 | } 102 | 103 | #create-new-app::before { 104 | content: '+'; 105 | margin-right: 0.6rem; 106 | font-size: 2em; 107 | vertical-align: middle; 108 | } 109 | 110 | #create-new-app:hover { 111 | border: none; 112 | color: #fff; 113 | background-color: #6aec; 114 | } 115 | 116 | @media (prefers-color-scheme: dark) { 117 | #create-new-app { 118 | border-color: #ccc; 119 | color: #ccc; 120 | } 121 | #create-new-app:hover { 122 | background-color: #9cf8; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /server/static/user.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | async function fetchAppInfo() { 4 | await Promise.all([ 5 | fetchAuthorizedApps(), 6 | fetchCreatedApps(), 7 | ]) 8 | } 9 | 10 | async function fetchAuthorizedApps() { 11 | let vt = localStorage.verifyToken 12 | let resp = await fetch('/api/user/apps/authorized', { 13 | headers: { 14 | 'Authorization': `BUTKN ${vt}`, 15 | }, 16 | }) 17 | let authorizedApps = await resp.json() 18 | let tpl = document.getElementById('app-tpl') 19 | for (let app of authorizedApps) { 20 | let row = document.importNode(tpl.content, true) 21 | row.querySelector('.app-icon').src = app['icon'] 22 | row.querySelector('.app-name').innerText = app['name'] 23 | row.querySelector('button').innerText = '撤销授权' 24 | row.querySelector('button').onclick = () => revokeAuthorization(app['cid']) 25 | document.getElementById('authorized-apps').appendChild(row) 26 | } 27 | } 28 | 29 | async function fetchCreatedApps() { 30 | let vt = localStorage.verifyToken 31 | let resp = await fetch('/api/user/apps/created', { 32 | headers: { 33 | 'Authorization': `BUTKN ${vt}`, 34 | }, 35 | }) 36 | let createdApps = await resp.json() 37 | let tpl = document.getElementById('app-tpl') 38 | for (let app of createdApps) { 39 | let row = document.importNode(tpl.content, true) 40 | row.querySelector('.app-icon').src = app['icon'] 41 | row.querySelector('.app-name').innerText = app['name'] 42 | row.querySelector('button').innerText = '删除应用' 43 | row.querySelector('button').onclick = () => deleteApplication(app['cid']) 44 | document.getElementById('created-apps').appendChild(row) 45 | } 46 | } 47 | 48 | async function revokeAuthorization(cid) { 49 | if (confirm('确认撤销对该应用的授权?')) { 50 | const vt = localStorage.verifyToken 51 | let resp = await fetch(`/api/user/apps/authorized?cid=${cid}`, { 52 | method: 'DELETE', 53 | headers: { 54 | 'Authorization': `BUTKN ${vt}`, 55 | }, 56 | }) 57 | if (resp.status === 200) 58 | alert('已撤销。') 59 | else 60 | alert('撤销失败。') 61 | } 62 | } 63 | 64 | async function deleteApplication(cid) { 65 | if (confirm('确认删除该应用?')) { 66 | const vt = localStorage.verifyToken 67 | let resp = await fetch(`/oauth/application/${cid}`, { 68 | method: 'DELETE', 69 | headers: { 70 | 'Authorization': `BUTKN ${vt}`, 71 | }, 72 | }) 73 | if (resp.status === 200) 74 | alert('已删除。') 75 | else 76 | alert('删除失败。') 77 | } 78 | } 79 | 80 | function switchOption(event) { 81 | const OptionCount = 2 82 | const matchResult = /opt-(\d+)/.exec(event.target.id) 83 | if (matchResult === null) 84 | return 85 | 86 | const optionId = matchResult[1] 87 | for (let i = 0; i < OptionCount; i++) { 88 | document.getElementById(`opt-${i}`).classList.remove('selected') 89 | document.getElementById(`area-${i}`).hidden = true 90 | } 91 | document.getElementById(`opt-${optionId}`).classList.add('selected') 92 | document.getElementById(`area-${optionId}`).hidden = false 93 | } 94 | 95 | function openAppCreatePage() { 96 | window.open('/oauth/application/new', '_blank') 97 | } 98 | 99 | async function init() { 100 | document.getElementById('navigator').onclick = (e) => switchOption(e) 101 | await setUserInfo() 102 | await fetchAppInfo() 103 | } 104 | 105 | init() 106 | -------------------------------------------------------------------------------- /server/static/verify.css: -------------------------------------------------------------------------------- 1 | .info-value, 2 | #challenge-msg { 3 | display: inline-block; 4 | padding: 0.2em 0.5em; 5 | font-size: 1.1em; 6 | background-color: #eef; 7 | border-radius: 0.2em; 8 | } 9 | 10 | .next-step { 11 | background-color: #6ae; 12 | padding: 0.5em 1em; 13 | border: solid 1px #59d; 14 | border-radius: 0.3em; 15 | color: #fff; 16 | } 17 | 18 | .next-step:hover { 19 | background-color: #7bf; 20 | } 21 | 22 | .next-step:active { 23 | background-color: #59d; 24 | } 25 | 26 | .next-step:disabled { 27 | background-color: #ccc; 28 | } 29 | 30 | button.option { 31 | display: flex; 32 | align-items: center; 33 | padding: 0.5em; 34 | font-size: 1.1em; 35 | border: none; 36 | border-radius: 0.4em; 37 | background-color: #0000; 38 | transition: background-color 0.3s; 39 | } 40 | 41 | button.option img { 42 | width: 2em; 43 | height: 2em; 44 | margin-right: 1em; 45 | } 46 | 47 | button.option:hover:not(:disabled) { 48 | background-color: #6ae; 49 | color: #fff; 50 | } 51 | 52 | button.option:disabled { 53 | color: #aaa; 54 | } 55 | 56 | #auth-main { 57 | display: flex; 58 | flex-direction: row; 59 | } 60 | 61 | #qrcode[src] { 62 | width: 8em; 63 | height: 8em; 64 | } 65 | 66 | img.guide { 67 | height: 8em; 68 | } 69 | 70 | #auth-by-qrscan:has(#qrcode[src]) { 71 | margin-left: 2em; 72 | padding-left: 2em; 73 | border-left: dashed 2px #555; 74 | } 75 | #auth-by-qrscan li { 76 | margin: 0 0 1em; 77 | } 78 | 79 | #user-avatar { 80 | width: 4em; 81 | height: 4em; 82 | margin-right: 1em; 83 | border-radius: 2em; 84 | } 85 | 86 | .user-info { 87 | margin: 1em 0; 88 | padding: 1em; 89 | border-radius: 0.5em; 90 | border: solid 1px #999; 91 | display: flex; 92 | align-items: center; 93 | } 94 | 95 | .user-header, #user-bio { 96 | line-height: 2rem; 97 | } 98 | 99 | #user-name { 100 | font-size: 1.4rem; 101 | font-weight: bold; 102 | } 103 | 104 | #user-level { 105 | height: 0.8rem; 106 | } 107 | 108 | .user-header > span { 109 | margin-right: 0.5rem; 110 | } 111 | 112 | .user-uid-container { 113 | font-size: 0.8rem; 114 | } 115 | 116 | #bio { 117 | color: #333; 118 | } 119 | 120 | #remain { 121 | width: max-content; 122 | background: linear-gradient(to right, #69f, #6df); 123 | background-size: var(--remain, 0) 2px; 124 | background-repeat: no-repeat; 125 | background-position: bottom left; 126 | border-bottom: solid 1px rgba(127, 127, 127, 0.5); 127 | } 128 | 129 | @media screen and (max-width: 600px) { 130 | main { 131 | margin-left: 0.6em; 132 | margin-right: 0.6em; 133 | padding: 1.5em; 134 | width: auto; 135 | } 136 | 137 | .user-uid-container { 138 | display: block; 139 | line-height: initial; 140 | } 141 | } 142 | 143 | @media (prefers-color-scheme: dark) { 144 | .info-value, 145 | #challenge-msg { 146 | background-color: #222; 147 | color: #aaf; 148 | } 149 | 150 | button.option { 151 | background-color: #0000; 152 | color: #eee; 153 | } 154 | 155 | #copy { 156 | background-color: #333; 157 | color: #ccc; 158 | border: none; 159 | } 160 | 161 | #bio { 162 | color: #aaa; 163 | } 164 | 165 | #show-guide { 166 | color: #eee; 167 | } 168 | 169 | #remain { 170 | border-color: #888; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /server/static/verify.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var redirect; 4 | var vid; 5 | var token; 6 | var botUid; 7 | var step = 'intro'; 8 | var expireAt, duration, timerRunId; 9 | 10 | async function generateRequest() { 11 | let ua = await parseUserAgent() 12 | let req = await fetch('/api/verify', { 13 | method: 'POST', 14 | headers: { 15 | 'Content-Type': 'application/json', 16 | }, 17 | body: JSON.stringify({ 18 | 'ua': ua, 19 | }), 20 | }); 21 | 22 | let data = await req.json(); 23 | if (req.status == 201) { 24 | const curTs = Math.floor(new Date().getTime() / 1000) 25 | expireAt = data['expire']; 26 | duration = 360; 27 | timerRunId = setInterval(refreshRemainTime, 1000); 28 | return data; 29 | } 30 | 31 | return null; 32 | } 33 | 34 | async function checkRequestState() { 35 | let req = await fetch(`/api/verify/${vid}`, { 36 | headers: { 37 | 'Authorization': `BUTKN ${token}`, 38 | }, 39 | }); 40 | if (req.status == 202) 41 | return {status: 'waiting'}; 42 | if (req.status == 404 || req.status == 403) 43 | return {status: 'timeout'}; 44 | if (req.status == 500) 45 | return {status: 'error'}; 46 | if (req.status == 200) { 47 | let result = await req.json(); 48 | localStorage['verifyToken'] = result['token'] 49 | return { 50 | status: 'succ', 51 | info: result, 52 | }; 53 | } 54 | } 55 | 56 | async function init() { 57 | let arg = {}; 58 | try { 59 | let queryArgs = window.location.href.split('?')[1].split('&'); 60 | for (let kv of queryArgs) { 61 | let [k, v] = kv.split('=').map(decodeURIComponent); 62 | arg[k] = v; 63 | } 64 | console.log(arg); 65 | } 66 | catch (TypeError) { 67 | // pass 68 | } 69 | 70 | redirect = arg['redirect']; 71 | } 72 | 73 | function nextStep(nxtStep) { 74 | document.getElementById(step).hidden = true; 75 | document.getElementById(nxtStep).hidden = false; 76 | step = nxtStep; 77 | } 78 | 79 | function setButtonDisable(state) { 80 | for (let e of document.getElementsByClassName('next-step')) 81 | e.disabled = state; 82 | } 83 | 84 | async function startVerify(authType) { 85 | setButtonDisable(true); 86 | document.querySelectorAll("button.option").forEach((btn) => btn.disabled = true); 87 | 88 | const result = await generateRequest(); 89 | 90 | if (result) { 91 | [vid, token, botUid] = [result.vid, result.token, result.botInfo.uid]; 92 | document.getElementById('challenge-msg').innerText = `确认授权 ${vid}`; 93 | if (authType === 'app') { 94 | if (isMobile()) authType = 'applink'; 95 | else { 96 | authType = 'qrscan'; 97 | document.getElementById('qrcode').src = result.botInfo.qrcode; 98 | } 99 | } 100 | nextStep('auth-main'); 101 | document.getElementById(`auth-by-${authType}`).hidden = false; 102 | } 103 | else 104 | alert('获取验证错误。请稍侯再试。'); 105 | 106 | setButtonDisable(false); 107 | } 108 | 109 | async function checkVerify() { 110 | setButtonDisable(true); 111 | let result = await checkRequestState(vid); 112 | if (result.status === 'error') { 113 | alert('服务内部出现错误,请稍后再试'); 114 | setButtonDisable(false); 115 | return; 116 | } 117 | if (result.status !== 'succ') { 118 | alert('暂未获取到验证用户信息,请稍后再试'); 119 | setButtonDisable(false); 120 | return; 121 | } 122 | 123 | let uid = result.info['uid']; 124 | try { 125 | await setUserInfo(uid); 126 | } 127 | catch (e) { 128 | alert('获取用户信息失败,请稍后再试;若多次出错您可以向开发者反馈。'); 129 | setButtonDisable(false); 130 | return; 131 | } 132 | nextStep('finish'); 133 | setButtonDisable(false); 134 | } 135 | 136 | function redirect2origin() { 137 | if (redirect === undefined) redirect = '/'; 138 | window.location.href = redirect; 139 | } 140 | 141 | async function copyVerifyCode() { 142 | try { 143 | await navigator.clipboard.writeText(document.getElementById('challenge-msg').innerText); 144 | alert('已复制内容到剪贴板。现在您可以在私信页面直接粘贴。'); 145 | } 146 | catch (e) { 147 | alert('复制失败,您的浏览器不支持 Clipboard API 或拒绝剪贴板访问。请手动复制消息。'); 148 | } 149 | } 150 | 151 | function openInApp() { 152 | window.location.href = `bilibili://space/${botUid}`; 153 | } 154 | 155 | function openInNewTab() { 156 | window.open(`https://message.bilibili.com/#/whisper/mid${botUid}`); 157 | } 158 | 159 | function refreshRemainTime() { 160 | const curTs = Math.floor(new Date().getTime() / 1000) 161 | const remainSec = expireAt - curTs 162 | if (remainSec < 0) { 163 | document.getElementById('remain').innerText = '本次操作已超时。' 164 | clearInterval(timerRunId) 165 | return 166 | } 167 | const percent = (remainSec / duration * 100).toFixed(2) 168 | document.getElementById('remain').style = `--remain: ${percent}%` 169 | document.getElementById('remain-timer').innerText = `${remainSec}秒` 170 | } 171 | 172 | init(); 173 | -------------------------------------------------------------------------------- /server/templates/authorize.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block title %}授权第四方应用 | bili-auth{% endblock %} 3 | {% block main %} 4 | 5 | 6 |
7 |

正在获取应用信息,请稍侯...

8 |
9 | 37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /server/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | {% block title %}bili-auth{% endblock %} 7 | 8 | 9 | 10 | 11 | 12 |
13 | bili-auth 14 | 18 |
19 | 20 |
21 | {% block main %} 22 |

欢迎使用 bili-auth,这是一个第三方实现的哔哩哔哩帐号验证服务。

23 |

作为用户,您可以使用哔哩哔哩帐号快捷地登录已支持的应用。发送一条特定的私信给我们的机器人即可确认您的帐号,简单快捷,并且不会给您的帐号带来潜在安全风险。

24 |

作为开发者,您可以轻松给您的应用添加哔哩哔哩帐号登录的功能。本项目提供遵循 OAuth 2.0 标准的接口,以方便开发者接入。本项目开源,您可自由部署。

25 |

在哔哩哔哩观看 26 | 介绍视频 27 | 或者在 GitHub 浏览 28 | 项目仓库 29 | 以了解更多。 30 |

31 | {% endblock %} 32 |
33 | 34 |
35 | {% if homepage is not defined %} 36 |
37 | bili-auth 38 | {% if version is defined and version is not none %} 39 | {{ version }} 40 | {% endif %} 41 |
42 |
43 | 第三方哔哩哔哩 OAuth 2.0 API | 44 | Bilibili | 45 | GitHub 46 |
47 | {% endif %} 48 |
49 | 50 | 51 | -------------------------------------------------------------------------------- /server/templates/create_app.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block title %}创建应用 | bili-auth{% endblock %} 3 | {% block main %} 4 | 5 | 6 | 7 |
8 |

创建应用

9 | 10 |

您将要创建一个 OAuth 2.0 应用,请在此填写应用的有关信息。

11 | 12 | 13 | 14 |
简短的应用名称。将展示在授权页面上。
15 | 16 | 17 | 18 |
应用图标的 URL,图标将展示在授权页面上。本实例不提供图片上传服务,您可以在其他图床服务上传图标后填入图片链接。
19 | 20 | 21 | 22 |
关于应用的简短介绍。将展示给用户。
23 | 24 | 25 | 26 |
链接到应用相关页面的 URL。您可以填写应用的主页、项目链接或开发者的个人主页等链接。
27 | 28 | 29 | 30 |
31 |
授权时的回调地址(redirect_uri)必须与您预设的前缀完全匹配。
32 |
例如,若您提供的前缀是“https://example.com/oauth”,则这些是合法的回调地址:
33 |
“https://example.com/oauth” “https://example.com/oauth/callback”
34 |
而这些都是不合法的回调地址:
35 |
“https://example.com/callback”(路径不符)、“https://oauth.example.com/oauth”(域名不符)、“http://example.com/oauth”(协议不符)
36 |
实例会拒绝跳转至不合法的回调地址。
37 |
38 | 39 | 40 | 41 |
42 | 43 | 56 | 57 | {% endblock %} 58 | -------------------------------------------------------------------------------- /server/templates/user.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block title %}用户信息 | bili-auth{% endblock %} 3 | {% block main %} 4 | 5 | 6 | 7 | 14 | 15 | 19 | 20 | 27 | 28 | 29 |
30 |
    31 |
    32 | 33 | 37 | 38 | {% endblock %} 39 | -------------------------------------------------------------------------------- /server/templates/verify.html: -------------------------------------------------------------------------------- 1 | {% extends 'base.html' %} 2 | {% block title %}验证帐号 | bili-auth{% endblock %} 3 | {% block main %} 4 | 5 | 6 | 7 | 8 | 9 |
    10 |

    验证您的哔哩哔哩帐号

    11 |

    我们将通过私信验证的方式,确认您对某个哔哩哔哩帐号的实际控制权。

    12 |

    13 | 我们不会要求您提供任何私有信息(包括密码、手机号、短信验证码、邮箱、与其他用户的私信等)。 14 | 我们只会验证您对帐号的实际控制权,以及访问您帐号的公开信息(UID、昵称、头像、个性签名)。 15 |

    16 |

    接下来,您只需准备好一个已经登录哔哩哔哩帐号的设备,以及两分钟左右的时间完成验证。

    17 | 18 |
    19 | 20 | 38 | 39 | 87 | 88 | 103 | {% endblock %} 104 | --------------------------------------------------------------------------------