├── .gitignore ├── LICENSE ├── README.md ├── byCloudflarePages ├── README.MD ├── api │ ├── index.js │ ├── package.json │ ├── pnpm-lock.yaml │ └── wrangler.toml └── totp-manager-frontend │ ├── .env │ ├── .eslintrc.js │ ├── .gitignore │ ├── package.json │ ├── pnpm-lock.yaml │ ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt │ └── src │ ├── .env │ ├── App.js │ ├── config.js │ ├── index.css │ ├── index.js │ ├── reportWebVitals.js │ └── services │ └── api.js ├── github └── auth.go ├── go.mod ├── go.sum ├── handlers └── handlers.go ├── main.go ├── models └── totp.go ├── static ├── bundle.js ├── bundle.js.LICENSE.txt ├── favicon.ico └── index.html ├── storage └── storage.go └── totp-manager-frontend ├── .babelrc ├── .gitignore ├── package-lock.json ├── package.json ├── pnpm-lock.yaml ├── src ├── App.js └── index.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | # 操作系统生成的文件 2 | .DS_Store 3 | Thumbs.db 4 | 5 | # 编辑器和IDE生成的文件 6 | .vscode/ 7 | .idea/ 8 | *.swp 9 | *~ 10 | 11 | # 编译输出 12 | /bin/ 13 | /build/ 14 | /dist/ 15 | *.exe 16 | *.dll 17 | *.so 18 | *.dylib 19 | 20 | # 依赖管理 21 | ./totp-manager-frontend/node_modules/ 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 lonestech 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TOTP Token Manager 2 | 3 | TOTP Token Manager 是一个全栈应用程序,用于管理和生成基于时间的一次性密码(TOTP)。它提供了一个用户友好的界面来添加、删除、生成和导出TOTP令牌,并支持与GitHub Gist集成以进行备份和恢复。 4 | 5 | ## 功能特性 6 | 7 | - 添加、查看和删除TOTP令牌 8 | - 生成TOTP令牌 9 | - 导出和导入TOTP数据 10 | - 支持导入谷歌验证器二维码 11 | - 与GitHub Gist集成,用于备份和恢复 12 | - 响应式Web界面 13 | 14 | ## 技术栈 15 | 16 | ### 后端 17 | - 语言:Go 18 | - Web框架:Gorilla Mux 19 | - 其他依赖:见`go.mod`文件 20 | 21 | ### 前端 22 | - 框架:React 23 | - UI库:Ant Design 24 | - 构建工具:Webpack 25 | - 其他依赖:见`package.json`文件 26 | 27 | ## 安装 28 | 29 | ### 后端 30 | 31 | 1. 确保已安装Go(推荐1.16+版本) 32 | 2. 克隆仓库: 33 | ```git clone https://github.com/lonestech/TOTPTokenManager.git``` 34 | 3. 进入项目目录: 35 | ```cd TOTPTokenManager``` 36 | 4. 安装依赖: 37 | ```go mod tidy``` 38 | 5. 运行后端服务: 39 | ```go run main.go``` 40 | 6. 或者编译为可执行文件: 41 | ```go build``` 42 | 43 | ### 前端 44 | 45 | 1. 确保已安装Node.js(推荐14.0.0+版本)和pnpm 46 | 2. 进入前端项目目录: 47 | ```cd totp-manager-frontend``` 48 | 3. 安装依赖: 49 | ```pnpm install``` 50 | 4. 构建生产版本: 51 | ```pnpm run build``` 52 | ### 设置GitHub OAuth应用 53 | 54 | 1. 登录到你的GitHub账户 55 | 2. 进入Settings > Developer settings > OAuth Apps 56 | 3. 点击"New OAuth App" 57 | 4. 填写应用信息: 58 | - Application name: TOTP Token Manager 59 | - Homepage URL: http://localhost:8080 (或你的部署URL) 60 | - Authorization callback URL: http://localhost:8080/api/github/callback 61 | 5. 注册应用后,你会获得Client ID和Client Secret 62 | ### 环境变量设置 63 | 64 | 在运行应用之前,需要设置以下环境变量: 65 | 66 | ```export GITHUB_CLIENT_ID=你的Client ID``` 67 | 68 | ```export GITHUB_CLIENT_SECRET=你的Client Secret``` 69 | 70 | 也可以在github/auth.go中设置默认环境变量值 71 | ### 使用说明 72 | 73 | 1. 启动后端服务 74 | 2. 在浏览器中访问:`http://localhost:8080` 75 | 3. 使用界面添加、管理和生成TOTP令牌 76 | 4. 可以选择连接GitHub账户以使用Gist功能进行备份和恢复 77 | 78 | ### API端点 79 | 80 | | 端点 | 方法 | 描述 | 81 | |------|-----|----| 82 | | `/api/totp` | POST | 添加新的TOTP | 83 | | `/api/totp` | GET | 获取所有TOTP | 84 | | `/api/totp/{id}` | DELETE | 删除特定TOTP | 85 | | `/api/totp/{id}/generate` | GET | 生成特定TOTP的令牌 | 86 | | `/api/totp/{id}/export` | GET | 导出特定TOTP | 87 | | `/api/totp/clear-all` | POST | 清除所有TOTP | 88 | | `/api/totp/import` | POST | 导入TOTP数据 | 89 | | `/api/github/auth` | - | 重定向用户到GitHub进行授权 | 90 | | `/api/github/upload` | POST | 将TOTP数据上传到GitHub Gist。如果mode为"create",则创建新的Gist;如果为"update",则更新现有Gist。 | 91 | | `/api/github/restore?id=` | GET | 从指定的Gist ID恢复TOTP数据 | 92 | | `/api/github/versions` | GET | 列出所有可用的TOTP数据备份版本(Gist) | 93 | 94 | 95 | ### 注意事项 96 | 97 | - 确保你的GitHub账户已启用Gist功能 98 | - 所有的Gist备份都是私密的,只有你能访问 99 | - 定期备份你的TOTP数据以确保数据安全 100 | - 在恢复数据时请谨慎操作,以避免覆盖现有的重要数据 101 | 102 | ## 贡献 103 | 104 | 欢迎贡献代码、报告问题或提出新功能建议。请遵循标准的GitHub工作流程: 105 | 106 | 1. Fork 仓库 107 | 2. 创建特性分支 (`git checkout -b feature/AmazingFeature`) 108 | 3. 提交更改 (`git commit -m 'Add some AmazingFeature'`) 109 | 4. 推送到分支 (`git push origin feature/AmazingFeature`) 110 | 5. 创建一个 Pull Request 111 | 112 | ## 许可证 113 | 114 | 本项目采用 [MIT 许可证](LICENSE) 115 | -------------------------------------------------------------------------------- /byCloudflarePages/README.MD: -------------------------------------------------------------------------------- 1 | # TOTP Manager Cloudflare Worker 2 | 一个用于管理基于时间的单次密码(TOTP)令牌的Cloudflare Pages版本。 3 | 范例网站:[https://2fa.civilguard.es/](https://2fa.civilguard.es/) 4 | ## 功能 5 | - **TOTP管理**:创建、删除和生成令牌。 6 | - **二维码导出**:为TOTP令牌生成和导出二维码。 7 | - **备份管理**:将TOTP令牌备份到/从GitHub Gist中。 8 | - **CORS支持**:允许跨源请求。 9 | ## 设置 10 | 1. 安装所需依赖项: 11 | - `npm install 建议使用 pnpm install` 12 | 2. 在Cloudflare Workers上创建一个新的Worker: 13 | - 访问[Cloudflare Workers](https://dash.cloudflare.com/workers)。 14 | - 创建一个kv空间,名称为`TOTP_STORE` 15 | - 创建一个新的Worker,并选择“Pages”类型。fork[前端仓库](https://github.com/lonestech/totppages-manager-frontend)使用Pages链接部署前端。构建命令`npm run build`,构建目录`build`,环境变量`` 16 | - 设置自定义域名 17 | 3. 在GitHub上创建一个OAuth应用: 18 | - 访问[GitHub OAuth Apps](https://github.com/settings/applications/new)。 19 | - 设置应用名称,选择“Web”类型,主页url 设置前端域名并输入重定向URI(例如:`https://localhost:8080/auth/callback`)。保存并复制生成的客户端ID和客户端密钥. 20 | - 选择创建好的应用,创建客户端密钥 21 | 4. 在`wrangler.toml`中设置以下环境变量: 22 | - `GITHUB_CLIENT_ID`:您的GitHub OAuth客户端ID。 23 | - `GITHUB_CLIENT_SECRET`:您的GitHub OAuth客户端密钥。 24 | - `GITHUB_REDIRECT_URI`:GitHub认证后重定向的URL。 25 | - `FRONTEND_URL`:前端自定义域名。 26 | - `TOTP_STORE`:kv空间id。 27 | 设置完成后,运行`pnpm run deploy`命令以部署到Workers中,得到后端接口域名。 28 | 5. 到workers设置中修改参数`GITHUB_REDIRECT_URI`为`https://你的后端接口域名/auth/callback`。添加路由,选择一个域,路由内容`*.域名/api/*` 29 | 6. 到pages设置,变量和机密中设置`REACT_APP_API_BASE_URL`为后端域名,`REACT_APP_GITHUB_AUTH_URL`为`https://你的后端接口域名/auth/callback`。 30 | 。 31 | 7. 到github设置中修改重定向URI 32 | 33 | ### API端点 34 | - **/api/totp** 35 | - GET:获取TOTP令牌列表。 36 | - POST:创建新的TOTP令牌。 37 | - **/api/totp/{id}/generate** 38 | - GET:为TOTP令牌生成一次性密码。 39 | - **/api/totp/{id}/export** 40 | - GET:为TOTP令牌生成并导出二维码。 41 | - **/api/totp/clear-all** 42 | - POST:删除所有TOTP令牌。 43 | - **/api/totp/import** 44 | - POST:从二维码导入TOTP令牌。 45 | - **/api/cleanup-kv** 46 | - POST:清理KV存储中的无效条目。 47 | - **/api/github/auth-status** 48 | - GET:检查与GitHub的认证状态。 49 | - **/api/github/auth** 50 | - GET:启动GitHub认证流程。 51 | - **/api/github/callback** 52 | - GET:处理GitHub认证回调。 53 | - **/api/github/upload** 54 | - POST:将TOTP令牌上传到GitHub Gist。 55 | - **/api/github/versions** 56 | - GET:获取GitHub Gist的版本。 57 | - **/api/github/restore** 58 | - GET:从GitHub Gist版本恢复TOTP令牌。 59 | - **/api/github/delete-backup** 60 | - DELETE:删除GitHub Gist。 -------------------------------------------------------------------------------- /byCloudflarePages/api/index.js: -------------------------------------------------------------------------------- 1 | import {nanoid} from 'nanoid'; 2 | import {authenticator} from 'otplib'; 3 | import jwt from '@tsndr/cloudflare-worker-jwt'; 4 | import crypto from 'crypto'; 5 | 6 | // 辅助函数 7 | const jsonResponse = (data, status = 200, additionalHeaders = {}) => { 8 | const headers = { 9 | 'Content-Type': 'application/json', 10 | ...additionalHeaders, 11 | }; 12 | return new Response(JSON.stringify(data), {status, headers}); 13 | }; 14 | const createToken = (payload, secret) => { 15 | return jwt.sign(payload, secret, {expiresIn: '1h'}); 16 | }; 17 | const handleCors = () => { 18 | return { 19 | 'Access-Control-Allow-Origin': '*', 20 | 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS', 21 | 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 22 | 'Access-Control-Allow-Credentials': 'true', 23 | }; 24 | }; 25 | 26 | const handleErrors = async (request, func) => { 27 | try { 28 | return await func(); 29 | } catch (error) { 30 | console.error('Error details:', error); 31 | console.error('Error stack:', error.stack); 32 | return jsonResponse({error: error.message}, 500); 33 | } 34 | }; 35 | 36 | const isLoggedIn = async (request, env) => { 37 | const sessionData = await env.USER_STORE.get('session'); 38 | if (sessionData) { 39 | try { 40 | const session = JSON.parse(sessionData); 41 | return session.isLoggedIn; 42 | } catch (error) { 43 | console.error('Error parsing session data:', error); 44 | return false; 45 | } 46 | } 47 | return false; 48 | }; 49 | 50 | const parseOtpMigrationData = (binaryData) => { 51 | let index = 0; 52 | const totps = []; 53 | 54 | while (index < binaryData.length) { 55 | if (index + 1 >= binaryData.length) break; 56 | 57 | const fieldNumber = binaryData[index] >> 3; 58 | const wireType = binaryData[index] & 0x07; 59 | index++; 60 | 61 | switch (wireType) { 62 | case 0: // Varint 63 | const [_, bytesRead] = decodeVarint(binaryData.slice(index)); 64 | index += bytesRead; 65 | break; 66 | case 2: // Length-delimited 67 | const [length, lengthBytesRead] = decodeVarint(binaryData.slice(index)); 68 | index += lengthBytesRead; 69 | if (index + length > binaryData.length) { 70 | throw new Error("Invalid length-delimited field length"); 71 | } 72 | const fieldData = binaryData.slice(index, index + length); 73 | index += length; 74 | 75 | if (fieldNumber === 1) { 76 | const totp = parseTOTPEntry(fieldData); 77 | if (totp) totps.push(totp); 78 | } 79 | break; 80 | default: 81 | // Skip unknown wire types 82 | index++; 83 | break; 84 | } 85 | } 86 | 87 | if (totps.length === 0) { 88 | console.warn("No valid TOTP entries found in migration data"); 89 | } 90 | 91 | return totps; 92 | }; 93 | 94 | const parseTOTPEntry = (data) => { 95 | let index = 0; 96 | let secret = ''; 97 | let name = ''; 98 | let issuer = ''; 99 | 100 | while (index < data.length) { 101 | if (index + 1 >= data.length) break; 102 | 103 | const fieldNumber = data[index] >> 3; 104 | const wireType = data[index] & 0x07; 105 | index++; 106 | 107 | switch (wireType) { 108 | case 0: // Varint 109 | const [_, bytesRead] = decodeVarint(data.slice(index)); 110 | index += bytesRead; 111 | break; 112 | case 2: // Length-delimited 113 | const [length, lengthBytesRead] = decodeVarint(data.slice(index)); 114 | index += lengthBytesRead; 115 | if (index + length > data.length) { 116 | throw new Error("Invalid length-delimited field length"); 117 | } 118 | const fieldData = data.slice(index, index + length); 119 | index += length; 120 | 121 | switch (fieldNumber) { 122 | case 1: // Secret 123 | secret = base32Encode(fieldData); 124 | break; 125 | case 2: // Name 126 | name = utf8Decode(fieldData); 127 | break; 128 | case 3: // Issuer 129 | issuer = utf8Decode(fieldData); 130 | break; 131 | } 132 | break; 133 | default: 134 | // Skip unknown wire types 135 | index++; 136 | break; 137 | } 138 | } 139 | 140 | if (secret && name) { 141 | const userInfo = issuer ? `${name} (${issuer})` : name; 142 | return {userInfo, secret}; 143 | } 144 | 145 | return null; 146 | }; 147 | 148 | const base32Encode = (buffer) => { 149 | const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; 150 | let result = ''; 151 | let bits = 0; 152 | let value = 0; 153 | 154 | for (const byte of buffer) { 155 | value = (value << 8) | byte; 156 | bits += 8; 157 | while (bits >= 5) { 158 | bits -= 5; 159 | result += alphabet[value >>> bits & 31]; 160 | } 161 | } 162 | 163 | if (bits > 0) { 164 | result += alphabet[value << (5 - bits) & 31]; 165 | } 166 | 167 | return result; 168 | }; 169 | 170 | function decodeVarint(buffer) { 171 | let result = 0; 172 | let shift = 0; 173 | let bytesRead = 0; 174 | 175 | for (const byte of buffer) { 176 | bytesRead++; 177 | result |= (byte & 0x7F) << shift; 178 | if ((byte & 0x80) === 0) break; 179 | shift += 7; 180 | } 181 | 182 | return [result, bytesRead]; 183 | } 184 | 185 | const utf8Decode = (buffer) => { 186 | let result = ''; 187 | let i = 0; 188 | while (i < buffer.length) { 189 | let c = buffer[i++]; 190 | if (c > 127) { 191 | if (c > 191 && c < 224) { 192 | if (i >= buffer.length) throw new Error('UTF-8 decode: incomplete 2-byte sequence'); 193 | c = (c & 31) << 6 | buffer[i++] & 63; 194 | } else if (c > 223 && c < 240) { 195 | if (i + 1 >= buffer.length) throw new Error('UTF-8 decode: incomplete 3-byte sequence'); 196 | c = (c & 15) << 12 | (buffer[i++] & 63) << 6 | buffer[i++] & 63; 197 | } else if (c > 239 && c < 248) { 198 | if (i + 2 >= buffer.length) throw new Error('UTF-8 decode: incomplete 4-byte sequence'); 199 | c = (c & 7) << 18 | (buffer[i++] & 63) << 12 | (buffer[i++] & 63) << 6 | buffer[i++] & 63; 200 | } else throw new Error('UTF-8 decode: unknown multibyte start 0x' + c.toString(16) + ' at index ' + (i - 1)); 201 | } 202 | if (c <= 0xffff) result += String.fromCharCode(c); 203 | else if (c <= 0x10ffff) { 204 | c -= 0x10000; 205 | result += String.fromCharCode(c >> 10 | 0xd800) 206 | result += String.fromCharCode(c & 0x3FF | 0xdc00) 207 | } else throw new Error('UTF-8 decode: code point 0x' + c.toString(16) + ' exceeds UTF-16 reach'); 208 | } 209 | return result; 210 | }; 211 | 212 | async function handleGetTOTPs(env) { 213 | const sessionData = await env.USER_STORE.get('session'); 214 | const session = JSON.parse(sessionData); 215 | const userPrefix = `${session.username}_`; 216 | const totps = await env.TOTP_STORE.list(); 217 | const totpList = []; 218 | for (const key of totps.keys) { 219 | if (key.name.startsWith(userPrefix) && key.name !== `${userPrefix}github_state` && key.name !== `${userPrefix}github_token` && key.name !== `${userPrefix}gist_id`) { 220 | try { 221 | const totpData = await env.TOTP_STORE.get(key.name); 222 | if (totpData) { 223 | const parsedData = JSON.parse(totpData); 224 | totpList.push(parsedData); 225 | } 226 | } catch (error) { 227 | console.error(`Error parsing TOTP data for ${key.name}:`, error); 228 | } 229 | } 230 | } 231 | return jsonResponse(totpList, 200, handleCors()); 232 | } 233 | 234 | async function handleClearAllTOTPs(env) { 235 | const sessionData = await env.USER_STORE.get('session'); 236 | const session = JSON.parse(sessionData); 237 | const userPrefix = `${session.username}_`; 238 | const totps = await env.TOTP_STORE.list(); 239 | for (const key of totps.keys) { 240 | if (key.name.startsWith(userPrefix)) { 241 | await env.TOTP_STORE.delete(key.name); 242 | } 243 | } 244 | return jsonResponse({message: 'All TOTPs cleared successfully'}, 200, handleCors()); 245 | } 246 | 247 | 248 | async function hashPassword(password, salt) { 249 | const encoder = new TextEncoder(); 250 | const keyMaterial = crypto.subtle.importKey( 251 | "raw", 252 | encoder.encode(password), 253 | {name: "PBKDF2"}, 254 | false, 255 | ["deriveBits", "deriveKey"] 256 | ); 257 | 258 | return new Promise((resolve, reject) => { 259 | keyMaterial.then((keyMaterial) => { 260 | crypto.subtle.deriveKey( 261 | { 262 | name: "PBKDF2", 263 | salt: encoder.encode(salt), 264 | iterations: 100000, 265 | hash: "SHA-256" 266 | }, 267 | keyMaterial, 268 | {name: "AES-GCM", length: 256}, 269 | true, 270 | ["encrypt", "decrypt"] 271 | ).then((key) => { 272 | crypto.subtle.exportKey("raw", key).then((exportedKey) => { 273 | resolve(btoa(String.fromCharCode(...new Uint8Array(exportedKey)))); 274 | }).catch(reject); 275 | }).catch(reject); 276 | }).catch(reject); 277 | }); 278 | }; 279 | 280 | export default { 281 | async fetch(request, env, ctx) { 282 | const requestUrl = new URL(request.url); 283 | const corsHeaders = handleCors(); 284 | 285 | if (request.method === 'OPTIONS') { 286 | return new Response(null, {status: 204, headers: corsHeaders}); 287 | } 288 | 289 | try { 290 | // 用户注册 291 | if (requestUrl.pathname === '/api/register' && request.method === 'POST') { 292 | const {username, password} = await request.json(); 293 | if (!username || !password) { 294 | return jsonResponse({error: 'Username and password are required'}, 400); 295 | } 296 | const hashedPassword = await hashPassword(password, env.ENCRYPTION_KEY); 297 | const id = nanoid(); 298 | const user = {id, username, password: hashedPassword, createdAt: new Date().toISOString()}; 299 | await env.USER_STORE.put(id, JSON.stringify(user)); 300 | const tokenPromise = createToken({user}, env.JWT_SECRET); 301 | const token = await tokenPromise; 302 | // 设置会话信息,表示用户已注册并登录 303 | await env.USER_STORE.put('session', JSON.stringify({isLoggedIn: true, username})); 304 | return jsonResponse(token, 201, corsHeaders); 305 | } 306 | 307 | // 用户登录 308 | if (requestUrl.pathname === '/api/login' && request.method === 'POST') { 309 | const {username, password} = await request.json(); 310 | if (!username || !password) { 311 | return jsonResponse({error: 'Username and password are required'}, 400); 312 | } 313 | const users = await env.USER_STORE.list(); 314 | for await (const key of users.keys) { 315 | const userData = await env.USER_STORE.get(key.name); 316 | if (userData) { 317 | const parsedUser = JSON.parse(userData); 318 | const hashedPassword = await hashPassword(password, env.ENCRYPTION_KEY); 319 | if (parsedUser.username === username && parsedUser.password === hashedPassword) { 320 | const tokenPromise = createToken({username}, env.JWT_SECRET); 321 | const token = await tokenPromise; 322 | await env.USER_STORE.put('session', JSON.stringify({isLoggedIn: true, username})); 323 | return jsonResponse({ 324 | message: 'Login successful', 325 | user: {id: parsedUser.id, username: parsedUser.username}, 326 | token 327 | }, 200, corsHeaders); 328 | } 329 | } 330 | } 331 | return jsonResponse({error: 'Invalid username or password'}, 401, corsHeaders); 332 | } 333 | 334 | // 退出登录 335 | if (requestUrl.pathname === '/api/logout' && request.method === 'POST') { 336 | // 删除会话信息 337 | await env.USER_STORE.delete('session'); 338 | await env.TOTP_STORE.delete('github_token') 339 | return jsonResponse({message: 'Logout successful'}, 200, corsHeaders); 340 | } 341 | 342 | // TOTP 相关函数 343 | if (requestUrl.pathname === '/api/totp' && request.method === 'GET') { 344 | if (!isLoggedIn(request, env)) { 345 | return jsonResponse({error: 'Not logged in'}, 401); 346 | } 347 | const sessionData = await env.USER_STORE.get('session'); 348 | const session = JSON.parse(sessionData); 349 | const userPrefix = `${session.username}_`; 350 | const totps = await env.TOTP_STORE.list(); 351 | const totpList = []; 352 | for (const key of totps.keys) { 353 | if (key.name.startsWith(userPrefix) && key.name !== `${userPrefix}github_state` && key.name !== `${userPrefix}github_token` && key.name !== `${userPrefix}gist_id`) { 354 | try { 355 | const totpData = await env.TOTP_STORE.get(key.name); 356 | if (totpData) { 357 | const parsedData = JSON.parse(totpData); 358 | totpList.push(parsedData); 359 | } 360 | } catch (error) { 361 | console.error(`Error parsing TOTP data for ${key.name}:`, error); 362 | } 363 | } 364 | } 365 | return jsonResponse(totpList, 200, corsHeaders); 366 | } 367 | 368 | if (requestUrl.pathname === '/api/totp' && request.method === 'POST') { 369 | if (!isLoggedIn(request, env)) { 370 | return jsonResponse({error: 'Not logged in'}, 401); 371 | } 372 | const sessionData = await env.USER_STORE.get('session'); 373 | const session = JSON.parse(sessionData); 374 | const {userInfo, secret} = await request.json(); 375 | if (!userInfo || !secret) { 376 | return jsonResponse({error: 'User info and secret are required'}, 400); 377 | } 378 | const id = nanoid(); 379 | const userPrefix = `${session.username}_`; 380 | const totp = {id, userInfo, secret, createdAt: new Date().toISOString(), username: session.username}; 381 | await env.TOTP_STORE.put(`${userPrefix}${id}`, JSON.stringify(totp)); 382 | return jsonResponse(totp, 201, corsHeaders); 383 | } 384 | 385 | if (requestUrl.pathname.startsWith('/api/totp/') && request.method === 'DELETE') { 386 | if (!isLoggedIn(request, env)) { 387 | return jsonResponse({error: 'Not logged in'}, 401); 388 | } 389 | const id = requestUrl.pathname.split('/').pop(); 390 | const sessionData = await env.USER_STORE.get('session'); 391 | const session = JSON.parse(sessionData); 392 | const userPrefix = `${session.username}_`; 393 | const totpData = await env.TOTP_STORE.get(`${userPrefix}${id}`); 394 | if (totpData) { 395 | const parsedData = JSON.parse(totpData); 396 | if (parsedData.username === session.username) { 397 | await env.TOTP_STORE.delete(`${userPrefix}${id}`); 398 | return jsonResponse({message: 'TOTP deleted successfully'}, 200, corsHeaders); 399 | } 400 | } 401 | return jsonResponse({error: 'You are not authorized to delete this TOTP'}, 403, corsHeaders); 402 | } 403 | 404 | if (requestUrl.pathname.match(/^\/api\/totp\/[^/]+\/generate$/) && request.method === 'GET') { 405 | if (!isLoggedIn(request, env)) { 406 | return jsonResponse({error: 'Not logged in'}, 401); 407 | } 408 | const id = requestUrl.pathname.split('/').slice(-2)[0]; 409 | const sessionData = await env.USER_STORE.get('session'); 410 | const session = JSON.parse(sessionData); 411 | const userPrefix = `${session.username}_`; 412 | const totpData = await env.TOTP_STORE.get(`${userPrefix}${id}`); 413 | if (!totpData) { 414 | console.error('TOTP not found for ID:', id); 415 | return jsonResponse({error: 'TOTP not found'}, 404); 416 | } 417 | try { 418 | const totp = JSON.parse(totpData); 419 | if (totp.username === session.username) { 420 | const token = authenticator.generate(totp.secret); 421 | return jsonResponse({token}, 200, corsHeaders); 422 | } else { 423 | return jsonResponse({error: 'You are not authorized to generate token for this TOTP'}, 403, corsHeaders); 424 | } 425 | } catch (error) { 426 | console.error('Error generating token:', error); 427 | return jsonResponse({error: 'Failed to generate token'}, 500, corsHeaders); 428 | } 429 | } 430 | 431 | if (requestUrl.pathname.match(/^\/api\/totp\/[^/]+\/export$/) && request.method === 'GET') { 432 | if (!isLoggedIn(request, env)) { 433 | return jsonResponse({error: 'Not logged in'}, 401); 434 | } 435 | const id = requestUrl.pathname.split('/').slice(-2)[0]; 436 | const sessionData = await env.USER_STORE.get('session'); 437 | const session = JSON.parse(sessionData); 438 | const userPrefix = `${session.username}_`; 439 | const totpData = await env.TOTP_STORE.get(`${userPrefix}${id}`); 440 | if (!totpData) { 441 | console.error('TOTP not found for ID:', id); 442 | return jsonResponse({error: 'TOTP not found'}, 404); 443 | } 444 | try { 445 | const totp = JSON.parse(totpData); 446 | if (totp.username === session.username) { 447 | const uri = authenticator.keyuri(totp.userInfo, 'TOTP Manager', totp.secret); 448 | return jsonResponse({uri}, 200, corsHeaders); 449 | } else { 450 | return jsonResponse({error: 'You are not authorized to export this TOTP'}, 403, corsHeaders); 451 | } 452 | } catch (error) { 453 | console.error('Error exporting TOTP:', error); 454 | return jsonResponse({error: 'Failed to export TOTP'}, 500, corsHeaders); 455 | } 456 | } 457 | 458 | if (requestUrl.pathname === '/api/totp/clear-all' && request.method === 'POST') { 459 | if (!isLoggedIn(request, env)) { 460 | return jsonResponse({error: 'Not logged in'}, 401); 461 | } 462 | await handleClearAllTOTPs(env); 463 | return jsonResponse({message: 'All your TOTPs cleared successfully'}, 200, corsHeaders); 464 | } 465 | 466 | if (requestUrl.pathname === '/api/totp/import' && request.method === 'POST') { 467 | if (!isLoggedIn(request, env)) { 468 | return jsonResponse({error: 'Not logged in'}, 401); 469 | } 470 | const {qrData} = await request.json(); 471 | try { 472 | let totps = []; 473 | if (qrData.startsWith('otpauth-migration://offline?data=')) { 474 | const base64Data = qrData.split('data=')[1]; 475 | const decodedData = atob(decodeURIComponent(base64Data)); 476 | const binaryData = new Uint8Array(decodedData.length); 477 | for (let i = 0; i < decodedData.length; i++) { 478 | binaryData[i] = decodedData.charCodeAt(i); 479 | } 480 | totps = parseOtpMigrationData(binaryData); 481 | } else if (qrData.startsWith('otpauth://')) { 482 | const uri = new URL(qrData); 483 | const secret = uri.searchParams.get('secret'); 484 | const userInfo = decodeURIComponent(uri.pathname.split('/').pop()); 485 | 486 | if (!secret || !userInfo) { 487 | console.error('Invalid QR code data:', {secret, userInfo}); 488 | return jsonResponse({error: 'Invalid QR code data'}, 400); 489 | } 490 | 491 | totps = [{userInfo, secret}]; 492 | } else { 493 | return jsonResponse({error: 'Unsupported QR code format'}, 400); 494 | } 495 | 496 | if (!Array.isArray(totps) || totps.length === 0) { 497 | return jsonResponse({error: 'No valid TOTP entries found'}, 400); 498 | } 499 | 500 | const sessionData = await env.USER_STORE.get('session'); 501 | const session = JSON.parse(sessionData); 502 | const userPrefix = `${session.username}_`; 503 | 504 | for (const totp of totps) { 505 | const id = nanoid(); 506 | await env.TOTP_STORE.put(`${userPrefix}${id}`, JSON.stringify({ 507 | id, 508 | userInfo: totp.userInfo, 509 | secret: totp.secret, 510 | createdAt: new Date().toISOString(), 511 | username: session.username 512 | })); 513 | } 514 | 515 | console.log('TOTPs imported successfully:', totps.length); 516 | return jsonResponse({success: true, count: totps.length}, 200, corsHeaders); 517 | } catch (error) { 518 | console.error('Import TOTP error:', error); 519 | return jsonResponse({error: 'Failed to import TOTP: ' + error.message}, 400); 520 | } 521 | } 522 | // GitHub 相关函数 523 | if (requestUrl.pathname === '/api/github/auth-status' && request.method === 'GET') { 524 | const token = await env.TOTP_STORE.get('github_token'); 525 | return jsonResponse({authenticated: !!token}, 200, corsHeaders); 526 | } 527 | 528 | if (requestUrl.pathname === '/api/github/auth' && request.method === 'GET') { 529 | const clientId = env.GITHUB_CLIENT_ID; 530 | const redirectUri = env.GITHUB_REDIRECT_URI; 531 | const state = nanoid(); 532 | await env.TOTP_STORE.put('github_state', state, {expirationTtl: 600}); 533 | const authUrl = `https://github.com/login/oauth/authorize?client_id=${clientId}&redirect_uri=${redirectUri}&state=${state}&scope=gist`; 534 | return new Response(null, { 535 | status: 302, 536 | headers: { 537 | 'Location': authUrl, 538 | ...corsHeaders 539 | } 540 | }); 541 | } 542 | 543 | if (requestUrl.pathname === '/api/github/callback' && request.method === 'GET') { 544 | const code = requestUrl.searchParams.get('code'); 545 | const state = requestUrl.searchParams.get('state'); 546 | 547 | const savedState = await env.TOTP_STORE.get('github_state'); 548 | if (state !== savedState) { 549 | return new Response(JSON.stringify({error: 'Invalid state'}), { 550 | status: 400, 551 | headers: {'Content-Type': 'application/json', ...corsHeaders} 552 | }); 553 | } 554 | ; 555 | const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { 556 | method: 'POST', 557 | headers: { 558 | 'Content-Type': 'application/json', 559 | 'Accept': 'application/json' 560 | }, 561 | body: JSON.stringify({ 562 | client_id: env.GITHUB_CLIENT_ID, 563 | client_secret: env.GITHUB_CLIENT_SECRET, 564 | code: code 565 | }) 566 | }); 567 | 568 | const tokenData = await tokenResponse.json(); 569 | if (tokenData.access_token) { 570 | console.log('Received GitHub token:', tokenData.access_token); 571 | await env.TOTP_STORE.put('github_token', tokenData.access_token); 572 | return new Response(null, { 573 | status: 302, 574 | headers: { 575 | 'Location': env.FRONTEND_URL, 576 | ...corsHeaders 577 | } 578 | }); 579 | } else { 580 | console.error('Failed to obtain GitHub access token:', tokenData); 581 | return new Response(JSON.stringify({error: 'Failed to obtain access token'}), { 582 | status: 400, 583 | headers: {'Content-Type': 'application/json', ...corsHeaders} 584 | }); 585 | } 586 | } 587 | 588 | if (requestUrl.pathname === '/api/github/upload' && request.method === 'POST') { 589 | if (!isLoggedIn(request, env)) { 590 | return jsonResponse({error: 'Not logged in'}, 401); 591 | } 592 | let mode; 593 | try { 594 | const body = await request.json(); 595 | mode = body.mode; 596 | } catch (error) { 597 | console.error('Error parsing request body:', error); 598 | return {error: 'Invalid request body'}; 599 | } 600 | const token = await env.TOTP_STORE.get('github_token'); 601 | if (!token) { 602 | return jsonResponse({error: 'Not authenticated with GitHub'}, 401, corsHeaders); 603 | } 604 | const sessionData = await env.USER_STORE.get('session'); 605 | const session = JSON.parse(sessionData); 606 | const totps = await handleGetTOTPs(env); 607 | const filteredTotps = totps.filter(totp => totp.username === session.username); 608 | const content = JSON.stringify(filteredTotps); 609 | let gistId = await env.TOTP_STORE.get('gist_id'); 610 | let method, url, body; 611 | if (mode === 'create' || !gistId) { 612 | method = 'POST'; 613 | url = 'https://api.github.com/gists'; 614 | body = JSON.stringify({ 615 | description: 'TOTP Backup', 616 | public: false, 617 | files: { 618 | 'totp_backup.json': { 619 | content: content 620 | } 621 | } 622 | }); 623 | } else { 624 | method = 'PATCH'; 625 | url = `https://api.github.com/gists/${gistId}`; 626 | body = JSON.stringify({ 627 | description: 'TOTP Backup', 628 | files: { 629 | 'totp_backup.json': { 630 | content: content 631 | } 632 | } 633 | }); 634 | } 635 | try { 636 | const response = await fetch(url, { 637 | method: method, 638 | headers: { 639 | 'Authorization': `token ${token}`, 640 | 'Content-Type': 'application/json', 641 | 'Accept': 'application/vnd.github.v3+json', 642 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0' 643 | }, 644 | body: body 645 | }); 646 | if (!response.ok) { 647 | const errorBody = await response.text(); 648 | console.error(`GitHub API error: ${response.status} ${response.statusText}`); 649 | console.error(`Error body: ${errorBody}`); 650 | throw new Error(`GitHub API responded with status ${response.status}: ${errorBody}`); 651 | } 652 | const data = await response.json(); 653 | if (data.id) { 654 | await env.TOTP_STORE.put('gist_id', data.id); 655 | return jsonResponse({ 656 | message: 'Data uploaded to Gist successfully', 657 | gistId: data.id 658 | }, 200, corsHeaders); 659 | } else { 660 | return jsonResponse({error: 'Failed to upload data to Gist', details: data}, 500, corsHeaders); 661 | } 662 | } catch (error) { 663 | console.error('Error uploading to GitHub:', error); 664 | return jsonResponse({ 665 | error: 'Failed to upload data to Gist', 666 | details: error.message 667 | }, 500, corsHeaders); 668 | } 669 | } 670 | 671 | if (requestUrl.pathname === '/api/github/versions' && request.method === 'GET') { 672 | if (!isLoggedIn(request, env)) { 673 | return jsonResponse({error: 'Not logged in'}, 401, corsHeaders); 674 | } 675 | const token = await env.TOTP_STORE.get('github_token'); 676 | const gistId = await env.TOTP_STORE.get('gist_id'); 677 | if (!token || !gistId) { 678 | console.error('GitHub token or gist ID not found'); 679 | return jsonResponse({error: 'Not authenticated with GitHub or no backup found'}, 401, corsHeaders); 680 | } 681 | try { 682 | console.log(`Fetching versions for gist: ${gistId}`); 683 | const response = await fetch(`https://api.github.com/gists/${gistId}`, { 684 | headers: { 685 | 'Authorization': token, 686 | 'Accept': 'application/vnd.github.v3+json', 687 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0' 688 | } 689 | }); 690 | if (!response.ok) { 691 | const errorBody = await response.text(); 692 | console.error(`GitHub API error: ${response.status} ${response.statusText}`); 693 | console.error(`Error body: ${errorBody}`); 694 | throw new Error(`GitHub API responded with status ${response.status}: ${errorBody}`); 695 | } 696 | const gistData = await response.json(); 697 | console.log('Gist data received:', JSON.stringify(gistData, null, 2)); 698 | const history = gistData.history || []; 699 | console.log(`Found ${history.length} versions`); 700 | return jsonResponse(history.map(version => ({ 701 | id: version.version, 702 | description: `Backup from ${new Date(version.committed_at).toLocaleString()}`, 703 | created_at: version.committed_at, 704 | updated_at: version.committed_at 705 | })), 200, corsHeaders); 706 | } catch (error) { 707 | console.error('Error fetching GitHub versions:', error); 708 | return jsonResponse({ 709 | error: 'Failed to fetch backup versions', 710 | details: error.message 711 | }, 500, corsHeaders); 712 | } 713 | } 714 | 715 | if (requestUrl.pathname === '/api/github/restore' && request.method === 'GET') { 716 | if (!isLoggedIn(request, env)) { 717 | return jsonResponse({error: 'Not logged in'}, 401, corsHeaders); 718 | } 719 | const token = await env.TOTP_STORE.get('github_token'); 720 | const gistId = await env.TOTP_STORE.get('gist_id'); 721 | if (!token || !gistId) { 722 | return jsonResponse({error: 'Not authenticated with GitHub or no backup found'}, 401, corsHeaders); 723 | } 724 | try { 725 | console.log(`Restoring version ${gistId}`); 726 | const response = await fetch(`https://api.github.com/gists/${gistId}`, { 727 | headers: { 728 | 'Authorization': `token ${token}`, 729 | 'Accept': 'application/vnd.github.v3+json', 730 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0' 731 | } 732 | }); 733 | if (!response.ok) { 734 | const errorBody = await response.text(); 735 | console.error(`GitHub API error: ${response.status} ${response.statusText}`); 736 | console.error(`Error body: ${errorBody}`); 737 | throw new Error(`GitHub API responded with status ${response.status}: ${errorBody}`); 738 | } 739 | const gistData = await response.json(); 740 | console.log('Gist data received:', JSON.stringify(gistData, null, 2)); 741 | const content = gistData.files['totp_backup.json'].content; 742 | const totps = JSON.parse(content); 743 | const sessionData = await env.USER_STORE.get('session'); 744 | const session = JSON.parse(sessionData); 745 | // Clear existing TOTPs for the logged-in user 746 | await handleClearAllTOTPs(env, session.username); 747 | // Add restored TOTPs for the logged-in user 748 | for (const totp of totps) { 749 | if (totp.username === session.username) { 750 | await env.TOTP_STORE.put(totp.id, JSON.stringify(totp)); 751 | } 752 | } 753 | console.log(`Restored ${totps.length} TOTPs for the user`); 754 | return jsonResponse({ 755 | message: 'Data restored from Gist successfully', 756 | count: totps.length 757 | }, 200, corsHeaders); 758 | } catch (error) { 759 | console.error('Error restoring from GitHub:', error); 760 | return jsonResponse({ 761 | error: 'Failed to restore data from Gist', 762 | details: error.message 763 | }, 500, corsHeaders); 764 | } 765 | } 766 | 767 | if (requestUrl.pathname === '/api/github/delete-backup' && request.method === 'DELETE') { 768 | if (!isLoggedIn(request, env)) { 769 | return jsonResponse({error: 'Not logged in'}, 401, corsHeaders); 770 | } 771 | const token = await env.TOTP_STORE.get('github_token'); 772 | const gistId = await env.TOTP_STORE.get('gist_id'); 773 | if (!token || !gistId) { 774 | return jsonResponse({error: 'Not authenticated with GitHub or no backup found'}, 401, corsHeaders); 775 | } 776 | try { 777 | console.log(`Deleting gist ${gistId}`); 778 | const response = await fetch(`https://api.github.com/gists/${gistId}`, { 779 | method: 'DELETE', 780 | headers: { 781 | 'Authorization': `token ${token}`, 782 | 'Accept': 'application/vnd.github.v3+json', 783 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36 Edg/127.0.0.0' 784 | } 785 | }); 786 | if (!response.ok) { 787 | const errorBody = await response.text(); 788 | console.error(`GitHub API error: ${response.status} ${response.statusText}`); 789 | console.error(`Error body: ${errorBody}`); 790 | throw new Error(`GitHub API responded with status ${response.status}: ${errorBody}`); 791 | } 792 | await env.TOTP_STORE.delete('gist_id'); 793 | console.log('Backup deleted successfully'); 794 | return jsonResponse({message: 'Backup deleted successfully'}, 200, corsHeaders); 795 | } catch (error) { 796 | console.error('Error deleting backup:', error); 797 | return jsonResponse({error: 'Failed to delete backup', details: error.message}, 500, corsHeaders); 798 | } 799 | } 800 | 801 | // 404 错误处理 802 | return jsonResponse({error: 'Not Found'}, 404, corsHeaders); 803 | } catch (error) { 804 | console.error('Unhandled error:', error); 805 | return jsonResponse({error: 'Internal Server Error'}, 500, corsHeaders); 806 | } 807 | } 808 | }; 809 | -------------------------------------------------------------------------------- /byCloudflarePages/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "dev": "wrangler dev --port 8080", 7 | "deploy": "wrangler publish" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "description": "", 13 | "dependencies": { 14 | "@octokit/auth-oauth-app": "^8.1.1", 15 | "@octokit/rest": "^21.0.2", 16 | "@tsndr/cloudflare-worker-jwt": "^3.1.1", 17 | "axios": "^1.7.7", 18 | "jsonwebtoken": "^9.0.2", 19 | "nanoid": "^5.0.7", 20 | "otplib": "^12.0.1", 21 | "wrangler": "^3.80.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /byCloudflarePages/api/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '6.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | dependencies: 8 | '@octokit/auth-oauth-app': 9 | specifier: ^8.1.1 10 | version: 8.1.1 11 | '@octokit/rest': 12 | specifier: ^21.0.2 13 | version: 21.0.2 14 | '@tsndr/cloudflare-worker-jwt': 15 | specifier: ^3.1.1 16 | version: 3.1.1 17 | axios: 18 | specifier: ^1.7.7 19 | version: 1.7.7 20 | jsonwebtoken: 21 | specifier: ^9.0.2 22 | version: 9.0.2 23 | nanoid: 24 | specifier: ^5.0.7 25 | version: 5.0.7 26 | otplib: 27 | specifier: ^12.0.1 28 | version: 12.0.1 29 | wrangler: 30 | specifier: ^3.80.0 31 | version: 3.80.0 32 | 33 | packages: 34 | 35 | /@cloudflare/kv-asset-handler@0.3.4: 36 | resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==} 37 | engines: {node: '>=16.13'} 38 | dependencies: 39 | mime: 3.0.0 40 | dev: false 41 | 42 | /@cloudflare/workerd-darwin-64@1.20240925.0: 43 | resolution: {integrity: sha512-KdLnSXuzB65CbqZPm+qYzk+zkQ1tUNPaaRGYVd/jPYAxwwtfTUQdQ+ahDPwVVs2tmQELKy7ZjQjf2apqSWUfjw==} 44 | engines: {node: '>=16'} 45 | cpu: [x64] 46 | os: [darwin] 47 | requiresBuild: true 48 | dev: false 49 | optional: true 50 | 51 | /@cloudflare/workerd-darwin-arm64@1.20240925.0: 52 | resolution: {integrity: sha512-MiQ6uUmCXjsXgWNV+Ock2tp2/tYqNJGzjuaH6jFioeRF+//mz7Tv7J7EczOL4zq+TH8QFOh0/PUsLyazIWVGng==} 53 | engines: {node: '>=16'} 54 | cpu: [arm64] 55 | os: [darwin] 56 | requiresBuild: true 57 | dev: false 58 | optional: true 59 | 60 | /@cloudflare/workerd-linux-64@1.20240925.0: 61 | resolution: {integrity: sha512-Rjix8jsJMfsInmq3Hm3fmiRQ+rwzuWRPV1pg/OWhMSfNP7Qp2RCU+RGkhgeR9Z5eNAje0Sn2BMrFq4RvF9/yRA==} 62 | engines: {node: '>=16'} 63 | cpu: [x64] 64 | os: [linux] 65 | requiresBuild: true 66 | dev: false 67 | optional: true 68 | 69 | /@cloudflare/workerd-linux-arm64@1.20240925.0: 70 | resolution: {integrity: sha512-VYIPeMHQRtbwQoIjUwS/zULlywPxyDvo46XkTpIW5MScEChfqHvAYviQ7TzYGx6Q+gmZmN+DUB2KOMx+MEpCxA==} 71 | engines: {node: '>=16'} 72 | cpu: [arm64] 73 | os: [linux] 74 | requiresBuild: true 75 | dev: false 76 | optional: true 77 | 78 | /@cloudflare/workerd-windows-64@1.20240925.0: 79 | resolution: {integrity: sha512-C8peGvaU5R51bIySi1VbyfRgwNSSRknqoFSnSbSBI3uTN3THTB3UnmRKy7GXJDmyjgXuT9Pcs1IgaWNubLtNtw==} 80 | engines: {node: '>=16'} 81 | cpu: [x64] 82 | os: [win32] 83 | requiresBuild: true 84 | dev: false 85 | optional: true 86 | 87 | /@cloudflare/workers-shared@0.5.4: 88 | resolution: {integrity: sha512-PNL/0TjKRdUHa1kwgVdqUNJVZ9ez4kacsi8omz+gv859EvJmsVuGiMAClY2YfJnC9LVKhKCcjqmFgKNXG9/IXA==} 89 | engines: {node: '>=16.7.0'} 90 | dependencies: 91 | mime: 3.0.0 92 | zod: 3.23.8 93 | dev: false 94 | 95 | /@cspotcode/source-map-support@0.8.1: 96 | resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} 97 | engines: {node: '>=12'} 98 | dependencies: 99 | '@jridgewell/trace-mapping': 0.3.9 100 | dev: false 101 | 102 | /@esbuild-plugins/node-globals-polyfill@0.2.3(esbuild@0.17.19): 103 | resolution: {integrity: sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==} 104 | peerDependencies: 105 | esbuild: '*' 106 | dependencies: 107 | esbuild: 0.17.19 108 | dev: false 109 | 110 | /@esbuild-plugins/node-modules-polyfill@0.2.2(esbuild@0.17.19): 111 | resolution: {integrity: sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==} 112 | peerDependencies: 113 | esbuild: '*' 114 | dependencies: 115 | esbuild: 0.17.19 116 | escape-string-regexp: 4.0.0 117 | rollup-plugin-node-polyfills: 0.2.1 118 | dev: false 119 | 120 | /@esbuild/android-arm64@0.17.19: 121 | resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==} 122 | engines: {node: '>=12'} 123 | cpu: [arm64] 124 | os: [android] 125 | requiresBuild: true 126 | dev: false 127 | optional: true 128 | 129 | /@esbuild/android-arm@0.17.19: 130 | resolution: {integrity: sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==} 131 | engines: {node: '>=12'} 132 | cpu: [arm] 133 | os: [android] 134 | requiresBuild: true 135 | dev: false 136 | optional: true 137 | 138 | /@esbuild/android-x64@0.17.19: 139 | resolution: {integrity: sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==} 140 | engines: {node: '>=12'} 141 | cpu: [x64] 142 | os: [android] 143 | requiresBuild: true 144 | dev: false 145 | optional: true 146 | 147 | /@esbuild/darwin-arm64@0.17.19: 148 | resolution: {integrity: sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==} 149 | engines: {node: '>=12'} 150 | cpu: [arm64] 151 | os: [darwin] 152 | requiresBuild: true 153 | dev: false 154 | optional: true 155 | 156 | /@esbuild/darwin-x64@0.17.19: 157 | resolution: {integrity: sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==} 158 | engines: {node: '>=12'} 159 | cpu: [x64] 160 | os: [darwin] 161 | requiresBuild: true 162 | dev: false 163 | optional: true 164 | 165 | /@esbuild/freebsd-arm64@0.17.19: 166 | resolution: {integrity: sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==} 167 | engines: {node: '>=12'} 168 | cpu: [arm64] 169 | os: [freebsd] 170 | requiresBuild: true 171 | dev: false 172 | optional: true 173 | 174 | /@esbuild/freebsd-x64@0.17.19: 175 | resolution: {integrity: sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==} 176 | engines: {node: '>=12'} 177 | cpu: [x64] 178 | os: [freebsd] 179 | requiresBuild: true 180 | dev: false 181 | optional: true 182 | 183 | /@esbuild/linux-arm64@0.17.19: 184 | resolution: {integrity: sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==} 185 | engines: {node: '>=12'} 186 | cpu: [arm64] 187 | os: [linux] 188 | requiresBuild: true 189 | dev: false 190 | optional: true 191 | 192 | /@esbuild/linux-arm@0.17.19: 193 | resolution: {integrity: sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==} 194 | engines: {node: '>=12'} 195 | cpu: [arm] 196 | os: [linux] 197 | requiresBuild: true 198 | dev: false 199 | optional: true 200 | 201 | /@esbuild/linux-ia32@0.17.19: 202 | resolution: {integrity: sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==} 203 | engines: {node: '>=12'} 204 | cpu: [ia32] 205 | os: [linux] 206 | requiresBuild: true 207 | dev: false 208 | optional: true 209 | 210 | /@esbuild/linux-loong64@0.17.19: 211 | resolution: {integrity: sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==} 212 | engines: {node: '>=12'} 213 | cpu: [loong64] 214 | os: [linux] 215 | requiresBuild: true 216 | dev: false 217 | optional: true 218 | 219 | /@esbuild/linux-mips64el@0.17.19: 220 | resolution: {integrity: sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==} 221 | engines: {node: '>=12'} 222 | cpu: [mips64el] 223 | os: [linux] 224 | requiresBuild: true 225 | dev: false 226 | optional: true 227 | 228 | /@esbuild/linux-ppc64@0.17.19: 229 | resolution: {integrity: sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==} 230 | engines: {node: '>=12'} 231 | cpu: [ppc64] 232 | os: [linux] 233 | requiresBuild: true 234 | dev: false 235 | optional: true 236 | 237 | /@esbuild/linux-riscv64@0.17.19: 238 | resolution: {integrity: sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==} 239 | engines: {node: '>=12'} 240 | cpu: [riscv64] 241 | os: [linux] 242 | requiresBuild: true 243 | dev: false 244 | optional: true 245 | 246 | /@esbuild/linux-s390x@0.17.19: 247 | resolution: {integrity: sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==} 248 | engines: {node: '>=12'} 249 | cpu: [s390x] 250 | os: [linux] 251 | requiresBuild: true 252 | dev: false 253 | optional: true 254 | 255 | /@esbuild/linux-x64@0.17.19: 256 | resolution: {integrity: sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==} 257 | engines: {node: '>=12'} 258 | cpu: [x64] 259 | os: [linux] 260 | requiresBuild: true 261 | dev: false 262 | optional: true 263 | 264 | /@esbuild/netbsd-x64@0.17.19: 265 | resolution: {integrity: sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==} 266 | engines: {node: '>=12'} 267 | cpu: [x64] 268 | os: [netbsd] 269 | requiresBuild: true 270 | dev: false 271 | optional: true 272 | 273 | /@esbuild/openbsd-x64@0.17.19: 274 | resolution: {integrity: sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==} 275 | engines: {node: '>=12'} 276 | cpu: [x64] 277 | os: [openbsd] 278 | requiresBuild: true 279 | dev: false 280 | optional: true 281 | 282 | /@esbuild/sunos-x64@0.17.19: 283 | resolution: {integrity: sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==} 284 | engines: {node: '>=12'} 285 | cpu: [x64] 286 | os: [sunos] 287 | requiresBuild: true 288 | dev: false 289 | optional: true 290 | 291 | /@esbuild/win32-arm64@0.17.19: 292 | resolution: {integrity: sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==} 293 | engines: {node: '>=12'} 294 | cpu: [arm64] 295 | os: [win32] 296 | requiresBuild: true 297 | dev: false 298 | optional: true 299 | 300 | /@esbuild/win32-ia32@0.17.19: 301 | resolution: {integrity: sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==} 302 | engines: {node: '>=12'} 303 | cpu: [ia32] 304 | os: [win32] 305 | requiresBuild: true 306 | dev: false 307 | optional: true 308 | 309 | /@esbuild/win32-x64@0.17.19: 310 | resolution: {integrity: sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==} 311 | engines: {node: '>=12'} 312 | cpu: [x64] 313 | os: [win32] 314 | requiresBuild: true 315 | dev: false 316 | optional: true 317 | 318 | /@fastify/busboy@2.1.1: 319 | resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} 320 | engines: {node: '>=14'} 321 | dev: false 322 | 323 | /@jridgewell/resolve-uri@3.1.2: 324 | resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} 325 | engines: {node: '>=6.0.0'} 326 | dev: false 327 | 328 | /@jridgewell/sourcemap-codec@1.5.0: 329 | resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} 330 | dev: false 331 | 332 | /@jridgewell/trace-mapping@0.3.9: 333 | resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} 334 | dependencies: 335 | '@jridgewell/resolve-uri': 3.1.2 336 | '@jridgewell/sourcemap-codec': 1.5.0 337 | dev: false 338 | 339 | /@octokit/auth-oauth-app@8.1.1: 340 | resolution: {integrity: sha512-5UtmxXAvU2wfcHIPPDWzVSAWXVJzG3NWsxb7zCFplCWEmMCArSZV0UQu5jw5goLQXbFyOr5onzEH37UJB3zQQg==} 341 | engines: {node: '>= 18'} 342 | dependencies: 343 | '@octokit/auth-oauth-device': 7.1.1 344 | '@octokit/auth-oauth-user': 5.1.1 345 | '@octokit/request': 9.1.3 346 | '@octokit/types': 13.6.1 347 | universal-user-agent: 7.0.2 348 | dev: false 349 | 350 | /@octokit/auth-oauth-device@7.1.1: 351 | resolution: {integrity: sha512-HWl8lYueHonuyjrKKIup/1tiy0xcmQCdq5ikvMO1YwkNNkxb6DXfrPjrMYItNLyCP/o2H87WuijuE+SlBTT8eg==} 352 | engines: {node: '>= 18'} 353 | dependencies: 354 | '@octokit/oauth-methods': 5.1.2 355 | '@octokit/request': 9.1.3 356 | '@octokit/types': 13.6.1 357 | universal-user-agent: 7.0.2 358 | dev: false 359 | 360 | /@octokit/auth-oauth-user@5.1.1: 361 | resolution: {integrity: sha512-rRkMz0ErOppdvEfnemHJXgZ9vTPhBuC6yASeFaB7I2yLMd7QpjfrL1mnvRPlyKo+M6eeLxrKanXJ9Qte29SRsw==} 362 | engines: {node: '>= 18'} 363 | dependencies: 364 | '@octokit/auth-oauth-device': 7.1.1 365 | '@octokit/oauth-methods': 5.1.2 366 | '@octokit/request': 9.1.3 367 | '@octokit/types': 13.6.1 368 | universal-user-agent: 7.0.2 369 | dev: false 370 | 371 | /@octokit/auth-token@5.1.1: 372 | resolution: {integrity: sha512-rh3G3wDO8J9wSjfI436JUKzHIxq8NaiL0tVeB2aXmG6p/9859aUOAjA9pmSPNGGZxfwmaJ9ozOJImuNVJdpvbA==} 373 | engines: {node: '>= 18'} 374 | dev: false 375 | 376 | /@octokit/core@6.1.2: 377 | resolution: {integrity: sha512-hEb7Ma4cGJGEUNOAVmyfdB/3WirWMg5hDuNFVejGEDFqupeOysLc2sG6HJxY2etBp5YQu5Wtxwi020jS9xlUwg==} 378 | engines: {node: '>= 18'} 379 | dependencies: 380 | '@octokit/auth-token': 5.1.1 381 | '@octokit/graphql': 8.1.1 382 | '@octokit/request': 9.1.3 383 | '@octokit/request-error': 6.1.5 384 | '@octokit/types': 13.6.1 385 | before-after-hook: 3.0.2 386 | universal-user-agent: 7.0.2 387 | dev: false 388 | 389 | /@octokit/endpoint@10.1.1: 390 | resolution: {integrity: sha512-JYjh5rMOwXMJyUpj028cu0Gbp7qe/ihxfJMLc8VZBMMqSwLgOxDI1911gV4Enl1QSavAQNJcwmwBF9M0VvLh6Q==} 391 | engines: {node: '>= 18'} 392 | dependencies: 393 | '@octokit/types': 13.6.1 394 | universal-user-agent: 7.0.2 395 | dev: false 396 | 397 | /@octokit/graphql@8.1.1: 398 | resolution: {integrity: sha512-ukiRmuHTi6ebQx/HFRCXKbDlOh/7xEV6QUXaE7MJEKGNAncGI/STSbOkl12qVXZrfZdpXctx5O9X1AIaebiDBg==} 399 | engines: {node: '>= 18'} 400 | dependencies: 401 | '@octokit/request': 9.1.3 402 | '@octokit/types': 13.6.1 403 | universal-user-agent: 7.0.2 404 | dev: false 405 | 406 | /@octokit/oauth-authorization-url@7.1.1: 407 | resolution: {integrity: sha512-ooXV8GBSabSWyhLUowlMIVd9l1s2nsOGQdlP2SQ4LnkEsGXzeCvbSbCPdZThXhEFzleGPwbapT0Sb+YhXRyjCA==} 408 | engines: {node: '>= 18'} 409 | dev: false 410 | 411 | /@octokit/oauth-methods@5.1.2: 412 | resolution: {integrity: sha512-C5lglRD+sBlbrhCUTxgJAFjWgJlmTx5bQ7Ch0+2uqRjYv7Cfb5xpX4WuSC9UgQna3sqRGBL9EImX9PvTpMaQ7g==} 413 | engines: {node: '>= 18'} 414 | dependencies: 415 | '@octokit/oauth-authorization-url': 7.1.1 416 | '@octokit/request': 9.1.3 417 | '@octokit/request-error': 6.1.5 418 | '@octokit/types': 13.6.1 419 | dev: false 420 | 421 | /@octokit/openapi-types@22.2.0: 422 | resolution: {integrity: sha512-QBhVjcUa9W7Wwhm6DBFu6ZZ+1/t/oYxqc2tp81Pi41YNuJinbFRx8B133qVOrAaBbF7D/m0Et6f9/pZt9Rc+tg==} 423 | dev: false 424 | 425 | /@octokit/plugin-paginate-rest@11.3.5(@octokit/core@6.1.2): 426 | resolution: {integrity: sha512-cgwIRtKrpwhLoBi0CUNuY83DPGRMaWVjqVI/bGKsLJ4PzyWZNaEmhHroI2xlrVXkk6nFv0IsZpOp+ZWSWUS2AQ==} 427 | engines: {node: '>= 18'} 428 | peerDependencies: 429 | '@octokit/core': '>=6' 430 | dependencies: 431 | '@octokit/core': 6.1.2 432 | '@octokit/types': 13.6.1 433 | dev: false 434 | 435 | /@octokit/plugin-request-log@5.3.1(@octokit/core@6.1.2): 436 | resolution: {integrity: sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw==} 437 | engines: {node: '>= 18'} 438 | peerDependencies: 439 | '@octokit/core': '>=6' 440 | dependencies: 441 | '@octokit/core': 6.1.2 442 | dev: false 443 | 444 | /@octokit/plugin-rest-endpoint-methods@13.2.6(@octokit/core@6.1.2): 445 | resolution: {integrity: sha512-wMsdyHMjSfKjGINkdGKki06VEkgdEldIGstIEyGX0wbYHGByOwN/KiM+hAAlUwAtPkP3gvXtVQA9L3ITdV2tVw==} 446 | engines: {node: '>= 18'} 447 | peerDependencies: 448 | '@octokit/core': '>=6' 449 | dependencies: 450 | '@octokit/core': 6.1.2 451 | '@octokit/types': 13.6.1 452 | dev: false 453 | 454 | /@octokit/request-error@6.1.5: 455 | resolution: {integrity: sha512-IlBTfGX8Yn/oFPMwSfvugfncK2EwRLjzbrpifNaMY8o/HTEAFqCA1FZxjD9cWvSKBHgrIhc4CSBIzMxiLsbzFQ==} 456 | engines: {node: '>= 18'} 457 | dependencies: 458 | '@octokit/types': 13.6.1 459 | dev: false 460 | 461 | /@octokit/request@9.1.3: 462 | resolution: {integrity: sha512-V+TFhu5fdF3K58rs1pGUJIDH5RZLbZm5BI+MNF+6o/ssFNT4vWlCh/tVpF3NxGtP15HUxTTMUbsG5llAuU2CZA==} 463 | engines: {node: '>= 18'} 464 | dependencies: 465 | '@octokit/endpoint': 10.1.1 466 | '@octokit/request-error': 6.1.5 467 | '@octokit/types': 13.6.1 468 | universal-user-agent: 7.0.2 469 | dev: false 470 | 471 | /@octokit/rest@21.0.2: 472 | resolution: {integrity: sha512-+CiLisCoyWmYicH25y1cDfCrv41kRSvTq6pPWtRroRJzhsCZWZyCqGyI8foJT5LmScADSwRAnr/xo+eewL04wQ==} 473 | engines: {node: '>= 18'} 474 | dependencies: 475 | '@octokit/core': 6.1.2 476 | '@octokit/plugin-paginate-rest': 11.3.5(@octokit/core@6.1.2) 477 | '@octokit/plugin-request-log': 5.3.1(@octokit/core@6.1.2) 478 | '@octokit/plugin-rest-endpoint-methods': 13.2.6(@octokit/core@6.1.2) 479 | dev: false 480 | 481 | /@octokit/types@13.6.1: 482 | resolution: {integrity: sha512-PHZE9Z+kWXb23Ndik8MKPirBPziOc0D2/3KH1P+6jK5nGWe96kadZuE4jev2/Jq7FvIfTlT2Ltg8Fv2x1v0a5g==} 483 | dependencies: 484 | '@octokit/openapi-types': 22.2.0 485 | dev: false 486 | 487 | /@otplib/core@12.0.1: 488 | resolution: {integrity: sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==} 489 | dev: false 490 | 491 | /@otplib/plugin-crypto@12.0.1: 492 | resolution: {integrity: sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==} 493 | dependencies: 494 | '@otplib/core': 12.0.1 495 | dev: false 496 | 497 | /@otplib/plugin-thirty-two@12.0.1: 498 | resolution: {integrity: sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==} 499 | dependencies: 500 | '@otplib/core': 12.0.1 501 | thirty-two: 1.0.2 502 | dev: false 503 | 504 | /@otplib/preset-default@12.0.1: 505 | resolution: {integrity: sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==} 506 | dependencies: 507 | '@otplib/core': 12.0.1 508 | '@otplib/plugin-crypto': 12.0.1 509 | '@otplib/plugin-thirty-two': 12.0.1 510 | dev: false 511 | 512 | /@otplib/preset-v11@12.0.1: 513 | resolution: {integrity: sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==} 514 | dependencies: 515 | '@otplib/core': 12.0.1 516 | '@otplib/plugin-crypto': 12.0.1 517 | '@otplib/plugin-thirty-two': 12.0.1 518 | dev: false 519 | 520 | /@tsndr/cloudflare-worker-jwt@3.1.1: 521 | resolution: {integrity: sha512-+npuhZ2P0/9rLp8ZwzpLGUeBldj50Pg01IprSxS0crxU83ywBlnXFgpvETPlM+BkI0AnhPXxwViKW4QD1uewgg==} 522 | dev: false 523 | 524 | /@types/node-forge@1.3.11: 525 | resolution: {integrity: sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ==} 526 | dependencies: 527 | '@types/node': 22.7.4 528 | dev: false 529 | 530 | /@types/node@22.7.4: 531 | resolution: {integrity: sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==} 532 | dependencies: 533 | undici-types: 6.19.8 534 | dev: false 535 | 536 | /acorn-walk@8.3.4: 537 | resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} 538 | engines: {node: '>=0.4.0'} 539 | dependencies: 540 | acorn: 8.12.1 541 | dev: false 542 | 543 | /acorn@8.12.1: 544 | resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} 545 | engines: {node: '>=0.4.0'} 546 | hasBin: true 547 | dev: false 548 | 549 | /anymatch@3.1.3: 550 | resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} 551 | engines: {node: '>= 8'} 552 | dependencies: 553 | normalize-path: 3.0.0 554 | picomatch: 2.3.1 555 | dev: false 556 | 557 | /as-table@1.0.55: 558 | resolution: {integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==} 559 | dependencies: 560 | printable-characters: 1.0.42 561 | dev: false 562 | 563 | /asynckit@0.4.0: 564 | resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} 565 | dev: false 566 | 567 | /axios@1.7.7: 568 | resolution: {integrity: sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==} 569 | dependencies: 570 | follow-redirects: 1.15.9 571 | form-data: 4.0.0 572 | proxy-from-env: 1.1.0 573 | transitivePeerDependencies: 574 | - debug 575 | dev: false 576 | 577 | /before-after-hook@3.0.2: 578 | resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==} 579 | dev: false 580 | 581 | /binary-extensions@2.3.0: 582 | resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} 583 | engines: {node: '>=8'} 584 | dev: false 585 | 586 | /blake3-wasm@2.1.5: 587 | resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} 588 | dev: false 589 | 590 | /braces@3.0.3: 591 | resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} 592 | engines: {node: '>=8'} 593 | dependencies: 594 | fill-range: 7.1.1 595 | dev: false 596 | 597 | /buffer-equal-constant-time@1.0.1: 598 | resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} 599 | dev: false 600 | 601 | /capnp-ts@0.7.0: 602 | resolution: {integrity: sha512-XKxXAC3HVPv7r674zP0VC3RTXz+/JKhfyw94ljvF80yynK6VkTnqE3jMuN8b3dUVmmc43TjyxjW4KTsmB3c86g==} 603 | dependencies: 604 | debug: 4.3.7 605 | tslib: 2.7.0 606 | transitivePeerDependencies: 607 | - supports-color 608 | dev: false 609 | 610 | /chokidar@3.6.0: 611 | resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} 612 | engines: {node: '>= 8.10.0'} 613 | dependencies: 614 | anymatch: 3.1.3 615 | braces: 3.0.3 616 | glob-parent: 5.1.2 617 | is-binary-path: 2.1.0 618 | is-glob: 4.0.3 619 | normalize-path: 3.0.0 620 | readdirp: 3.6.0 621 | optionalDependencies: 622 | fsevents: 2.3.3 623 | dev: false 624 | 625 | /combined-stream@1.0.8: 626 | resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} 627 | engines: {node: '>= 0.8'} 628 | dependencies: 629 | delayed-stream: 1.0.0 630 | dev: false 631 | 632 | /cookie@0.5.0: 633 | resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} 634 | engines: {node: '>= 0.6'} 635 | dev: false 636 | 637 | /data-uri-to-buffer@2.0.2: 638 | resolution: {integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==} 639 | dev: false 640 | 641 | /debug@4.3.7: 642 | resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} 643 | engines: {node: '>=6.0'} 644 | peerDependencies: 645 | supports-color: '*' 646 | peerDependenciesMeta: 647 | supports-color: 648 | optional: true 649 | dependencies: 650 | ms: 2.1.3 651 | dev: false 652 | 653 | /defu@6.1.4: 654 | resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} 655 | dev: false 656 | 657 | /delayed-stream@1.0.0: 658 | resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} 659 | engines: {node: '>=0.4.0'} 660 | dev: false 661 | 662 | /ecdsa-sig-formatter@1.0.11: 663 | resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} 664 | dependencies: 665 | safe-buffer: 5.2.1 666 | dev: false 667 | 668 | /esbuild@0.17.19: 669 | resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==} 670 | engines: {node: '>=12'} 671 | hasBin: true 672 | requiresBuild: true 673 | optionalDependencies: 674 | '@esbuild/android-arm': 0.17.19 675 | '@esbuild/android-arm64': 0.17.19 676 | '@esbuild/android-x64': 0.17.19 677 | '@esbuild/darwin-arm64': 0.17.19 678 | '@esbuild/darwin-x64': 0.17.19 679 | '@esbuild/freebsd-arm64': 0.17.19 680 | '@esbuild/freebsd-x64': 0.17.19 681 | '@esbuild/linux-arm': 0.17.19 682 | '@esbuild/linux-arm64': 0.17.19 683 | '@esbuild/linux-ia32': 0.17.19 684 | '@esbuild/linux-loong64': 0.17.19 685 | '@esbuild/linux-mips64el': 0.17.19 686 | '@esbuild/linux-ppc64': 0.17.19 687 | '@esbuild/linux-riscv64': 0.17.19 688 | '@esbuild/linux-s390x': 0.17.19 689 | '@esbuild/linux-x64': 0.17.19 690 | '@esbuild/netbsd-x64': 0.17.19 691 | '@esbuild/openbsd-x64': 0.17.19 692 | '@esbuild/sunos-x64': 0.17.19 693 | '@esbuild/win32-arm64': 0.17.19 694 | '@esbuild/win32-ia32': 0.17.19 695 | '@esbuild/win32-x64': 0.17.19 696 | dev: false 697 | 698 | /escape-string-regexp@4.0.0: 699 | resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} 700 | engines: {node: '>=10'} 701 | dev: false 702 | 703 | /estree-walker@0.6.1: 704 | resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} 705 | dev: false 706 | 707 | /exit-hook@2.2.1: 708 | resolution: {integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==} 709 | engines: {node: '>=6'} 710 | dev: false 711 | 712 | /fill-range@7.1.1: 713 | resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} 714 | engines: {node: '>=8'} 715 | dependencies: 716 | to-regex-range: 5.0.1 717 | dev: false 718 | 719 | /follow-redirects@1.15.9: 720 | resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==} 721 | engines: {node: '>=4.0'} 722 | peerDependencies: 723 | debug: '*' 724 | peerDependenciesMeta: 725 | debug: 726 | optional: true 727 | dev: false 728 | 729 | /form-data@4.0.0: 730 | resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} 731 | engines: {node: '>= 6'} 732 | dependencies: 733 | asynckit: 0.4.0 734 | combined-stream: 1.0.8 735 | mime-types: 2.1.35 736 | dev: false 737 | 738 | /fsevents@2.3.3: 739 | resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} 740 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} 741 | os: [darwin] 742 | requiresBuild: true 743 | dev: false 744 | optional: true 745 | 746 | /function-bind@1.1.2: 747 | resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} 748 | dev: false 749 | 750 | /get-source@2.0.12: 751 | resolution: {integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==} 752 | dependencies: 753 | data-uri-to-buffer: 2.0.2 754 | source-map: 0.6.1 755 | dev: false 756 | 757 | /glob-parent@5.1.2: 758 | resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} 759 | engines: {node: '>= 6'} 760 | dependencies: 761 | is-glob: 4.0.3 762 | dev: false 763 | 764 | /glob-to-regexp@0.4.1: 765 | resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} 766 | dev: false 767 | 768 | /hasown@2.0.2: 769 | resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} 770 | engines: {node: '>= 0.4'} 771 | dependencies: 772 | function-bind: 1.1.2 773 | dev: false 774 | 775 | /is-binary-path@2.1.0: 776 | resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} 777 | engines: {node: '>=8'} 778 | dependencies: 779 | binary-extensions: 2.3.0 780 | dev: false 781 | 782 | /is-core-module@2.15.1: 783 | resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} 784 | engines: {node: '>= 0.4'} 785 | dependencies: 786 | hasown: 2.0.2 787 | dev: false 788 | 789 | /is-extglob@2.1.1: 790 | resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} 791 | engines: {node: '>=0.10.0'} 792 | dev: false 793 | 794 | /is-glob@4.0.3: 795 | resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} 796 | engines: {node: '>=0.10.0'} 797 | dependencies: 798 | is-extglob: 2.1.1 799 | dev: false 800 | 801 | /is-number@7.0.0: 802 | resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} 803 | engines: {node: '>=0.12.0'} 804 | dev: false 805 | 806 | /jsonwebtoken@9.0.2: 807 | resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} 808 | engines: {node: '>=12', npm: '>=6'} 809 | dependencies: 810 | jws: 3.2.2 811 | lodash.includes: 4.3.0 812 | lodash.isboolean: 3.0.3 813 | lodash.isinteger: 4.0.4 814 | lodash.isnumber: 3.0.3 815 | lodash.isplainobject: 4.0.6 816 | lodash.isstring: 4.0.1 817 | lodash.once: 4.1.1 818 | ms: 2.1.3 819 | semver: 7.6.3 820 | dev: false 821 | 822 | /jwa@1.4.1: 823 | resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} 824 | dependencies: 825 | buffer-equal-constant-time: 1.0.1 826 | ecdsa-sig-formatter: 1.0.11 827 | safe-buffer: 5.2.1 828 | dev: false 829 | 830 | /jws@3.2.2: 831 | resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} 832 | dependencies: 833 | jwa: 1.4.1 834 | safe-buffer: 5.2.1 835 | dev: false 836 | 837 | /lodash.includes@4.3.0: 838 | resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} 839 | dev: false 840 | 841 | /lodash.isboolean@3.0.3: 842 | resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} 843 | dev: false 844 | 845 | /lodash.isinteger@4.0.4: 846 | resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} 847 | dev: false 848 | 849 | /lodash.isnumber@3.0.3: 850 | resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} 851 | dev: false 852 | 853 | /lodash.isplainobject@4.0.6: 854 | resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} 855 | dev: false 856 | 857 | /lodash.isstring@4.0.1: 858 | resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} 859 | dev: false 860 | 861 | /lodash.once@4.1.1: 862 | resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} 863 | dev: false 864 | 865 | /magic-string@0.25.9: 866 | resolution: {integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==} 867 | dependencies: 868 | sourcemap-codec: 1.4.8 869 | dev: false 870 | 871 | /mime-db@1.52.0: 872 | resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} 873 | engines: {node: '>= 0.6'} 874 | dev: false 875 | 876 | /mime-types@2.1.35: 877 | resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} 878 | engines: {node: '>= 0.6'} 879 | dependencies: 880 | mime-db: 1.52.0 881 | dev: false 882 | 883 | /mime@3.0.0: 884 | resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} 885 | engines: {node: '>=10.0.0'} 886 | hasBin: true 887 | dev: false 888 | 889 | /miniflare@3.20240925.0: 890 | resolution: {integrity: sha512-2LmQbKHf0n6ertUKhT+Iltixi53giqDH7P71+wCir3OnGyXIODqYwOECx1mSDNhYThpxM2dav8UdPn6SQiMoXw==} 891 | engines: {node: '>=16.13'} 892 | hasBin: true 893 | dependencies: 894 | '@cspotcode/source-map-support': 0.8.1 895 | acorn: 8.12.1 896 | acorn-walk: 8.3.4 897 | capnp-ts: 0.7.0 898 | exit-hook: 2.2.1 899 | glob-to-regexp: 0.4.1 900 | stoppable: 1.1.0 901 | undici: 5.28.4 902 | workerd: 1.20240925.0 903 | ws: 8.18.0 904 | youch: 3.3.3 905 | zod: 3.23.8 906 | transitivePeerDependencies: 907 | - bufferutil 908 | - supports-color 909 | - utf-8-validate 910 | dev: false 911 | 912 | /ms@2.1.3: 913 | resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} 914 | dev: false 915 | 916 | /mustache@4.2.0: 917 | resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} 918 | hasBin: true 919 | dev: false 920 | 921 | /nanoid@3.3.7: 922 | resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} 923 | engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} 924 | hasBin: true 925 | dev: false 926 | 927 | /nanoid@5.0.7: 928 | resolution: {integrity: sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==} 929 | engines: {node: ^18 || >=20} 930 | hasBin: true 931 | dev: false 932 | 933 | /node-forge@1.3.1: 934 | resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} 935 | engines: {node: '>= 6.13.0'} 936 | dev: false 937 | 938 | /normalize-path@3.0.0: 939 | resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} 940 | engines: {node: '>=0.10.0'} 941 | dev: false 942 | 943 | /ohash@1.1.4: 944 | resolution: {integrity: sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==} 945 | dev: false 946 | 947 | /otplib@12.0.1: 948 | resolution: {integrity: sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==} 949 | dependencies: 950 | '@otplib/core': 12.0.1 951 | '@otplib/preset-default': 12.0.1 952 | '@otplib/preset-v11': 12.0.1 953 | dev: false 954 | 955 | /path-parse@1.0.7: 956 | resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} 957 | dev: false 958 | 959 | /path-to-regexp@6.3.0: 960 | resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} 961 | dev: false 962 | 963 | /pathe@1.1.2: 964 | resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} 965 | dev: false 966 | 967 | /picomatch@2.3.1: 968 | resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} 969 | engines: {node: '>=8.6'} 970 | dev: false 971 | 972 | /printable-characters@1.0.42: 973 | resolution: {integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==} 974 | dev: false 975 | 976 | /proxy-from-env@1.1.0: 977 | resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} 978 | dev: false 979 | 980 | /readdirp@3.6.0: 981 | resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} 982 | engines: {node: '>=8.10.0'} 983 | dependencies: 984 | picomatch: 2.3.1 985 | dev: false 986 | 987 | /resolve.exports@2.0.2: 988 | resolution: {integrity: sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==} 989 | engines: {node: '>=10'} 990 | dev: false 991 | 992 | /resolve@1.22.8: 993 | resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} 994 | hasBin: true 995 | dependencies: 996 | is-core-module: 2.15.1 997 | path-parse: 1.0.7 998 | supports-preserve-symlinks-flag: 1.0.0 999 | dev: false 1000 | 1001 | /rollup-plugin-inject@3.0.2: 1002 | resolution: {integrity: sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==} 1003 | deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject. 1004 | dependencies: 1005 | estree-walker: 0.6.1 1006 | magic-string: 0.25.9 1007 | rollup-pluginutils: 2.8.2 1008 | dev: false 1009 | 1010 | /rollup-plugin-node-polyfills@0.2.1: 1011 | resolution: {integrity: sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==} 1012 | dependencies: 1013 | rollup-plugin-inject: 3.0.2 1014 | dev: false 1015 | 1016 | /rollup-pluginutils@2.8.2: 1017 | resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==} 1018 | dependencies: 1019 | estree-walker: 0.6.1 1020 | dev: false 1021 | 1022 | /safe-buffer@5.2.1: 1023 | resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} 1024 | dev: false 1025 | 1026 | /selfsigned@2.4.1: 1027 | resolution: {integrity: sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==} 1028 | engines: {node: '>=10'} 1029 | dependencies: 1030 | '@types/node-forge': 1.3.11 1031 | node-forge: 1.3.1 1032 | dev: false 1033 | 1034 | /semver@7.6.3: 1035 | resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} 1036 | engines: {node: '>=10'} 1037 | hasBin: true 1038 | dev: false 1039 | 1040 | /source-map@0.6.1: 1041 | resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} 1042 | engines: {node: '>=0.10.0'} 1043 | dev: false 1044 | 1045 | /sourcemap-codec@1.4.8: 1046 | resolution: {integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==} 1047 | deprecated: Please use @jridgewell/sourcemap-codec instead 1048 | dev: false 1049 | 1050 | /stacktracey@2.1.8: 1051 | resolution: {integrity: sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==} 1052 | dependencies: 1053 | as-table: 1.0.55 1054 | get-source: 2.0.12 1055 | dev: false 1056 | 1057 | /stoppable@1.1.0: 1058 | resolution: {integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==} 1059 | engines: {node: '>=4', npm: '>=6'} 1060 | dev: false 1061 | 1062 | /supports-preserve-symlinks-flag@1.0.0: 1063 | resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} 1064 | engines: {node: '>= 0.4'} 1065 | dev: false 1066 | 1067 | /thirty-two@1.0.2: 1068 | resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==} 1069 | engines: {node: '>=0.2.6'} 1070 | dev: false 1071 | 1072 | /to-regex-range@5.0.1: 1073 | resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} 1074 | engines: {node: '>=8.0'} 1075 | dependencies: 1076 | is-number: 7.0.0 1077 | dev: false 1078 | 1079 | /tslib@2.7.0: 1080 | resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} 1081 | dev: false 1082 | 1083 | /ufo@1.5.4: 1084 | resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} 1085 | dev: false 1086 | 1087 | /undici-types@6.19.8: 1088 | resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} 1089 | dev: false 1090 | 1091 | /undici@5.28.4: 1092 | resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} 1093 | engines: {node: '>=14.0'} 1094 | dependencies: 1095 | '@fastify/busboy': 2.1.1 1096 | dev: false 1097 | 1098 | /unenv-nightly@2.0.0-20240919-125358-9a64854: 1099 | resolution: {integrity: sha512-XjsgUTrTHR7iw+k/SRTNjh6EQgwpC9voygnoCJo5kh4hKqsSDHUW84MhL9EsHTNfLctvVBHaSw8e2k3R2fKXsQ==} 1100 | dependencies: 1101 | defu: 6.1.4 1102 | ohash: 1.1.4 1103 | pathe: 1.1.2 1104 | ufo: 1.5.4 1105 | dev: false 1106 | 1107 | /universal-user-agent@7.0.2: 1108 | resolution: {integrity: sha512-0JCqzSKnStlRRQfCdowvqy3cy0Dvtlb8xecj/H8JFZuCze4rwjPZQOgvFvn0Ws/usCHQFGpyr+pB9adaGwXn4Q==} 1109 | dev: false 1110 | 1111 | /workerd@1.20240925.0: 1112 | resolution: {integrity: sha512-/Jj6+yLwfieZGEt3Kx4+5MoufuC3g/8iFaIh4MPBNGJOGYmdSKXvgCqz09m2+tVCYnysRfbq2zcbVxJRBfOCqQ==} 1113 | engines: {node: '>=16'} 1114 | hasBin: true 1115 | requiresBuild: true 1116 | optionalDependencies: 1117 | '@cloudflare/workerd-darwin-64': 1.20240925.0 1118 | '@cloudflare/workerd-darwin-arm64': 1.20240925.0 1119 | '@cloudflare/workerd-linux-64': 1.20240925.0 1120 | '@cloudflare/workerd-linux-arm64': 1.20240925.0 1121 | '@cloudflare/workerd-windows-64': 1.20240925.0 1122 | dev: false 1123 | 1124 | /wrangler@3.80.0: 1125 | resolution: {integrity: sha512-c9aN7Buf7XgTPpQkw1d0VjNRI4qg3m35PTg70Tg4mOeHwHGjsd74PAsP1G8BjpdqDjfWtsua7tj1g4M3/5dYNQ==} 1126 | engines: {node: '>=16.17.0'} 1127 | hasBin: true 1128 | peerDependencies: 1129 | '@cloudflare/workers-types': ^4.20240925.0 1130 | peerDependenciesMeta: 1131 | '@cloudflare/workers-types': 1132 | optional: true 1133 | dependencies: 1134 | '@cloudflare/kv-asset-handler': 0.3.4 1135 | '@cloudflare/workers-shared': 0.5.4 1136 | '@esbuild-plugins/node-globals-polyfill': 0.2.3(esbuild@0.17.19) 1137 | '@esbuild-plugins/node-modules-polyfill': 0.2.2(esbuild@0.17.19) 1138 | blake3-wasm: 2.1.5 1139 | chokidar: 3.6.0 1140 | esbuild: 0.17.19 1141 | miniflare: 3.20240925.0 1142 | nanoid: 3.3.7 1143 | path-to-regexp: 6.3.0 1144 | resolve: 1.22.8 1145 | resolve.exports: 2.0.2 1146 | selfsigned: 2.4.1 1147 | source-map: 0.6.1 1148 | unenv: /unenv-nightly@2.0.0-20240919-125358-9a64854 1149 | workerd: 1.20240925.0 1150 | xxhash-wasm: 1.0.2 1151 | optionalDependencies: 1152 | fsevents: 2.3.3 1153 | transitivePeerDependencies: 1154 | - bufferutil 1155 | - supports-color 1156 | - utf-8-validate 1157 | dev: false 1158 | 1159 | /ws@8.18.0: 1160 | resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} 1161 | engines: {node: '>=10.0.0'} 1162 | peerDependencies: 1163 | bufferutil: ^4.0.1 1164 | utf-8-validate: '>=5.0.2' 1165 | peerDependenciesMeta: 1166 | bufferutil: 1167 | optional: true 1168 | utf-8-validate: 1169 | optional: true 1170 | dev: false 1171 | 1172 | /xxhash-wasm@1.0.2: 1173 | resolution: {integrity: sha512-ibF0Or+FivM9lNrg+HGJfVX8WJqgo+kCLDc4vx6xMeTce7Aj+DLttKbxxRR/gNLSAelRc1omAPlJ77N/Jem07A==} 1174 | dev: false 1175 | 1176 | /youch@3.3.3: 1177 | resolution: {integrity: sha512-qSFXUk3UZBLfggAW3dJKg0BMblG5biqSF8M34E06o5CSsZtH92u9Hqmj2RzGiHDi64fhe83+4tENFP2DB6t6ZA==} 1178 | dependencies: 1179 | cookie: 0.5.0 1180 | mustache: 4.2.0 1181 | stacktracey: 2.1.8 1182 | dev: false 1183 | 1184 | /zod@3.23.8: 1185 | resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} 1186 | dev: false 1187 | -------------------------------------------------------------------------------- /byCloudflarePages/api/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "totp-manager-api" 2 | main = "index.js" 3 | compatibility_date = "2024-09-23" 4 | compatibility_flags = ["nodejs_compat"] 5 | 6 | kv_namespaces = [ 7 | { binding = "TOTP_STORE", id = "你的kv空间id" }, 8 | { binding = "USER_STORE", id = "用户kv空间id" }, 9 | ] 10 | 11 | 12 | 13 | [vars] 14 | ENVIRONMENT = "production" 15 | GITHUB_CLIENT_ID="你的github应用id" 16 | GITHUB_CLIENT_SECRET="你的github应用密钥" 17 | JWT_SECRET="你的jwt密钥" 18 | ENCRYPTION_KEY = "你的加密密钥" 19 | //部署完后要改成对应的worker域名 20 | GITHUB_REDIRECT_URI = "http://localhost:8080/api/github/callback" 21 | //部署完后要改成对应的pages域名 22 | FRONTEND_URL = "http://localhost:3000" 23 | 24 | [dev] 25 | port = 8080 26 | 27 | [build] 28 | command = "pnpm install" 29 | [[rules]] 30 | type = "ESModule" 31 | globs = ["**/*.js"] 32 | 33 | [build.upload.module_rules] 34 | "bcryptjs" = { type = "npm" } 35 | 36 | [observability] 37 | enabled = true -------------------------------------------------------------------------------- /byCloudflarePages/totp-manager-frontend/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL=http://localhost:8080 -------------------------------------------------------------------------------- /byCloudflarePages/totp-manager-frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['react-app'], 3 | rules: { 4 | // 你可以在这里添加或覆盖规则 5 | } 6 | }; -------------------------------------------------------------------------------- /byCloudflarePages/totp-manager-frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /byCloudflarePages/totp-manager-frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "totp-manager-frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@ant-design/icons": "^5.5.1", 7 | "@ant-design/pro-layout": "^7.20.2", 8 | "@testing-library/jest-dom": "^5.17.0", 9 | "@testing-library/react": "^13.4.0", 10 | "@testing-library/user-event": "^13.5.0", 11 | "antd": "^5.21.2", 12 | "axios": "^1.7.7", 13 | "jsqr": "^1.4.0", 14 | "qrcode.react": "^4.0.1", 15 | "react": "^18.3.1", 16 | "react-dom": "^18.3.1", 17 | "react-responsive": "^10.0.0", 18 | "react-scripts": "5.0.1", 19 | "web-vitals": "^2.1.4" 20 | }, 21 | "scripts": { 22 | "start": "react-scripts start", 23 | "build": "react-scripts build", 24 | "test": "react-scripts test", 25 | "eject": "react-scripts eject" 26 | }, 27 | "eslintConfig": { 28 | "extends": [ 29 | "react-app", 30 | "react-app/jest" 31 | ] 32 | }, 33 | "browserslist": { 34 | "production": [ 35 | ">0.2%", 36 | "not dead", 37 | "not op_mini all" 38 | ], 39 | "development": [ 40 | "last 1 chrome version", 41 | "last 1 firefox version", 42 | "last 1 safari version" 43 | ] 44 | }, 45 | "devDependencies": { 46 | "@typescript-eslint/eslint-plugin": "^5.59.9", 47 | "@typescript-eslint/parser": "^5.59.9", 48 | "eslint": "^8.42.0", 49 | "eslint-config-react-app": "^7.0.1", 50 | "eslint-plugin-react": "^7.32.2" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /byCloudflarePages/totp-manager-frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lonestech/TOTPTokenManager/7fd8c6d4f3503cfe4a5550aea5d7bbbaee5bfa38/byCloudflarePages/totp-manager-frontend/public/favicon.ico -------------------------------------------------------------------------------- /byCloudflarePages/totp-manager-frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /byCloudflarePages/totp-manager-frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lonestech/TOTPTokenManager/7fd8c6d4f3503cfe4a5550aea5d7bbbaee5bfa38/byCloudflarePages/totp-manager-frontend/public/logo192.png -------------------------------------------------------------------------------- /byCloudflarePages/totp-manager-frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lonestech/TOTPTokenManager/7fd8c6d4f3503cfe4a5550aea5d7bbbaee5bfa38/byCloudflarePages/totp-manager-frontend/public/logo512.png -------------------------------------------------------------------------------- /byCloudflarePages/totp-manager-frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /byCloudflarePages/totp-manager-frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /byCloudflarePages/totp-manager-frontend/src/.env: -------------------------------------------------------------------------------- 1 | REACT_APP_API_BASE_URL=http://localhost:8080 2 | REACT_APP_GITHUB_AUTH_URL=http://localhost:8080/api/github/auth -------------------------------------------------------------------------------- /byCloudflarePages/totp-manager-frontend/src/App.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect, useCallback, useMemo} from 'react'; 2 | import { 3 | Layout, 4 | Menu, 5 | Button, 6 | Table, 7 | Input, 8 | Upload, 9 | message, 10 | Modal, 11 | Popconfirm, 12 | Switch, 13 | Radio, 14 | List, 15 | Card, 16 | Typography, 17 | Space, 18 | Empty, 19 | Spin, 20 | Alert, 21 | Drawer 22 | } from 'antd'; 23 | import { 24 | PlusOutlined, 25 | UploadOutlined, 26 | QrcodeOutlined, 27 | ClearOutlined, 28 | SyncOutlined, 29 | DeleteOutlined, 30 | MenuOutlined, 31 | UserOutlined, 32 | LockOutlined 33 | } from '@ant-design/icons'; 34 | import {PageContainer} from '@ant-design/pro-layout'; 35 | import {QRCodeSVG} from 'qrcode.react'; 36 | import jsQR from 'jsqr'; 37 | import 'antd/dist/reset.css'; 38 | import * as api from './services/api'; 39 | import config from './config'; 40 | import Cookies from 'js-cookie'; 41 | 42 | import {useMediaQuery} from 'react-responsive'; 43 | 44 | const {Header, Content, Footer} = Layout; 45 | const {Dragger} = Upload; 46 | const {Text} = Typography; 47 | 48 | const CountdownTimer = React.memo(({onComplete}) => { 49 | const [countdown, setCountdown] = useState(30); 50 | 51 | useEffect(() => { 52 | const timer = setInterval(() => { 53 | setCountdown((prevCount) => { 54 | if (prevCount === 1) { 55 | clearInterval(timer); 56 | onComplete(); 57 | return 30; 58 | } 59 | return prevCount - 1; 60 | }); 61 | }, 1000); 62 | 63 | return () => clearInterval(timer); 64 | }, [onComplete]); 65 | 66 | const radius = 15; 67 | const circumference = 2 * Math.PI * radius; 68 | const dashoffset = circumference * (1 - countdown / 30); 69 | 70 | return ( 71 | 72 | 73 | 84 | 85 | {countdown} 86 | 87 | 88 | ); 89 | }); 90 | 91 | function App() { 92 | const [totps, setTotps] = useState([]); 93 | const [userInfo, setUserInfo] = useState(''); 94 | const [secret, setSecret] = useState(''); 95 | const [qrModalVisible, setQrModalVisible] = useState(false); 96 | const [currentQR, setCurrentQR] = useState(''); 97 | const [tokens, setTokens] = useState({}); 98 | const [syncEnabled, setSyncEnabled] = useState(false); 99 | const [isAuthenticated, setIsAuthenticated] = useState(false); 100 | const [backupMode, setBackupMode] = useState('update'); 101 | const [backupModalVisible, setBackupModalVisible] = useState(false); 102 | const [backupVersions, setBackupVersions] = useState([]); 103 | const [restoreModalVisible, setRestoreModalVisible] = useState(false); 104 | const [isLoadingBackups, setIsLoadingBackups] = useState(false); 105 | const [importStatus, setImportStatus] = useState({loading: false, count: 0}); 106 | const [drawerVisible, setDrawerVisible] = useState(false); 107 | const [username, setUsername] = useState(''); 108 | const [password, setPassword] = useState(''); 109 | const [isRegistering, setIsRegistering] = useState(false); 110 | const [isLoggedIn, setIsLoggedIn] = useState(false); 111 | const handleRegister = useCallback(async () => { 112 | if (!username || !password) { 113 | message.warning('用户名和密码不能为空'); 114 | return; 115 | } 116 | try { 117 | const response = await api.register(username, password); 118 | console.log(response) 119 | if (response.status === 201) { 120 | // 如果注册后后端返回会话令牌,也进行保存 121 | Cookies.set('sessionToken', response.data); 122 | message.success('注册成功'); 123 | setIsRegistering(false); 124 | } else { 125 | throw new Error(response.data.error || '注册失败'); 126 | } 127 | } catch (error) { 128 | console.error('注册失败:', error); 129 | message.error('注册失败: ' + error.message); 130 | } 131 | }, [username, password]); 132 | 133 | const handleLogin = useCallback(async () => { 134 | if (!username || !password) { 135 | message.warning('用户名和密码不能为空'); 136 | return; 137 | } 138 | try { 139 | const response = await api.login(username, password); 140 | console.log(response) 141 | if (response.status === 200) { 142 | // 保存会话令牌 143 | Cookies.set('sessionToken', response.data.token); 144 | message.success('登录成功'); 145 | setIsLoggedIn(true); 146 | } else { 147 | throw new Error(response.data.error || '登录失败'); 148 | } 149 | } catch (error) { 150 | console.error('登录失败:', error); 151 | message.error('登录失败: ' + error.message); 152 | } 153 | }, [username, password]); 154 | const handleLogout = useCallback(async () => { 155 | try { 156 | await api.logout(); 157 | Cookies.remove('sessionToken'); 158 | setIsLoggedIn(false); 159 | setTotps([]); 160 | setSyncEnabled(false); 161 | setUserInfo(''); 162 | setPassword(''); 163 | message.success('已退出登录'); 164 | } catch (error) { 165 | console.error('退出登录失败:', error); 166 | message.error('退出登录失败'); 167 | } 168 | }, []); 169 | 170 | const isDesktopOrLaptop = useMediaQuery({minWidth: 1024}); 171 | 172 | const loadTOTPs = useCallback(async () => { 173 | if (!isLoggedIn) return; 174 | try { 175 | const response = await api.getTOTPs(); 176 | setTotps(response.data); 177 | } catch (error) { 178 | console.error('加载TOTP列表失败:', error); 179 | message.error('加载TOTP列表失败'); 180 | } 181 | }, [isLoggedIn]); 182 | 183 | const checkAuthStatus = useCallback(async () => { 184 | if (!isLoggedIn) return; 185 | try { 186 | const response = await api.getGithubAuthStatus(); 187 | setSyncEnabled(response.data.authenticated); 188 | } catch (error) { 189 | console.error('Failed to check GitHub auth status:', error); 190 | } 191 | }, [isLoggedIn]); 192 | 193 | useEffect(() => { 194 | const sessionToken = Cookies.get('sessionToken'); 195 | if (sessionToken) { 196 | setIsLoggedIn(true); 197 | loadTOTPs(); 198 | checkAuthStatus(); 199 | } 200 | }, [loadTOTPs, checkAuthStatus]); 201 | 202 | const addTOTP = useCallback(async () => { 203 | if (!userInfo || !secret) { 204 | message.warning('用户信息和密钥不能为空'); 205 | return; 206 | } 207 | try { 208 | const processedSecret = secret.replace(/\s+/g, ''); 209 | await api.addTOTP(userInfo, processedSecret); 210 | message.success('TOTP添加成功'); 211 | await loadTOTPs(); 212 | setUserInfo(''); 213 | setSecret(''); 214 | } catch (error) { 215 | console.error('TOTP添加失败:', error); 216 | message.error('TOTP添加失败: ' + (error.response?.data?.message || error.message)); 217 | } 218 | }, [userInfo, secret, loadTOTPs]); 219 | 220 | const deleteTOTP = useCallback(async (id) => { 221 | try { 222 | await api.deleteTOTP(id); 223 | message.success('TOTP删除成功'); 224 | await loadTOTPs(); 225 | } catch (error) { 226 | console.error('TOTP删除失败:', error); 227 | message.error('TOTP删除失败'); 228 | } 229 | }, [loadTOTPs]); 230 | 231 | const generateToken = useCallback(async (id) => { 232 | try { 233 | const response = await api.generateToken(id); 234 | if (response.data.error) { 235 | message.error(response.data.error); 236 | } else { 237 | setTokens(prev => ({...prev, [id]: response.data.token})); 238 | } 239 | } catch (error) { 240 | console.error('令牌生成失败:', error); 241 | message.error('令牌生成失败'); 242 | } 243 | }, []); 244 | 245 | const showQRCode = useCallback(async (record) => { 246 | try { 247 | const response = await api.exportTOTP(record.id); 248 | console.log('Export response:', response.data); 249 | if (response.data && response.data.uri) { 250 | setCurrentQR(response.data.uri); 251 | setQrModalVisible(true); 252 | } else { 253 | throw new Error('Invalid response data: URI not found'); 254 | } 255 | } catch (error) { 256 | console.error('Error generating QR code:', error); 257 | message.error(`Failed to generate QR code: ${error.message}`); 258 | } 259 | }, []); 260 | 261 | const downloadQRCode = () => { 262 | try { 263 | const svg = document.getElementById("qr-code-canvas"); 264 | if (!svg) { 265 | throw new Error("QR code SVG not found"); 266 | } 267 | const svgData = new XMLSerializer().serializeToString(svg); 268 | const canvas = document.createElement("canvas"); 269 | const ctx = canvas.getContext("2d"); 270 | const img = new Image(); 271 | img.onload = () => { 272 | canvas.width = img.width; 273 | canvas.height = img.height; 274 | ctx.drawImage(img, 0, 0); 275 | const pngFile = canvas.toDataURL("image/png"); 276 | const downloadLink = document.createElement("a"); 277 | downloadLink.download = "qrcode.png"; 278 | downloadLink.href = pngFile; 279 | downloadLink.click(); 280 | }; 281 | img.src = "data:image/svg+xml;base64," + btoa(svgData); 282 | message.success("QR code downloaded successfully"); 283 | } catch (error) { 284 | console.error("Error downloading QR code:", error); 285 | message.error(`Failed to download QR code: ${error.message}`); 286 | } 287 | }; 288 | 289 | const handleSyncToggle = (checked) => { 290 | if (checked && !isAuthenticated) { 291 | window.location.href = config.GITHUB_AUTH_URL; 292 | } else { 293 | setSyncEnabled(checked); 294 | } 295 | }; 296 | 297 | const showBackupModal = () => { 298 | setBackupModalVisible(true); 299 | }; 300 | 301 | const handleBackupModalOk = () => { 302 | setBackupModalVisible(false); 303 | uploadToGist(); 304 | }; 305 | 306 | const handleBackupModalCancel = () => { 307 | setBackupModalVisible(false); 308 | }; 309 | 310 | const uploadToGist = async () => { 311 | try { 312 | await api.uploadToGist(backupMode); 313 | message.success('数据成功上传到Gist'); 314 | } catch (error) { 315 | console.error('上传到Gist失败:', error); 316 | message.error('上传到Gist失败'); 317 | } 318 | }; 319 | 320 | const showRestoreModal = async () => { 321 | try { 322 | setIsLoadingBackups(true); 323 | setRestoreModalVisible(true); 324 | setBackupVersions([]); 325 | const response = await api.getGistVersions(); 326 | if (response.data && response.data.length > 0) { 327 | setBackupVersions(response.data); 328 | } else { 329 | console.log('No backup versions found'); 330 | } 331 | } catch (error) { 332 | console.error('获取备份版本失败:', error); 333 | message.error('获取备份版本失败: ' + (error.response?.data?.message || error.message)); 334 | } finally { 335 | setIsLoadingBackups(false); 336 | } 337 | }; 338 | 339 | const clearAllTOTPs = async () => { 340 | try { 341 | await api.clearAllTOTPs(); 342 | message.success('所有TOTP已清除'); 343 | await loadTOTPs(); 344 | setTokens({}); 345 | } catch (error) { 346 | console.error('清除所有TOTP失败:', error); 347 | message.error('清除所有TOTP失败'); 348 | } 349 | }; 350 | 351 | const handleRestoreModalOk = async (gistId) => { 352 | try { 353 | await api.restoreFromGist(gistId); 354 | message.success('数据成功从Gist恢复'); 355 | await loadTOTPs(); 356 | setRestoreModalVisible(false); 357 | } catch (error) { 358 | console.error('从Gist恢复数据失败:', error); 359 | message.error('从Gist恢复数据失败'); 360 | } 361 | }; 362 | 363 | const deleteBackup = async (gistId) => { 364 | try { 365 | console.log('Deleting backup with ID:', gistId); 366 | const response = await api.deleteBackup(gistId); 367 | console.log('Delete response:', response); 368 | 369 | if (response.status === 200) { 370 | message.success('备份已成功删除'); 371 | console.log('Fetching updated TOTP list...'); 372 | await loadTOTPs(); 373 | setRestoreModalVisible(false); 374 | } else { 375 | throw new Error('删除失败'); 376 | } 377 | } catch (error) { 378 | console.error('删除备份失败:', error); 379 | message.error('删除备份失败: ' + (error.response?.data?.message || error.message)); 380 | } 381 | }; 382 | 383 | const formatSecret = useCallback((secret) => { 384 | const cleanSecret = secret.replace(/\s+/g, ''); 385 | return cleanSecret.match(/.{1,4}/g)?.join(' ') || cleanSecret; 386 | }, []); 387 | 388 | const handleQRUpload = async (file) => { 389 | setImportStatus({loading: true, count: 0}); 390 | try { 391 | const dataUrl = await new Promise((resolve, reject) => { 392 | const reader = new FileReader(); 393 | reader.onload = (e) => resolve(e.target.result); 394 | reader.onerror = reject; 395 | reader.readAsDataURL(file); 396 | }); 397 | 398 | const img = await new Promise((resolve, reject) => { 399 | const img = new Image(); 400 | img.onload = () => resolve(img); 401 | img.onerror = reject; 402 | img.src = dataUrl; 403 | }); 404 | 405 | const canvas = document.createElement('canvas'); 406 | canvas.width = img.width; 407 | canvas.height = img.height; 408 | const ctx = canvas.getContext('2d'); 409 | ctx.drawImage(img, 0, 0, img.width, img.height); 410 | const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); 411 | const code = jsQR(imageData.data, imageData.width, imageData.height); 412 | 413 | if (code) { 414 | const response = await api.importTOTP(code.data); 415 | if (response.data.success) { 416 | setImportStatus({loading: false, count: response.data.count}); 417 | message.success(`成功导入 ${response.data.count} 个TOTP`); 418 | await loadTOTPs(); 419 | } else { 420 | throw new Error(response.data.error || 'TOTP导入失败'); 421 | } 422 | } else { 423 | throw new Error('无法识别二维码'); 424 | } 425 | } catch (error) { 426 | console.error('QR上传错误:', error); 427 | message.error(error.message || 'TOTP导入过程中发生错误'); 428 | setImportStatus({loading: false, count: 0}); 429 | } 430 | return false; 431 | }; 432 | 433 | const draggerProps = { 434 | name: 'file', 435 | multiple: false, 436 | accept: 'image/*', 437 | beforeUpload: handleQRUpload, 438 | showUploadList: false, 439 | }; 440 | 441 | const columns = useMemo(() => [ 442 | { 443 | title: '序号', 444 | key: 'index', 445 | render: (text, record, index) => index + 1, 446 | width: 80, 447 | }, 448 | { 449 | title: '用户信息', 450 | dataIndex: 'userInfo', 451 | key: 'userInfo', 452 | ellipsis: true, 453 | }, 454 | { 455 | title: '密钥', 456 | dataIndex: 'secret', 457 | key: 'secret', 458 | render: (text) => text && text.length > 0 ? {formatSecret(text)} : '已清空', 459 | ellipsis: true, 460 | }, 461 | { 462 | title: '令牌', 463 | key: 'token', 464 | render: (text, record) => ( 465 | 466 | {tokens[record.id] || '未生成'} 467 | generateToken(record.id)}/> 468 | 469 | ), 470 | }, 471 | { 472 | title: '操作', 473 | key: 'action', 474 | render: (text, record) => ( 475 | 476 | 479 | 482 | 485 | 486 | ), 487 | }, 488 | ], [generateToken, showQRCode, deleteTOTP, tokens, formatSecret]); 489 | 490 | const renderContent = () => ( 491 | 492 | {isLoggedIn ? ( 493 | // 已登录时显示的内容 494 | 495 | 501 |
502 |
509 | 511 | setUserInfo(e.target.value)} 515 | style={{width: isDesktopOrLaptop ? 200 : '100%'}} 516 | /> 517 | setSecret(formatSecret(e.target.value))} 521 | style={{width: isDesktopOrLaptop ? 200 : '100%'}} 522 | /> 523 | 527 | 528 | 530 | 536 | {syncEnabled && ( 537 | <> 538 | 542 | 546 | 547 | )} 548 | 562 | 563 |
564 | 565 |

566 | 567 |

568 |

点击或拖拽二维码图片到此区域以导入 TOTP

569 |
570 | {importStatus.loading && ( 571 |
572 | 573 |
574 | )} 575 | {importStatus.count > 0 && ( 576 |
577 | 582 |
583 | )} 584 | 594 | 595 |
596 | 602 |
603 | 604 | 605 | ) : isRegistering ? ( 606 | // 注册页面内容 607 | 615 | setUsername(e.target.value)} 619 | style={{width: '100%', marginBottom: '12px'}} 620 | prefix={} 621 | /> 622 | setPassword(e.target.value)} 626 | style={{width: '100%', marginBottom: '16px'}} 627 | prefix={} 628 | /> 629 | 630 | 631 | ) : ( 632 | // 登录页面内容 633 | 641 | setUsername(e.target.value)} 645 | style={{width: '100%', marginBottom: '12px'}} 646 | prefix={} 647 | /> 648 | setPassword(e.target.value)} 652 | style={{width: '100%', marginBottom: '16px'}} 653 | prefix={} 654 | /> 655 | 656 | 658 | 659 | )} 660 | 661 | ); 662 | 663 | return ( 664 | 665 | {isDesktopOrLaptop ? ( 666 | <> 667 |
668 |
670 | TOTP Token Manager 671 |
672 | 673 | 主页 674 | 675 |
676 | 677 | {renderContent()} 678 | 679 | 680 | ) : ( 681 | <> 682 |
683 |
684 | TOTP Token Manager 685 |
686 |
688 | 689 | {renderContent()} 690 | 691 | setDrawerVisible(false)} 695 | open={drawerVisible} 696 | > 697 | 698 | }> 699 | TOTP管理 700 | 701 | 702 | 703 | 704 | )} 705 |
706 | TOTP Token Manager ©{new Date().getFullYear()} Created by Lones 707 |
708 | 709 | setQrModalVisible(false)} 713 | footer={[ 714 | , 717 | , 720 | ]} 721 | > 722 | {currentQR ? ( 723 | 730 | ) : ( 731 |

无法生成二维码:未找到有效的 URI

732 | )} 733 |
734 | 735 | 741 | setBackupMode(e.target.value)} value={backupMode}> 742 | 更新现有备份 743 | 创建新备份 744 | 745 | 746 | 747 | setRestoreModalVisible(false)} 751 | footer={null} 752 | > 753 | {isLoadingBackups ? ( 754 |
755 | 756 |
757 | ) : backupVersions.length > 0 ? ( 758 | ( 761 | handleRestoreModalOk(item.id)}>恢复此版本, 764 | deleteBackup(item.id)} 767 | okText="是" 768 | cancelText="否" 769 | > 770 | 771 | 772 | ]} 773 | > 774 | 778 | 779 | )} 780 | /> 781 | ) : ( 782 | 786 | 787 | 788 | )} 789 |
790 |
791 | ); 792 | } 793 | 794 | export default App; 795 | -------------------------------------------------------------------------------- /byCloudflarePages/totp-manager-frontend/src/config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | API_BASE_URL: process.env.REACT_APP_API_BASE_URL || 'http://localhost:8080', 3 | GITHUB_AUTH_URL: process.env.REACT_APP_GITHUB_AUTH_URL || 'http://localhost:8080/api/github/auth', 4 | }; 5 | 6 | export default config; -------------------------------------------------------------------------------- /byCloudflarePages/totp-manager-frontend/src/index.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lonestech/TOTPTokenManager/7fd8c6d4f3503cfe4a5550aea5d7bbbaee5bfa38/byCloudflarePages/totp-manager-frontend/src/index.css -------------------------------------------------------------------------------- /byCloudflarePages/totp-manager-frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | const root = ReactDOM.createRoot(document.getElementById('root')); 7 | root.render( 8 | 9 | 10 | 11 | ); -------------------------------------------------------------------------------- /byCloudflarePages/totp-manager-frontend/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 4 | getCLS(onPerfEntry); 5 | getFID(onPerfEntry); 6 | getFCP(onPerfEntry); 7 | getLCP(onPerfEntry); 8 | getTTFB(onPerfEntry); 9 | }); 10 | } 11 | }; 12 | 13 | export default reportWebVitals; 14 | -------------------------------------------------------------------------------- /byCloudflarePages/totp-manager-frontend/src/services/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import Cookies from 'js-cookie'; 3 | import config from '../config'; 4 | 5 | const api = axios.create({ 6 | baseURL: config.API_BASE_URL, 7 | }); 8 | 9 | api.interceptors.request.use((config) => { 10 | const sessionToken = Cookies.get('sessionToken'); 11 | if (sessionToken) { 12 | config.headers.Authorization = `Bearer ${sessionToken}`; 13 | } 14 | return config; 15 | }); 16 | // 添加注册 API 17 | export const register = (username, password) => api.post('/api/register', { username, password }); 18 | 19 | // 添加登录 API 20 | export const login = (username, password) => api.post('/api/login', { username, password }); 21 | export const logout = () => api.post('/api/logout', {}); 22 | export const getTOTPs = () => api.get('/api/totp'); 23 | export const addTOTP = (userInfo, secret) => api.post('/api/totp', { userInfo, secret }); 24 | export const deleteTOTP = (id) => api.delete(`/api/totp/${id}`); 25 | export const generateToken = (id) => api.get(`/api/totp/${id}/generate`); 26 | export const exportTOTP = (id) => api.get(`/api/totp/${id}/export`); 27 | export const clearAllTOTPs = () => api.post('/api/totp/clear-all'); 28 | export const importTOTP = (qrData) => api.post('/api/totp/import', { qrData }); 29 | export const getGithubAuthStatus = () => api.get('/api/github/auth-status'); 30 | export const uploadToGist = (mode) => api.post('/api/github/upload', { mode }); 31 | export const getGistVersions = () => api.get('/api/github/versions'); 32 | export const restoreFromGist = (gistId) => api.get(`/api/github/restore?id=${gistId}`); 33 | export const deleteBackup = (gistId) => api.delete(`/api/github/delete-backup?id=${gistId}`); 34 | 35 | export default api; -------------------------------------------------------------------------------- /github/auth.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "TOTPTokenManager/storage" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | "os" 10 | "time" 11 | 12 | "TOTPTokenManager/models" 13 | 14 | "github.com/google/go-github/v35/github" 15 | "golang.org/x/oauth2" 16 | ) 17 | 18 | var ( 19 | githubClientID = getEnvOrDefault("GITHUB_CLIENT_ID", "你的github客户端id") 20 | githubClientSecret = getEnvOrDefault("GITHUB_CLIENT_SECRET", "你的客户端密钥") 21 | oauthConfig = &oauth2.Config{ 22 | ClientID: githubClientID, 23 | ClientSecret: githubClientSecret, 24 | Scopes: []string{"gist"}, 25 | Endpoint: oauth2.Endpoint{ 26 | AuthURL: "https://github.com/login/oauth/authorize", 27 | TokenURL: "https://github.com/login/oauth/access_token", 28 | }, 29 | } 30 | ) 31 | 32 | func getEnvOrDefault(key, defaultValue string) string { 33 | value := os.Getenv(key) 34 | if value == "" { 35 | return defaultValue 36 | } 37 | return value 38 | } 39 | func HandleGithubAuth(w http.ResponseWriter, r *http.Request) { 40 | url := oauthConfig.AuthCodeURL("state", oauth2.AccessTypeOffline) 41 | http.Redirect(w, r, url, http.StatusTemporaryRedirect) 42 | } 43 | func CheckAuthStatus(w http.ResponseWriter, r *http.Request) { 44 | token := storage.GetGithubToken() 45 | status := map[string]bool{ 46 | "authenticated": token != "", 47 | } 48 | w.Header().Set("Content-Type", "application/json") 49 | json.NewEncoder(w).Encode(status) 50 | } 51 | func HandleGithubCallback(w http.ResponseWriter, r *http.Request) { 52 | code := r.URL.Query().Get("code") 53 | token, err := oauthConfig.Exchange(context.Background(), code) 54 | if err != nil { 55 | http.Error(w, "Failed to exchange token", http.StatusInternalServerError) 56 | return 57 | } 58 | 59 | storage.SaveGithubToken(token.AccessToken) 60 | 61 | http.Redirect(w, r, "/", http.StatusTemporaryRedirect) 62 | } 63 | 64 | func UploadToGist(w http.ResponseWriter, r *http.Request) { 65 | token := storage.GetGithubToken() 66 | if token == "" { 67 | http.Error(w, "Not authenticated", http.StatusUnauthorized) 68 | return 69 | } 70 | 71 | var requestBody struct { 72 | Mode string `json:"mode"` 73 | } 74 | if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { 75 | http.Error(w, "Invalid request body", http.StatusBadRequest) 76 | return 77 | } 78 | 79 | ctx := context.Background() 80 | ts := oauth2.StaticTokenSource( 81 | &oauth2.Token{AccessToken: token}, 82 | ) 83 | tc := oauth2.NewClient(ctx, ts) 84 | client := github.NewClient(tc) 85 | 86 | totps := storage.GetAllTOTPs() 87 | content, _ := json.Marshal(totps) 88 | 89 | gist, err := findOrCreateBackupGist(ctx, client, requestBody.Mode == "create") 90 | if err != nil { 91 | http.Error(w, fmt.Sprintf("Failed to find or create backup gist: %v", err), http.StatusInternalServerError) 92 | return 93 | } 94 | 95 | gist.Files["totp_secret_backup.json"] = github.GistFile{ 96 | Content: github.String(string(content)), 97 | } 98 | 99 | _, _, err = client.Gists.Edit(ctx, *gist.ID, gist) 100 | if err != nil { 101 | http.Error(w, "Failed to update gist", http.StatusInternalServerError) 102 | return 103 | } 104 | 105 | fmt.Fprintf(w, "Successfully uploaded to gist") 106 | } 107 | 108 | func RestoreFromGist(w http.ResponseWriter, r *http.Request) { 109 | token := storage.GetGithubToken() 110 | if token == "" { 111 | http.Error(w, "Not authenticated", http.StatusUnauthorized) 112 | return 113 | } 114 | 115 | ctx := context.Background() 116 | ts := oauth2.StaticTokenSource( 117 | &oauth2.Token{AccessToken: token}, 118 | ) 119 | tc := oauth2.NewClient(ctx, ts) 120 | client := github.NewClient(tc) 121 | 122 | gistID := r.URL.Query().Get("id") 123 | if gistID == "" { 124 | http.Error(w, "Gist ID is required", http.StatusBadRequest) 125 | return 126 | } 127 | 128 | gist, _, err := client.Gists.Get(ctx, gistID) 129 | if err != nil { 130 | http.Error(w, "Failed to get gist", http.StatusInternalServerError) 131 | return 132 | } 133 | 134 | content := *gist.Files["totp_secret_backup.json"].Content 135 | var totps []models.TOTP 136 | err = json.Unmarshal([]byte(content), &totps) 137 | if err != nil { 138 | http.Error(w, "Failed to parse gist content", http.StatusInternalServerError) 139 | return 140 | } 141 | 142 | storage.MergeTOTPs(totps) 143 | 144 | fmt.Fprintf(w, "Successfully restored from gist") 145 | } 146 | 147 | func ListBackupVersions(w http.ResponseWriter, r *http.Request) { 148 | token := storage.GetGithubToken() 149 | if token == "" { 150 | http.Error(w, "Not authenticated", http.StatusUnauthorized) 151 | return 152 | } 153 | 154 | ctx := context.Background() 155 | ts := oauth2.StaticTokenSource( 156 | &oauth2.Token{AccessToken: token}, 157 | ) 158 | tc := oauth2.NewClient(ctx, ts) 159 | client := github.NewClient(tc) 160 | 161 | gists, _, err := client.Gists.List(ctx, "", nil) 162 | if err != nil { 163 | http.Error(w, fmt.Sprintf("Failed to list gists: %v", err), http.StatusInternalServerError) 164 | return 165 | } 166 | 167 | var backupVersions []map[string]string 168 | for _, gist := range gists { 169 | if _, ok := gist.Files["totp_secret_backup.json"]; ok { 170 | backupVersions = append(backupVersions, map[string]string{ 171 | "id": *gist.ID, 172 | "description": *gist.Description, 173 | "created_at": gist.CreatedAt.Format(time.RFC3339), 174 | "updated_at": gist.UpdatedAt.Format(time.RFC3339), 175 | }) 176 | } 177 | } 178 | 179 | json.NewEncoder(w).Encode(backupVersions) 180 | } 181 | 182 | func findOrCreateBackupGist(ctx context.Context, client *github.Client, createNew bool) (*github.Gist, error) { 183 | if !createNew { 184 | gists, _, err := client.Gists.List(ctx, "", nil) 185 | if err != nil { 186 | return nil, fmt.Errorf("failed to list gists: %v", err) 187 | } 188 | 189 | for _, gist := range gists { 190 | if _, ok := gist.Files["totp_secret_backup.json"]; ok { 191 | return gist, nil 192 | } 193 | } 194 | } 195 | 196 | newGist := &github.Gist{ 197 | Description: github.String("TOTP Backup"), 198 | Public: github.Bool(false), 199 | Files: map[github.GistFilename]github.GistFile{ 200 | "totp_secret_backup.json": { 201 | Content: github.String("{}"), 202 | }, 203 | }, 204 | } 205 | 206 | createdGist, _, err := client.Gists.Create(ctx, newGist) 207 | if err != nil { 208 | return nil, fmt.Errorf("failed to create new gist: %v", err) 209 | } 210 | 211 | return createdGist, nil 212 | } 213 | func DeleteBackupGist(w http.ResponseWriter, r *http.Request) { 214 | token := storage.GetGithubToken() 215 | if token == "" { 216 | http.Error(w, "Not authenticated", http.StatusUnauthorized) 217 | return 218 | } 219 | 220 | gistID := r.URL.Query().Get("id") 221 | if gistID == "" { 222 | http.Error(w, "Gist ID is required", http.StatusBadRequest) 223 | return 224 | } 225 | 226 | ctx := context.Background() 227 | ts := oauth2.StaticTokenSource( 228 | &oauth2.Token{AccessToken: token}, 229 | ) 230 | tc := oauth2.NewClient(ctx, ts) 231 | client := github.NewClient(tc) 232 | 233 | // 获取Gist详情 234 | gist, _, err := client.Gists.Get(ctx, gistID) 235 | if err != nil { 236 | http.Error(w, fmt.Sprintf("Failed to get gist: %v", err), http.StatusInternalServerError) 237 | return 238 | } 239 | 240 | // 验证Gist是否属于当前用户 241 | user, _, err := client.Users.Get(ctx, "") 242 | if err != nil { 243 | http.Error(w, fmt.Sprintf("Failed to get user info: %v", err), http.StatusInternalServerError) 244 | return 245 | } 246 | if *gist.Owner.Login != *user.Login { 247 | http.Error(w, "You don't have permission to delete this gist", http.StatusForbidden) 248 | return 249 | } 250 | 251 | // 验证Gist的内容是否为TOTP备份 252 | file, ok := gist.Files["totp_secret_backup.json"] 253 | if !ok { 254 | http.Error(w, "This gist is not a TOTP backup", http.StatusBadRequest) 255 | return 256 | } 257 | 258 | // 可以进一步验证文件内容,例如: 259 | var totps []models.TOTP 260 | err = json.Unmarshal([]byte(*file.Content), &totps) 261 | if err != nil { 262 | http.Error(w, "Invalid TOTP backup content", http.StatusBadRequest) 263 | return 264 | } 265 | 266 | // 删除Gist 267 | _, err = client.Gists.Delete(ctx, gistID) 268 | if err != nil { 269 | http.Error(w, fmt.Sprintf("Failed to delete gist: %v", err), http.StatusInternalServerError) 270 | return 271 | } 272 | 273 | w.Header().Set("Content-Type", "application/json") 274 | w.WriteHeader(http.StatusOK) 275 | json.NewEncoder(w).Encode(map[string]string{"message": "Successfully deleted gist"}) 276 | } 277 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module TOTPTokenManager 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/google/go-github/v35 v35.3.0 7 | github.com/google/uuid v1.6.0 8 | github.com/gorilla/mux v1.8.1 9 | github.com/pquerna/otp v1.4.0 10 | golang.org/x/oauth2 v0.23.0 11 | ) 12 | 13 | require ( 14 | github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect 15 | github.com/google/go-querystring v1.0.0 // indirect 16 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI= 2 | github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= 3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 6 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 7 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 8 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 9 | github.com/google/go-github/v35 v35.3.0 h1:fU+WBzuukn0VssbayTT+Zo3/ESKX9JYWjbZTLOTEyho= 10 | github.com/google/go-github/v35 v35.3.0/go.mod h1:yWB7uCcVWaUbUP74Aq3whuMySRMatyRmq5U9FTNlbio= 11 | github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= 12 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 13 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 14 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 15 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 16 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 17 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 18 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 19 | github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg= 20 | github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= 21 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 22 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 23 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 24 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90PveolxSbWFaJdECFbxSq0Mqo2M= 25 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 26 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 27 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 28 | golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs= 29 | golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 30 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 31 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 32 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 33 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 34 | -------------------------------------------------------------------------------- /handlers/handlers.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "TOTPTokenManager/models" 5 | "TOTPTokenManager/storage" 6 | "encoding/base64" 7 | "encoding/json" 8 | "fmt" 9 | "net/http" 10 | "net/url" 11 | "strings" 12 | "time" 13 | 14 | "github.com/google/uuid" 15 | "github.com/gorilla/mux" 16 | ) 17 | 18 | func HomePage(w http.ResponseWriter, r *http.Request) { 19 | http.ServeFile(w, r, "static/index.html") 20 | } 21 | 22 | func AddTOTP(w http.ResponseWriter, r *http.Request) { 23 | var totp models.TOTP 24 | err := json.NewDecoder(r.Body).Decode(&totp) 25 | if err != nil { 26 | http.Error(w, err.Error(), http.StatusBadRequest) 27 | return 28 | } 29 | totp.ID = uuid.New().String() 30 | totp.Created = time.Now() 31 | totp.Secret = strings.ReplaceAll(totp.Secret, " ", "") // 移除密钥中的所有空格 32 | storage.AddTOTP(totp) 33 | json.NewEncoder(w).Encode(totp) 34 | } 35 | 36 | func GetAllTOTPs(w http.ResponseWriter, r *http.Request) { 37 | totps := storage.GetAllTOTPs() 38 | json.NewEncoder(w).Encode(totps) 39 | } 40 | 41 | func DeleteTOTP(w http.ResponseWriter, r *http.Request) { 42 | vars := mux.Vars(r) 43 | id := vars["id"] 44 | storage.DeleteTOTP(id) 45 | w.WriteHeader(http.StatusOK) 46 | } 47 | 48 | func GenerateToken(w http.ResponseWriter, r *http.Request) { 49 | vars := mux.Vars(r) 50 | id := vars["id"] 51 | totp, err := storage.GetTOTP(id) 52 | if err != nil { 53 | http.Error(w, "TOTP not found", http.StatusNotFound) 54 | return 55 | } 56 | if totp.Secret == "" { 57 | json.NewEncoder(w).Encode(map[string]string{"error": "密钥已被清空,无法生成令牌"}) 58 | return 59 | } 60 | token, err := totp.GenerateToken() 61 | if err != nil { 62 | http.Error(w, err.Error(), http.StatusInternalServerError) 63 | return 64 | } 65 | json.NewEncoder(w).Encode(map[string]string{"token": token}) 66 | } 67 | 68 | func ExportTOTP(w http.ResponseWriter, r *http.Request) { 69 | vars := mux.Vars(r) 70 | id := vars["id"] 71 | totp, err := storage.GetTOTP(id) 72 | if err != nil { 73 | http.Error(w, "TOTP not found", http.StatusNotFound) 74 | return 75 | } 76 | 77 | uri := fmt.Sprintf("otpauth://totp/%s?secret=%s&issuer=TOTPTokenManager", 78 | url.QueryEscape(totp.UserInfo), totp.Secret) 79 | 80 | w.Header().Set("Content-Type", "application/json") 81 | json.NewEncoder(w).Encode(map[string]string{"uri": uri}) 82 | } 83 | 84 | func ClearAllTOTPs(w http.ResponseWriter, r *http.Request) { 85 | storage.ClearAllTOTPs() 86 | w.WriteHeader(http.StatusOK) 87 | } 88 | func ImportTOTP(w http.ResponseWriter, r *http.Request) { 89 | 90 | var request struct { 91 | QRData string `json:"qrData"` 92 | } 93 | err := json.NewDecoder(r.Body).Decode(&request) 94 | if err != nil { 95 | fmt.Printf("Error decoding request body: %v\n", err) 96 | http.Error(w, err.Error(), http.StatusBadRequest) 97 | return 98 | } 99 | 100 | totps, err := models.ParseQRData(request.QRData) 101 | if err != nil { 102 | fmt.Printf("Error parsing QR data: %v\n", err) 103 | data := strings.TrimPrefix(request.QRData, "otpauth-migration://offline?data=") 104 | decodedData, _ := url.QueryUnescape(data) 105 | rawData, _ := base64.StdEncoding.DecodeString(decodedData) 106 | fmt.Printf("Raw decoded data: %v\n", rawData) 107 | for i, b := range rawData { 108 | fmt.Printf("Byte %d: %d (0x%02x)\n", i, b, b) 109 | } 110 | json.NewEncoder(w).Encode(map[string]interface{}{ 111 | "success": false, 112 | "error": err.Error(), 113 | }) 114 | return 115 | } 116 | 117 | for i, totp := range totps { 118 | totp.ID = uuid.New().String() 119 | totp.Created = time.Now() 120 | storage.AddTOTP(totp) 121 | fmt.Printf("Added TOTP %d: UserInfo=%s, Secret=%s\n", i+1, totp.UserInfo, totp.Secret) 122 | } 123 | 124 | json.NewEncoder(w).Encode(map[string]interface{}{ 125 | "success": true, 126 | "count": len(totps), 127 | }) 128 | } 129 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "TOTPTokenManager/github" 5 | "TOTPTokenManager/handlers" 6 | "github.com/gorilla/mux" 7 | "log" 8 | "net/http" 9 | ) 10 | 11 | func main() { 12 | r := mux.NewRouter() 13 | 14 | // 静态文件服务 15 | r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.Dir("static")))) 16 | 17 | // 添加 favicon.ico 路由 18 | r.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { 19 | http.ServeFile(w, r, "static/favicon.ico") 20 | }) 21 | 22 | // API路由 23 | r.HandleFunc("/api/totp", handlers.AddTOTP).Methods("POST") 24 | r.HandleFunc("/api/totp", handlers.GetAllTOTPs).Methods("GET") 25 | r.HandleFunc("/api/totp/{id}", handlers.DeleteTOTP).Methods("DELETE") 26 | r.HandleFunc("/api/totp/{id}/generate", handlers.GenerateToken).Methods("GET") 27 | r.HandleFunc("/api/totp/{id}/export", handlers.ExportTOTP).Methods("GET") 28 | r.HandleFunc("/api/totp/clear-all", handlers.ClearAllTOTPs).Methods("POST") 29 | r.HandleFunc("/api/totp/import", handlers.ImportTOTP).Methods("POST") 30 | 31 | // GitHub相关路由 32 | r.HandleFunc("/api/github/auth", github.HandleGithubAuth).Methods("GET") 33 | r.HandleFunc("/api/github/auth-status", github.CheckAuthStatus).Methods("GET") 34 | r.HandleFunc("/api/github/callback", github.HandleGithubCallback).Methods("GET") 35 | r.HandleFunc("/api/github/upload", github.UploadToGist).Methods("POST") 36 | r.HandleFunc("/api/github/restore", github.RestoreFromGist).Methods("GET") 37 | r.HandleFunc("/api/github/versions", github.ListBackupVersions).Methods("GET") 38 | r.HandleFunc("/api/github/delete-backup", github.DeleteBackupGist).Methods("DELETE") 39 | 40 | // 主页 41 | r.HandleFunc("/", handlers.HomePage) 42 | 43 | log.Fatal(http.ListenAndServe(":8080", r)) 44 | } 45 | -------------------------------------------------------------------------------- /models/totp.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/base32" 5 | "encoding/base64" 6 | "encoding/binary" 7 | "fmt" 8 | "github.com/pquerna/otp/totp" 9 | "net/url" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | type TOTP struct { 15 | ID string `json:"id"` 16 | UserInfo string `json:"userInfo"` 17 | Secret string `json:"secret"` 18 | Created time.Time `json:"created"` 19 | } 20 | 21 | func (t *TOTP) GenerateToken() (string, error) { 22 | return totp.GenerateCode(t.Secret, time.Now()) 23 | } 24 | 25 | func (t *TOTP) ValidateToken(token string) bool { 26 | return totp.Validate(token, t.Secret) 27 | } 28 | 29 | func ParseQRData(qrData string) ([]TOTP, error) { 30 | if strings.HasPrefix(qrData, "otpauth-migration://offline?data=") { 31 | return parseGoogleAuthenticatorExport(qrData) 32 | } 33 | 34 | totp, err := ParseTOTPUri(qrData) 35 | if err != nil { 36 | return nil, err 37 | } 38 | return []TOTP{totp}, nil 39 | } 40 | 41 | func ParseTOTPUri(uri string) (TOTP, error) { 42 | u, err := url.Parse(uri) 43 | if err != nil { 44 | return TOTP{}, err 45 | } 46 | 47 | if u.Scheme != "otpauth" || u.Host != "totp" { 48 | return TOTP{}, fmt.Errorf("无效的TOTP URI") 49 | } 50 | 51 | query := u.Query() 52 | secret := query.Get("secret") 53 | if secret == "" { 54 | return TOTP{}, fmt.Errorf("TOTP URI中缺少secret") 55 | } 56 | 57 | issuer := query.Get("issuer") 58 | userInfo := strings.TrimPrefix(u.Path, "/") 59 | 60 | // 如果有 issuer,将其添加到 userInfo 中 61 | if issuer != "" { 62 | userInfo = fmt.Sprintf("%s (%s)", userInfo, issuer) 63 | } 64 | 65 | return TOTP{ 66 | UserInfo: userInfo, 67 | Secret: secret, 68 | }, nil 69 | } 70 | 71 | func parseGoogleAuthenticatorExport(qrData string) ([]TOTP, error) { 72 | data := strings.TrimPrefix(qrData, "otpauth-migration://offline?data=") 73 | 74 | decodedData, err := url.QueryUnescape(data) 75 | if err != nil { 76 | return nil, fmt.Errorf("failed to URL decode data: %v", err) 77 | } 78 | 79 | rawData, err := base64.StdEncoding.DecodeString(decodedData) 80 | if err != nil { 81 | return nil, fmt.Errorf("failed to decode base64 data: %v", err) 82 | } 83 | 84 | var totps []TOTP 85 | index := 0 86 | 87 | for index < len(rawData) { 88 | if index+1 >= len(rawData) { 89 | break 90 | } 91 | 92 | fieldNumber := int(rawData[index] >> 3) 93 | wireType := int(rawData[index] & 0x07) 94 | index++ 95 | 96 | switch wireType { 97 | case 0: // Varint 98 | _, bytesRead := decodeVarint(rawData[index:]) 99 | index += bytesRead 100 | case 1: // 64-bit 101 | if index+8 > len(rawData) { 102 | return nil, fmt.Errorf("insufficient data for 64-bit field") 103 | } 104 | value := binary.LittleEndian.Uint64(rawData[index : index+8]) 105 | index += 8 106 | fmt.Printf("64-bit value: %d\n", value) 107 | case 2: // Length-delimited 108 | length, bytesRead := decodeVarint(rawData[index:]) 109 | index += bytesRead 110 | if index+int(length) > len(rawData) { 111 | return nil, fmt.Errorf("invalid length-delimited field length") 112 | } 113 | fieldData := rawData[index : index+int(length)] 114 | index += int(length) 115 | 116 | if fieldNumber == 1 { 117 | totp, err := parseTOTPEntry(fieldData) 118 | if err != nil { 119 | fmt.Printf("Warning: failed to parse TOTP entry: %v\n", err) 120 | } else { 121 | totps = append(totps, totp) 122 | } 123 | } 124 | case 5: // 32-bit 125 | if index+4 > len(rawData) { 126 | return nil, fmt.Errorf("insufficient data for 32-bit field") 127 | } 128 | value := binary.LittleEndian.Uint32(rawData[index : index+4]) 129 | index += 4 130 | fmt.Printf("32-bit value: %d\n", value) 131 | default: 132 | return nil, fmt.Errorf("unknown wire type: %d", wireType) 133 | } 134 | } 135 | 136 | if len(totps) == 0 { 137 | return nil, fmt.Errorf("no valid TOTP entries found") 138 | } 139 | 140 | return totps, nil 141 | } 142 | 143 | func parseTOTPEntry(data []byte) (TOTP, error) { 144 | var totp TOTP 145 | index := 0 146 | 147 | for index < len(data) { 148 | if index+1 >= len(data) { 149 | break 150 | } 151 | 152 | fieldNumber := int(data[index] >> 3) 153 | wireType := int(data[index] & 0x07) 154 | index++ 155 | 156 | switch wireType { 157 | case 0: // Varint 158 | _, bytesRead := decodeVarint(data[index:]) 159 | index += bytesRead 160 | case 1: // 64-bit 161 | if index+8 > len(data) { 162 | return TOTP{}, fmt.Errorf("insufficient data for 64-bit field") 163 | } 164 | value := binary.LittleEndian.Uint64(data[index : index+8]) 165 | index += 8 166 | fmt.Printf("TOTP 64-bit value: %d\n", value) 167 | case 2: // Length-delimited 168 | length, bytesRead := decodeVarint(data[index:]) 169 | index += bytesRead 170 | if index+int(length) > len(data) { 171 | return TOTP{}, fmt.Errorf("invalid length-delimited field length") 172 | } 173 | fieldData := data[index : index+int(length)] 174 | index += int(length) 175 | 176 | switch fieldNumber { 177 | case 1: // Secret 178 | totp.Secret = base32.StdEncoding.EncodeToString(fieldData) 179 | case 2: // Name 180 | totp.UserInfo = string(fieldData) 181 | case 3: // Issuer 182 | issuer := string(fieldData) 183 | if issuer != "" { 184 | totp.UserInfo = fmt.Sprintf("%s (%s)", totp.UserInfo, issuer) 185 | } 186 | } 187 | case 5: // 32-bit 188 | if index+4 > len(data) { 189 | return TOTP{}, fmt.Errorf("insufficient data for 32-bit field") 190 | } 191 | value := binary.LittleEndian.Uint32(data[index : index+4]) 192 | index += 4 193 | fmt.Printf("TOTP 32-bit value: %d\n", value) 194 | default: 195 | return TOTP{}, fmt.Errorf("unknown wire type: %d", wireType) 196 | } 197 | } 198 | 199 | return totp, nil 200 | } 201 | 202 | func decodeVarint(buf []byte) (uint64, int) { 203 | var x uint64 204 | var s uint 205 | for i, b := range buf { 206 | if b < 0x80 { 207 | if i > 9 || i == 9 && b > 1 { 208 | return 0, i + 1 209 | } 210 | return x | uint64(b)< 2 | 3 | 4 | 5 | 6 | TOTP Token Manager 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "TOTPTokenManager/models" 5 | "errors" 6 | "sync" 7 | ) 8 | 9 | var ( 10 | totps = make(map[string]models.TOTP) 11 | mutex = &sync.Mutex{} 12 | githubToken string 13 | ) 14 | 15 | func AddTOTP(totp models.TOTP) { 16 | mutex.Lock() 17 | defer mutex.Unlock() 18 | totps[totp.ID] = totp 19 | } 20 | 21 | // 添加这个新函数 22 | func GetTOTP(id string) (models.TOTP, error) { 23 | mutex.Lock() 24 | defer mutex.Unlock() 25 | if totp, exists := totps[id]; exists { 26 | return totp, nil 27 | } 28 | return models.TOTP{}, errors.New("TOTP not found") 29 | } 30 | func GetAllTOTPs() []models.TOTP { 31 | mutex.Lock() 32 | defer mutex.Unlock() 33 | result := make([]models.TOTP, 0, len(totps)) 34 | for _, totp := range totps { 35 | result = append(result, totp) 36 | } 37 | return result 38 | } 39 | 40 | func DeleteTOTP(id string) { 41 | mutex.Lock() 42 | defer mutex.Unlock() 43 | delete(totps, id) 44 | } 45 | func ClearAllTOTPs() { 46 | mutex.Lock() 47 | defer mutex.Unlock() 48 | totps = make(map[string]models.TOTP) 49 | } 50 | func SaveGithubToken(token string) { 51 | githubToken = token 52 | } 53 | 54 | func GetGithubToken() string { 55 | return githubToken 56 | } 57 | 58 | func MergeTOTPs(newTOTPs []models.TOTP) { 59 | mutex.Lock() 60 | defer mutex.Unlock() 61 | for _, newTOTP := range newTOTPs { 62 | if _, exists := totps[newTOTP.ID]; !exists { 63 | totps[newTOTP.ID] = newTOTP 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /totp-manager-frontend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env", "@babel/preset-react"] 3 | } -------------------------------------------------------------------------------- /totp-manager-frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /totp-manager-frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "totp-manager-frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@ant-design/icons": "^5.5.1", 7 | "@ant-design/pro-layout": "^7.20.2", 8 | "@testing-library/jest-dom": "^5.17.0", 9 | "@testing-library/react": "^13.4.0", 10 | "@testing-library/user-event": "^13.5.0", 11 | "antd": "^5.21.2", 12 | "axios": "^1.7.7", 13 | "jsqr": "^1.4.0", 14 | "qrcode.react": "^4.0.1", 15 | "react": "^18.3.1", 16 | "react-dom": "^18.3.1", 17 | "react-scripts": "5.0.1", 18 | "web-vitals": "^2.1.4" 19 | }, 20 | "scripts": { 21 | "start": "react-scripts start", 22 | "build": "webpack --mode production", 23 | "test": "react-scripts test", 24 | "eject": "react-scripts eject" 25 | }, 26 | "eslintConfig": { 27 | "extends": [ 28 | "react-app", 29 | "react-app/jest" 30 | ] 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | }, 44 | "proxy": "http://localhost:8080", 45 | "devDependencies": { 46 | "@babel/core": "^7.25.7", 47 | "@babel/preset-env": "^7.25.7", 48 | "@babel/preset-react": "^7.25.7", 49 | "babel-loader": "^9.2.1", 50 | "css-loader": "^7.1.2", 51 | "style-loader": "^4.0.0", 52 | "terser-webpack-plugin": "^5.3.10", 53 | "webpack": "^5.95.0", 54 | "webpack-cli": "^5.1.4" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /totp-manager-frontend/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback, useMemo } from 'react'; 2 | import { Layout, Menu, Button, Table, Input, Upload, message, Modal, Popconfirm, Switch, Radio, List, Card, Typography, Space, Empty, Spin, Alert, Drawer } from 'antd'; 3 | import { PlusOutlined, UploadOutlined, QrcodeOutlined, ClearOutlined, SyncOutlined, DeleteOutlined, MenuOutlined } from '@ant-design/icons'; 4 | import { PageContainer } from '@ant-design/pro-layout'; 5 | import { QRCodeSVG } from 'qrcode.react'; 6 | import jsQR from 'jsqr'; 7 | import 'antd/dist/reset.css'; 8 | import * as api from './services/api'; 9 | import config from './config'; 10 | import { useMediaQuery } from 'react-responsive'; 11 | 12 | const { Header, Content, Footer } = Layout; 13 | const { Dragger } = Upload; 14 | const { Text } = Typography; 15 | 16 | const CountdownTimer = React.memo(({ onComplete }) => { 17 | const [countdown, setCountdown] = useState(30); 18 | 19 | useEffect(() => { 20 | const timer = setInterval(() => { 21 | setCountdown((prevCount) => { 22 | if (prevCount === 1) { 23 | clearInterval(timer); 24 | onComplete(); 25 | return 30; 26 | } 27 | return prevCount - 1; 28 | }); 29 | }, 1000); 30 | 31 | return () => clearInterval(timer); 32 | }, [onComplete]); 33 | 34 | const radius = 15; 35 | const circumference = 2 * Math.PI * radius; 36 | const dashoffset = circumference * (1 - countdown / 30); 37 | 38 | return ( 39 | 40 | 41 | 52 | 53 | {countdown} 54 | 55 | 56 | ); 57 | }); 58 | 59 | function App() { 60 | const [totps, setTotps] = useState([]); 61 | const [userInfo, setUserInfo] = useState(''); 62 | const [secret, setSecret] = useState(''); 63 | const [qrModalVisible, setQrModalVisible] = useState(false); 64 | const [currentQR, setCurrentQR] = useState(''); 65 | const [tokens, setTokens] = useState({}); 66 | const [syncEnabled, setSyncEnabled] = useState(false); 67 | const [isAuthenticated, setIsAuthenticated] = useState(false); 68 | const [backupMode, setBackupMode] = useState('update'); 69 | const [backupModalVisible, setBackupModalVisible] = useState(false); 70 | const [backupVersions, setBackupVersions] = useState([]); 71 | const [restoreModalVisible, setRestoreModalVisible] = useState(false); 72 | const [isLoadingBackups, setIsLoadingBackups] = useState(false); 73 | const [importStatus, setImportStatus] = useState({ loading: false, count: 0 }); 74 | const [drawerVisible, setDrawerVisible] = useState(false); 75 | 76 | const isDesktopOrLaptop = useMediaQuery({ minWidth: 1024 }); 77 | 78 | const loadTOTPs = useCallback(async () => { 79 | try { 80 | console.log('开始加载TOTP列表'); 81 | const response = await api.getTOTPs(); 82 | console.log('服务器返回的TOTP列表:', response.data); 83 | setTotps(response.data); 84 | } catch (error) { 85 | console.error('加载TOTP列表失败:', error); 86 | message.error('加载TOTP列表失败'); 87 | } 88 | }, []); 89 | 90 | const checkAuthStatus = useCallback(async () => { 91 | try { 92 | const response = await api.getGithubAuthStatus(); 93 | setIsAuthenticated(response.data.authenticated); 94 | setSyncEnabled(response.data.authenticated); 95 | } catch (error) { 96 | console.error('Failed to check auth status:', error); 97 | } 98 | }, []); 99 | 100 | useEffect(() => { 101 | loadTOTPs(); 102 | checkAuthStatus(); 103 | }, [loadTOTPs, checkAuthStatus]); 104 | 105 | const addTOTP = useCallback(async () => { 106 | if (!userInfo || !secret) { 107 | message.warning('用户信息和密钥不能为空'); 108 | return; 109 | } 110 | try { 111 | const processedSecret = secret.replace(/\s+/g, ''); 112 | await api.addTOTP(userInfo, processedSecret); 113 | message.success('TOTP添加成功'); 114 | await loadTOTPs(); 115 | setUserInfo(''); 116 | setSecret(''); 117 | } catch (error) { 118 | console.error('TOTP添加失败:', error); 119 | message.error('TOTP添加失败: ' + (error.response?.data?.message || error.message)); 120 | } 121 | }, [userInfo, secret, loadTOTPs]); 122 | 123 | const deleteTOTP = useCallback(async (id) => { 124 | try { 125 | await api.deleteTOTP(id); 126 | message.success('TOTP删除成功'); 127 | await loadTOTPs(); 128 | } catch (error) { 129 | console.error('TOTP删除失败:', error); 130 | message.error('TOTP删除失败'); 131 | } 132 | }, [loadTOTPs]); 133 | 134 | const generateToken = useCallback(async (id) => { 135 | try { 136 | const response = await api.generateToken(id); 137 | if (response.data.error) { 138 | message.error(response.data.error); 139 | } else { 140 | setTokens(prev => ({ ...prev, [id]: response.data.token })); 141 | } 142 | } catch (error) { 143 | console.error('令牌生成失败:', error); 144 | message.error('令牌生成失败'); 145 | } 146 | }, []); 147 | 148 | const showQRCode = useCallback(async (record) => { 149 | try { 150 | const response = await api.exportTOTP(record.id); 151 | console.log('Export response:', response.data); 152 | if (response.data && response.data.uri) { 153 | setCurrentQR(response.data.uri); 154 | setQrModalVisible(true); 155 | } else { 156 | throw new Error('Invalid response data: URI not found'); 157 | } 158 | } catch (error) { 159 | console.error('Error generating QR code:', error); 160 | message.error(`Failed to generate QR code: ${error.message}`); 161 | } 162 | }, []); 163 | 164 | const downloadQRCode = () => { 165 | try { 166 | const svg = document.getElementById("qr-code-canvas"); 167 | if (!svg) { 168 | throw new Error("QR code SVG not found"); 169 | } 170 | const svgData = new XMLSerializer().serializeToString(svg); 171 | const canvas = document.createElement("canvas"); 172 | const ctx = canvas.getContext("2d"); 173 | const img = new Image(); 174 | img.onload = () => { 175 | canvas.width = img.width; 176 | canvas.height = img.height; 177 | ctx.drawImage(img, 0, 0); 178 | const pngFile = canvas.toDataURL("image/png"); 179 | const downloadLink = document.createElement("a"); 180 | downloadLink.download = "qrcode.png"; 181 | downloadLink.href = pngFile; 182 | downloadLink.click(); 183 | }; 184 | img.src = "data:image/svg+xml;base64," + btoa(svgData); 185 | message.success("QR code downloaded successfully"); 186 | } catch (error) { 187 | console.error("Error downloading QR code:", error); 188 | message.error(`Failed to download QR code: ${error.message}`); 189 | } 190 | }; 191 | 192 | const handleSyncToggle = (checked) => { 193 | if (checked && !isAuthenticated) { 194 | window.location.href = config.GITHUB_AUTH_URL; 195 | } else { 196 | setSyncEnabled(checked); 197 | } 198 | }; 199 | 200 | const showBackupModal = () => { 201 | setBackupModalVisible(true); 202 | }; 203 | 204 | const handleBackupModalOk = () => { 205 | setBackupModalVisible(false); 206 | uploadToGist(); 207 | }; 208 | 209 | const handleBackupModalCancel = () => { 210 | setBackupModalVisible(false); 211 | }; 212 | 213 | const uploadToGist = async () => { 214 | try { 215 | await api.uploadToGist(backupMode); 216 | message.success('数据成功上传到Gist'); 217 | } catch (error) { 218 | console.error('上传到Gist失败:', error); 219 | message.error('上传到Gist失败'); 220 | } 221 | }; 222 | 223 | const showRestoreModal = async () => { 224 | try { 225 | setIsLoadingBackups(true); 226 | setRestoreModalVisible(true); 227 | setBackupVersions([]); 228 | const response = await api.getGistVersions(); 229 | if (response.data && response.data.length > 0) { 230 | setBackupVersions(response.data); 231 | } else { 232 | console.log('No backup versions found'); 233 | } 234 | } catch (error) { 235 | console.error('获取备份版本失败:', error); 236 | message.error('获取备份版本失败: ' + (error.response?.data?.message || error.message)); 237 | } finally { 238 | setIsLoadingBackups(false); 239 | } 240 | }; 241 | 242 | const clearAllTOTPs = async () => { 243 | try { 244 | await api.clearAllTOTPs(); 245 | message.success('所有TOTP已清除'); 246 | await loadTOTPs(); 247 | setTokens({}); 248 | } catch (error) { 249 | console.error('清除所有TOTP失败:', error); 250 | message.error('清除所有TOTP失败'); 251 | } 252 | }; 253 | 254 | const handleRestoreModalOk = async (gistId) => { 255 | try { 256 | await api.restoreFromGist(gistId); 257 | message.success('数据成功从Gist恢复'); 258 | await loadTOTPs(); 259 | setRestoreModalVisible(false); 260 | } catch (error) { 261 | console.error('从Gist恢复数据失败:', error); 262 | message.error('从Gist恢复数据失败'); 263 | } 264 | }; 265 | 266 | const deleteBackup = async (gistId) => { 267 | try { 268 | console.log('Deleting backup with ID:', gistId); 269 | const response = await api.deleteBackup(gistId); 270 | console.log('Delete response:', response); 271 | 272 | if (response.status === 200) { 273 | message.success('备份已成功删除'); 274 | console.log('Fetching updated TOTP list...'); 275 | await loadTOTPs(); 276 | setRestoreModalVisible(false); 277 | } else { 278 | throw new Error('删除失败'); 279 | } 280 | } catch (error) { 281 | console.error('删除备份失败:', error); 282 | message.error('删除备份失败: ' + (error.response?.data?.message || error.message)); 283 | } 284 | }; 285 | 286 | const formatSecret = useCallback((secret) => { 287 | const cleanSecret = secret.replace(/\s+/g, ''); 288 | return cleanSecret.match(/.{1,4}/g)?.join(' ') || cleanSecret; 289 | }, []); 290 | 291 | const handleQRUpload = async (file) => { 292 | setImportStatus({ loading: true, count: 0 }); 293 | try { 294 | const dataUrl = await new Promise((resolve, reject) => { 295 | const reader = new FileReader(); 296 | reader.onload = (e) => resolve(e.target.result); 297 | reader.onerror = reject; 298 | reader.readAsDataURL(file); 299 | }); 300 | 301 | const img = await new Promise((resolve, reject) => { 302 | const img = new Image(); 303 | img.onload = () => resolve(img); 304 | img.onerror = reject; 305 | img.src = dataUrl; 306 | }); 307 | 308 | const canvas = document.createElement('canvas'); 309 | canvas.width = img.width; 310 | canvas.height = img.height; 311 | const ctx = canvas.getContext('2d'); 312 | ctx.drawImage(img, 0, 0, img.width, img.height); 313 | const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); 314 | const code = jsQR(imageData.data, imageData.width, imageData.height); 315 | 316 | if (code) { 317 | const response = await api.importTOTP(code.data); 318 | if (response.data.success) { 319 | setImportStatus({ loading: false, count: response.data.count }); 320 | message.success(`成功导入 ${response.data.count} 个TOTP`); 321 | await loadTOTPs(); 322 | } else { 323 | throw new Error(response.data.error || 'TOTP导入失败'); 324 | } 325 | } else { 326 | throw new Error('无法识别二维码'); 327 | } 328 | } catch (error) { 329 | console.error('QR上传错误:', error); 330 | message.error(error.message || 'TOTP导入过程中发生错误'); 331 | setImportStatus({ loading: false, count: 0 }); 332 | } 333 | return false; 334 | }; 335 | 336 | const draggerProps = { 337 | name: 'file', 338 | multiple: false, 339 | accept: 'image/*', 340 | beforeUpload: handleQRUpload, 341 | showUploadList: false, 342 | }; 343 | 344 | const columns = useMemo(() => [ 345 | { 346 | title: '序号', 347 | key: 'index', 348 | render: (text, record, index) => index + 1, 349 | width: 80, 350 | }, 351 | { 352 | title: '用户信息', 353 | dataIndex: 'userInfo', 354 | key: 'userInfo', 355 | ellipsis: true, 356 | }, 357 | { 358 | title: '密钥', 359 | dataIndex: 'secret', 360 | key: 'secret', 361 | render: (text) => text && text.length > 0 ? {formatSecret(text)} : '已清空', 362 | ellipsis: true, 363 | }, 364 | { 365 | title: '令牌', 366 | key: 'token', 367 | render: (text, record) => ( 368 | 369 | {tokens[record.id] || '未生成'} 370 | generateToken(record.id)} /> 371 | 372 | ), 373 | }, 374 | { 375 | title: '操作', 376 | key: 'action', 377 | render: (text, record) => ( 378 | 379 | 382 | 385 | 388 | 389 | ), 390 | }, 391 | ], [generateToken, showQRCode, deleteTOTP, tokens, formatSecret]); 392 | 393 | const renderContent = () => ( 394 | 395 | 396 | 397 |
398 | 399 | setUserInfo(e.target.value)} 403 | style={{ width: isDesktopOrLaptop ? 200 : '100%' }} 404 | /> 405 | setSecret(formatSecret(e.target.value))} 409 | style={{ width: isDesktopOrLaptop ? 200 : '100%' }} 410 | /> 411 | 414 | 415 | 416 | 422 | {syncEnabled && ( 423 | <> 424 | 427 | 430 | 431 | )} 432 | 446 | 447 |
448 | 449 |

450 | 451 |

452 |

点击或拖拽二维码图片到此区域以导入TOTP

453 |
454 | {importStatus.loading && ( 455 |
456 | 457 |
458 | )} 459 | {importStatus.count > 0 && ( 460 |
461 | 466 |
467 | )} 468 |
478 | 479 | 480 | 481 | ); 482 | 483 | return ( 484 | 485 | {isDesktopOrLaptop ? ( 486 | <> 487 |
488 |
489 | TOTP Token Manager 490 |
491 | 492 | 主页 493 | 494 |
495 | 496 | {renderContent()} 497 | 498 | 499 | ) : ( 500 | <> 501 |
502 |
503 | TOTP Token Manager 504 |
505 |
507 | 508 | {renderContent()} 509 | 510 | setDrawerVisible(false)} 514 | open={drawerVisible} 515 | > 516 | 517 | }> 518 | TOTP管理 519 | 520 | 521 | 522 | 523 | )} 524 |
525 | TOTP Token Manager ©{new Date().getFullYear()} Created by Lones 526 |
527 | 528 | setQrModalVisible(false)} 532 | footer={[ 533 | , 536 | , 539 | ]} 540 | > 541 | {currentQR ? ( 542 | 549 | ) : ( 550 |

无法生成二维码:未找到有效的 URI

551 | )} 552 |
553 | 554 | 560 | setBackupMode(e.target.value)} value={backupMode}> 561 | 更新现有备份 562 | 创建新备份 563 | 564 | 565 | 566 | setRestoreModalVisible(false)} 570 | footer={null} 571 | > 572 | {isLoadingBackups ? ( 573 |
574 | 575 |
576 | ) : backupVersions.length > 0 ? ( 577 | ( 580 | handleRestoreModalOk(item.id)}>恢复此版本, 583 | deleteBackup(item.id)} 586 | okText="是" 587 | cancelText="否" 588 | > 589 | 590 | 591 | ]} 592 | > 593 | 597 | 598 | )} 599 | /> 600 | ) : ( 601 | 605 | 606 | 607 | )} 608 |
609 |
610 | ); 611 | } 612 | 613 | export default App; 614 | -------------------------------------------------------------------------------- /totp-manager-frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './App'; 4 | 5 | const root = createRoot(document.getElementById('root')); 6 | 7 | root.render( 8 | 9 | 10 | 11 | ); -------------------------------------------------------------------------------- /totp-manager-frontend/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: './src/index.js', 5 | output: { 6 | filename: 'bundle.js', 7 | path: path.resolve(__dirname, '../static'), 8 | }, 9 | module: { 10 | rules: [ 11 | { 12 | test: /\.jsx?$/, 13 | exclude: /node_modules/, 14 | use: { 15 | loader: 'babel-loader', 16 | options: { 17 | presets: ['@babel/preset-env', '@babel/preset-react'] 18 | } 19 | } 20 | }, 21 | { 22 | test: /\.css$/, 23 | use: ['style-loader', 'css-loader'] 24 | } 25 | ] 26 | }, 27 | resolve: { 28 | extensions: ['.js', '.jsx'] 29 | } 30 | }; --------------------------------------------------------------------------------