├── platforms ├── vercel │ └── sosf │ │ ├── .gitignore │ │ ├── package.json │ │ ├── vercel.json │ │ └── api │ │ └── index.ts ├── cloudbase │ └── sosf │ │ ├── package.json │ │ └── index.js └── template.js ├── renovate.json ├── .editorconfig ├── sor ├── graph │ ├── endpoint.d.ts │ └── endpoint.js ├── package.json ├── README.md ├── LICENSE ├── index.js └── index.d.ts ├── package.json ├── dprint.json ├── .github └── workflows │ └── sosf.yml ├── .gitignore ├── LICENSE ├── cloudbaserc.json ├── auth-cli └── auth.js └── README.md /platforms/vercel/sosf/.gitignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [!{node_modules}/**] 4 | end_of_line = lf 5 | charset = utf-8 6 | 7 | [{*.js,*.ts,*.html,*.gql,*.graqhql}] 8 | indent_style = space 9 | indent_size = 2 10 | -------------------------------------------------------------------------------- /sor/graph/endpoint.d.ts: -------------------------------------------------------------------------------- 1 | export function getItem(strs: string, ...parmas: string[]): string 2 | export function listChildren(strs: string, ...parmas: string[]): string 3 | export function listRoot(strs: string, ...parmas: string[]): string 4 | -------------------------------------------------------------------------------- /platforms/vercel/sosf/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sosf-vercel", 3 | "version": "1.0.0", 4 | "description": "sosf entity", 5 | "main": "index.js", 6 | "scripts": {}, 7 | "author": "", 8 | "license": "ISC", 9 | "dependencies": { 10 | "@beetcb/sor": "^1.0.3" 11 | }, 12 | "devDependencies": {} 13 | } -------------------------------------------------------------------------------- /platforms/cloudbase/sosf/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sosf-tcb", 3 | "version": "1.0.0", 4 | "description": "sosf entity", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "@beetcb/sor": "^1.0.1" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /sor/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@beetcb/sor", 3 | "version": "1.0.3", 4 | "description": "SOR makes it easy to access sharepoint & onedrive resource API", 5 | "main": "index.js", 6 | "types": "index.d.ts", 7 | "scripts": {}, 8 | "keywords": [], 9 | "author": "beetcb", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@beetcb/sstore": "^0.1.1", 13 | "dotenv": "^10.0.0", 14 | "node-fetch": "^2.6.1" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /sor/README.md: -------------------------------------------------------------------------------- 1 | ### SOR makes it easy to access sharepoint & onedrive resource API 2 | 3 | Just get started directly with the beautiful DTS prompt 4 | 5 | ### How to authenticate ms graph? 6 | 7 | Plz make sure the following environment variables key is set: 8 | 9 | ```ts 10 | export interface GraphAuthEnv { 11 | [ 12 | (key in 'client_id') 13 | | 'client_secret' 14 | | 'refresh_token' 15 | | 'redirect_uri' 16 | | 'auth_endpoint' 17 | ]: string 18 | } 19 | ``` 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sosf", 3 | "version": "1.0.0", 4 | "description": "serverless onedrive & sharepoint function", 5 | "main": "index.js", 6 | "scripts": { 7 | "auth": "node auth-cli/auth.js", 8 | "fmt": "dprint fmt" 9 | }, 10 | "prettier": { 11 | "semi": false, 12 | "singleQuote": true, 13 | "printWidth": 80 14 | }, 15 | "author": "", 16 | "license": "ISC", 17 | "dependencies": { 18 | "inquirer": "^8.0.0", 19 | "node-fetch": "^2.6.1" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /dprint.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://dprint.dev/schemas/v0.json", 3 | "projectType": "openSource", 4 | "indentWidth": 2, 5 | "lineWidth": 80, 6 | "incremental": true, 7 | "typescript": { 8 | "semiColons": "asi", 9 | "quoteStyle": "preferSingle" 10 | }, 11 | "json": {}, 12 | "markdown": {}, 13 | "includes": ["**/*.{ts,tsx,js,jsx,cjs,mjs,json,md}"], 14 | "excludes": ["**/node_modules", "**/*-lock.json", "**/docs"], 15 | "plugins": [ 16 | "https://plugins.dprint.dev/typescript-0.46.0.wasm", 17 | "https://plugins.dprint.dev/json-0.11.0.wasm", 18 | "https://plugins.dprint.dev/markdown-0.7.1.wasm" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/sosf.yml: -------------------------------------------------------------------------------- 1 | name: sosf 2 | 3 | on: [push] 4 | 5 | jobs: 6 | deploy: 7 | runs-on: ubuntu-latest 8 | name: sosf deploy 9 | env: 10 | dotenv: | 11 | ${{secrets.dotenv}} 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v2 15 | 16 | - name: Create dotenv file 17 | run: echo -e "$dotenv" > platforms/cloudbase/sosf/.env 18 | 19 | - name: Deploy to Tencent CloudBase 20 | uses: TencentCloudBase/cloudbase-action@v2 21 | with: 22 | secretId: ${{secrets.secretId}} 23 | secretKey: ${{secrets.secretKey}} 24 | envId: ${{secrets.envId}} 25 | -------------------------------------------------------------------------------- /platforms/vercel/sosf/vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "functions": { 3 | "api/index.ts": { 4 | "memory": 128, 5 | "maxDuration": 10, 6 | "includeFiles": "/tmp/conf/*" 7 | } 8 | }, 9 | "cleanUrls": true, 10 | "trailingSlash": false, 11 | "headers": [ 12 | { 13 | "source": "/", 14 | "headers": [ 15 | { 16 | "key": "Cache-Control", 17 | "value": "s-maxage=3600" 18 | } 19 | ] 20 | } 21 | ], 22 | "rewrites": [ 23 | { 24 | "source": "/", 25 | "destination": "/api" 26 | } 27 | ], 28 | "build": { 29 | "env": { 30 | "NODEJS_AWS_HANDLER_NAME": "default" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /sor/graph/endpoint.js: -------------------------------------------------------------------------------- 1 | const { join } = require('path') 2 | 3 | const parseStrs = (strs, parmas) => 4 | [ 5 | strs.indexOf('drive'), 6 | strs.indexOf('id'), 7 | strs.indexOf('path'), 8 | strs.indexOf('select'), 9 | ].map((idx) => parmas[idx]) 10 | 11 | exports.getItem = (strs, ...parmas) => { 12 | const [drive, id, path, select] = parseStrs(strs, parmas) 13 | if (id) { 14 | return `${drive}/items/${id}?$select=${select}` 15 | } else { 16 | return `${drive}/root:${join(...path)}?$select=${select}` 17 | } 18 | } 19 | 20 | exports.listChildren = (strs, ...parmas) => { 21 | const [drive, id, path, select] = parseStrs(strs, parmas) 22 | if (id) { 23 | return `${drive}/items/${id}/children` 24 | } else { 25 | return `${drive}/root:${ 26 | join(...path).slice( 27 | 0, 28 | -1, 29 | ) 30 | }:/children?$select=${select}` 31 | } 32 | } 33 | 34 | exports.listRoot = (strs, ...parmas) => { 35 | const [drive, _, __, select] = parseStrs(strs, parmas) 36 | return `${drive}/root/children?$select=${select}` 37 | } 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | 38 | # Optional npm cache directory 39 | .npm 40 | 41 | .env* 42 | 43 | # Optional eslint cache 44 | .eslintcache 45 | 46 | # Optional REPL history 47 | .node_repl_history 48 | 49 | # Output of 'npm pack' 50 | *.zip 51 | 52 | # Yarn Integrity file 53 | .yarn-integrity 54 | 55 | package-lock.json 56 | 57 | .vercel/ 58 | 59 | .leancloud/ -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 beet 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 | -------------------------------------------------------------------------------- /sor/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 beet 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 | -------------------------------------------------------------------------------- /cloudbaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "envId": "{{env.ENV_ID}}", 3 | "version": "2.0", 4 | "$schema": "https://framework-1258016615.tcloudbaseapp.com/schema/latest.json", 5 | "functions": [ 6 | { 7 | "name": "sosf", 8 | "memorySize": 128, 9 | "timeout": 5, 10 | "runtime": "Nodejs10.15", 11 | "installDependency": true, 12 | "handler": "index.main" 13 | } 14 | ], 15 | "framework": { 16 | "name": "sosf", 17 | "plugins": { 18 | "func": { 19 | "use": "@cloudbase/framework-plugin-function", 20 | "inputs": { 21 | "servicePaths": { 22 | "sosf": "/" 23 | }, 24 | "functionRootPath": "./platforms/cloudbase", 25 | "functions": [ 26 | { 27 | "name": "sosf", 28 | "memorySize": 128, 29 | "timeout": 5, 30 | "runtime": "Nodejs10.15", 31 | "installDependency": true, 32 | "envVariables": { 33 | "ENV_ID": "{{env.ENV_ID}}" 34 | }, 35 | "handler": "index.main" 36 | } 37 | ] 38 | } 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /platforms/vercel/sosf/api/index.ts: -------------------------------------------------------------------------------- 1 | import { getItem, getToken, listChildren } from '@beetcb/sor' 2 | 3 | export default async function handler({ 4 | _, 5 | queryStringParameters, 6 | headers, 7 | }) { 8 | const { id, key, type, path = '/' } = queryStringParameters 9 | const { access_key } = process.env 10 | 11 | const isReqFolder = path.endsWith('/') && type !== 'file' 12 | 13 | if (path === '/favicon.ico' || (isReqFolder && access_key != key)) { 14 | return null 15 | } 16 | 17 | const access_token = await getToken() 18 | 19 | if (!access_token) { 20 | return {} 21 | } 22 | 23 | if (isReqFolder && type !== 'file') { 24 | // Render folder 25 | const isReturnJson = type === 'json' 26 | || (headers['content-type'] && headers['content-type'].includes('json')) 27 | 28 | // Render html first 29 | if (!isReturnJson) { 30 | return { 31 | isBase64Encoded: false, 32 | statusCode: 200, 33 | headers: { 34 | 'content-type': 'text/html', 35 | }, 36 | body: ` 37 | 38 | 39 | 40 | 44 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | `, 55 | } 56 | } else { 57 | const data = await listChildren(path, access_token, id, key) 58 | if (data) { 59 | const itemTable = data.value.reduce((arr, ele) => { 60 | arr.push({ 61 | name: `${ele.name}${ele.file ? '' : '/'}`, 62 | params: '?' 63 | + new URLSearchParams( 64 | `${ele.id ? `&id=${ele.id}` : ''}${ 65 | key && !ele.file ? `&key=${key}` : '' 66 | }${ele.file ? '&type=file' : ''}`, 67 | ).toString(), 68 | }) 69 | return arr 70 | }, []) 71 | return { 72 | statusCode: 200, 73 | headers: { 74 | 'content-type': 'application/json', 75 | }, 76 | body: JSON.stringify(itemTable), 77 | } 78 | } 79 | } 80 | } else { 81 | // Render file 82 | const data = await getItem(path, access_token, id) 83 | if (data) { 84 | return { 85 | statusCode: 308, 86 | headers: { Location: data['@microsoft.graph.downloadUrl'].slice(6) }, 87 | } 88 | } else return {} 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /platforms/cloudbase/sosf/index.js: -------------------------------------------------------------------------------- 1 | const { getToken, getItem, listChildren } = require('@beetcb/sor') 2 | 3 | async function handler({ path, queryStringParameters, headers }) { 4 | const { id, key, type } = queryStringParameters 5 | const { access_key } = process.env 6 | const isReqFolder = path.endsWith('/') && type !== 'file' 7 | 8 | if (path === '/favicon.ico' || (isReqFolder && access_key != key)) { 9 | return null 10 | } 11 | 12 | const access_token = await getToken() 13 | 14 | if (!access_token) { 15 | return null 16 | } 17 | 18 | if (isReqFolder && type !== 'file') { 19 | // Render folder 20 | const isReturnJson = type === 'json' 21 | || (headers['content-type'] && headers['content-type'].includes('json')) 22 | 23 | // Render html first 24 | if (!isReturnJson) { 25 | return { 26 | isBase64Encoded: false, 27 | statusCode: 200, 28 | headers: { 29 | 'content-type': 'text/html', 30 | }, 31 | body: ` 32 | 33 | 34 | 35 | 39 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | `, 50 | } 51 | } else { 52 | const data = await listChildren(path, access_token, id, key) 53 | if (data) { 54 | const itemTable = data.value.reduce((arr, ele) => { 55 | arr.push({ 56 | name: `${ele.name}${ele.file ? '' : '/'}`, 57 | params: '?' 58 | + new URLSearchParams( 59 | `${ele.id ? `&id=${ele.id}` : ''}${ 60 | key && !ele.file ? `&key=${key}` : '' 61 | }${ele.file ? '&type=file' : ''}`, 62 | ).toString(), 63 | }) 64 | return arr 65 | }, []) 66 | 67 | return { 68 | isBase64Encoded: false, 69 | statusCode: 200, 70 | headers: { 71 | 'content-type': 'application/json', 72 | }, 73 | body: itemTable, 74 | } 75 | } 76 | } 77 | } else { 78 | // Render file 79 | const data = await getItem(path, access_token, id) 80 | if (data) { 81 | return { 82 | isBase64Encoded: false, 83 | statusCode: 307, 84 | headers: { Location: data['@microsoft.graph.downloadUrl'].slice(6) }, 85 | body: null, 86 | } 87 | } else return 'Resource not found' 88 | } 89 | } 90 | 91 | exports.main = handler 92 | -------------------------------------------------------------------------------- /sor/index.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch') 2 | const sstore = require('@beetcb/sstore') 3 | 4 | const { getItem, listChildren, listRoot } = require('./graph/endpoint') 5 | 6 | require('dotenv').config() 7 | 8 | // Get & Store access_token from/to db 9 | // Using tcb-sstore as fake db 10 | function db(token) { 11 | return token ? sstore.set('token', token) : sstore.get('token') 12 | } 13 | 14 | const checkExpired = (token) => { 15 | const { expires_at } = token 16 | if (timestamp() > expires_at) { 17 | console.log('Token expired') 18 | return true 19 | } 20 | } 21 | 22 | const timestamp = () => (Date.now() / 1000) | 0 23 | 24 | const getFetchOpts = (a_t) => { 25 | const opts = { 26 | headers: { 27 | Authorization: `bearer ${a_t}`, 28 | }, 29 | compress: false, 30 | } 31 | return opts 32 | } 33 | 34 | async function acquireToken() { 35 | const { 36 | client_id, 37 | client_secret, 38 | refresh_token, 39 | redirect_uri, 40 | auth_endpoint, 41 | } = process.env 42 | 43 | try { 44 | console.log(auth_endpoint) 45 | const res = await fetch(`${auth_endpoint}/token`, { 46 | method: 'POST', 47 | body: `${ 48 | new URLSearchParams({ 49 | grant_type: 'refresh_token', 50 | client_id, 51 | client_secret, 52 | refresh_token, 53 | }).toString() 54 | }&redirect_uri=${redirect_uri}`, 55 | headers: { 56 | 'content-type': 'application/x-www-form-urlencoded', 57 | }, 58 | }) 59 | return await storeToken(res) 60 | } catch (e) { 61 | console.warn(e) 62 | } 63 | } 64 | 65 | async function storeToken(res) { 66 | const { expires_in, access_token, refresh_token } = await res.json() 67 | const expires_at = timestamp() + expires_in 68 | const token = { expires_at, access_token, refresh_token } 69 | return db(token) 70 | } 71 | 72 | exports.getToken = async () => { 73 | // Grab access token 74 | let token = db() 75 | if (!token || checkExpired(token)) { 76 | token = await acquireToken() 77 | } else { 78 | console.log('Grab token from sstore!') 79 | } 80 | sstore.close() 81 | return token.access_token 82 | } 83 | 84 | exports.getItem = async (path, access_token, item_id = '') => { 85 | const base_dir = process.env.base_dir || '' 86 | const graph = getItem `drive${process.env.drive_api}id${item_id}path${[ 87 | base_dir, 88 | path, 89 | ]}select${`@microsoft.graph.downloadUrl`}` 90 | 91 | const res = await fetch(graph, getFetchOpts(access_token)) 92 | if (res.ok) { 93 | return await res.json() 94 | } else { 95 | console.error(res.statusText) 96 | return null 97 | } 98 | } 99 | 100 | exports.listChildren = async (path, access_token, item_id = '') => { 101 | const { base_dir } = process.env 102 | const graph = path === '/' && !item_id 103 | ? listRoot `drive${process.env.drive_api}select${`id,name,file`}` 104 | : listChildren `drive${process.env.drive_api}id${item_id}path${[ 105 | base_dir, 106 | path, 107 | ]}select${`id,name,file`}` 108 | 109 | const res = await fetch(graph, getFetchOpts(access_token)) 110 | if (res.ok) { 111 | return await res.json() 112 | } else { 113 | console.error(res.statusText) 114 | return null 115 | } 116 | } 117 | 118 | exports.drive_api = process.env.drive_api 119 | -------------------------------------------------------------------------------- /platforms/template.js: -------------------------------------------------------------------------------- 1 | const { h, html } = gridjs 2 | 3 | function copyText(data) { 4 | navigator.clipboard.writeText(data) 5 | } 6 | 7 | function initDocument() { 8 | document.body.innerHTML = ` 9 | 10 |
11 |

12 | SOSF Index 13 |

14 |

15 | Usage: Type a keyword below to search for your files 16 |

17 |
18 | 26 |
27 | ` 28 | 29 | // render table 30 | const { id, key, type, path } = Object.fromEntries( 31 | new URL(location.href).searchParams 32 | ) 33 | const isNoParams = !(id || key || type || path) 34 | new gridjs.Grid({ 35 | columns: [ 36 | 'Resource', 37 | { 38 | name: 'Link', 39 | hidden: true, 40 | }, 41 | { 42 | name: 'Actions', 43 | formatter: (_, row) => { 44 | const linkData = row.cells[1].data 45 | const isFolder = row.cells[0].data.endsWith('/') 46 | 47 | const folderEle = h( 48 | 'div', 49 | { 50 | className: 'text-indigo-500', 51 | }, 52 | html(feather.icons.folder.toSvg()) 53 | ) 54 | 55 | return isFolder 56 | ? linkData === '' 57 | ? h( 58 | 'div', 59 | { 60 | onClick: () => history.back(), 61 | className: 'cursor-pointer', 62 | }, 63 | folderEle 64 | ) 65 | : h( 66 | 'a', 67 | { 68 | className: 'cursor-pointer', 69 | href: linkData, 70 | }, 71 | folderEle 72 | ) 73 | : h( 74 | 'div', 75 | { 76 | className: 'cursor-pointer', 77 | onClick: () => copyText(linkData), 78 | }, 79 | h('i', {}, html(feather.icons.copy.toSvg())) 80 | ) 81 | }, 82 | }, 83 | ], 84 | 85 | search: true, 86 | server: { 87 | url: encodeURI( 88 | `${location.href}${ 89 | isNoParams ? `?type=json${key ? `&key=${key}` : ''}` : '&type=json' 90 | }` 91 | ), 92 | then: (data) => { 93 | const fromData = data.map(({ name, params }) => { 94 | const item = { 95 | resource: name, 96 | link: `${location.origin}/${params}`, 97 | } 98 | return item 99 | }) 100 | 101 | if (id) { 102 | fromData.unshift({ 103 | resource: '../', 104 | link: '', 105 | }) 106 | } 107 | return fromData 108 | }, 109 | }, 110 | }).render(document.getElementById('wrapper')) 111 | 112 | // center the search box 113 | const searchHead = document.getElementsByClassName('gridjs-head')[0] 114 | searchHead.classList.add('flex', 'justify-center', 'm-4') 115 | } 116 | 117 | initDocument() 118 | -------------------------------------------------------------------------------- /sor/index.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get the token to access graph API 3 | */ 4 | export function getToken(): Promise 5 | /** 6 | * Get a file resource by using getItem API 7 | * @param path 8 | * @param access_token 9 | * @param item_id 10 | */ 11 | export function getItem( 12 | path: string, 13 | access_token: string, 14 | item_id?: string, 15 | ): Promise 16 | /** 17 | * Get a folder resource by using listChildren API 18 | * @param path 19 | * @param access_token 20 | * @param item_id 21 | */ 22 | export function listChildren( 23 | path: string, 24 | access_token: string, 25 | item_id?: string, 26 | key?: string, 27 | ): Promise 28 | 29 | type GraphAuthEnv = { 30 | [ 31 | key in 32 | | 'client_id' 33 | | 'client_secret' 34 | | 'refresh_token' 35 | | 'redirect_uri' 36 | | 'auth_endpoint' 37 | ]: string 38 | } 39 | 40 | export interface ResourceType { 41 | value: Array 42 | audio: Audio 43 | content: Content 44 | cTag: string 45 | deleted: Deleted 46 | description: string 47 | file: File 48 | fileSystemInfo: FileSystemInfo 49 | folder: Folder 50 | image: Image 51 | location: Location 52 | malware: Malware 53 | package: Package 54 | photo: Photo 55 | publication: Publication 56 | remoteItem: RemoteItem 57 | root: Root2 58 | searchResult: SearchResult 59 | shared: Shared 60 | sharepointIds: SharepointIds 61 | size: number 62 | specialFolder: SpecialFolder 63 | video: Video 64 | webDavUrl: string 65 | activities: Activity[] 66 | children: Children[] 67 | permissions: Permission[] 68 | thumbnails: Thumbnail[] 69 | versions: Version[] 70 | id: string 71 | createdBy: CreatedBy 72 | createdDateTime: string 73 | eTag: string 74 | lastModifiedBy: LastModifiedBy 75 | lastModifiedDateTime: string 76 | name: string 77 | parentReference: ParentReference 78 | webUrl: string 79 | '@microsoft.graph.conflictBehavior': string 80 | '@microsoft.graph.downloadUrl': string 81 | '@microsoft.graph.sourceUrl': string 82 | } 83 | 84 | export interface Audio { 85 | '@odata.type': string 86 | } 87 | 88 | export interface Content { 89 | '@odata.type': string 90 | } 91 | 92 | export interface Deleted { 93 | '@odata.type': string 94 | } 95 | 96 | export interface File { 97 | '@odata.type': string 98 | } 99 | 100 | export interface FileSystemInfo { 101 | '@odata.type': string 102 | } 103 | 104 | export interface Folder { 105 | '@odata.type': string 106 | } 107 | 108 | export interface Image { 109 | '@odata.type': string 110 | } 111 | 112 | export interface Location { 113 | '@odata.type': string 114 | } 115 | 116 | export interface Malware { 117 | '@odata.type': string 118 | } 119 | 120 | export interface Package { 121 | '@odata.type': string 122 | } 123 | 124 | export interface Photo { 125 | '@odata.type': string 126 | } 127 | 128 | export interface Publication { 129 | '@odata.type': string 130 | } 131 | 132 | export interface RemoteItem { 133 | '@odata.type': string 134 | } 135 | 136 | export interface Root2 { 137 | '@odata.type': string 138 | } 139 | 140 | export interface SearchResult { 141 | '@odata.type': string 142 | } 143 | 144 | export interface Shared { 145 | '@odata.type': string 146 | } 147 | 148 | export interface SharepointIds { 149 | '@odata.type': string 150 | } 151 | 152 | export interface SpecialFolder { 153 | '@odata.type': string 154 | } 155 | 156 | export interface Video { 157 | '@odata.type': string 158 | } 159 | 160 | export interface Activity { 161 | '@odata.type': string 162 | } 163 | 164 | export interface Children { 165 | '@odata.type': string 166 | } 167 | 168 | export interface Permission { 169 | '@odata.type': string 170 | } 171 | 172 | export interface Thumbnail { 173 | '@odata.type': string 174 | } 175 | 176 | export interface Version { 177 | '@odata.type': string 178 | } 179 | 180 | export interface CreatedBy { 181 | '@odata.type': string 182 | } 183 | 184 | export interface LastModifiedBy { 185 | '@odata.type': string 186 | } 187 | 188 | export interface ParentReference { 189 | '@odata.type': string 190 | } 191 | -------------------------------------------------------------------------------- /auth-cli/auth.js: -------------------------------------------------------------------------------- 1 | const { prompt } = require('inquirer') 2 | const { EOL } = require('os') 3 | const { writeFileSync } = require('fs') 4 | const fetch = require('node-fetch') 5 | const path = require('path') 6 | 7 | const headers = { 8 | 'content-type': 'application/x-www-form-urlencoded', 9 | } 10 | 11 | // Prompt and acquire code, returns credentials 12 | async function init() { 13 | let questions = [ 14 | { 15 | type: 'list', 16 | name: 'account_type', 17 | message: '请选择 onedrive & sharepoint 账户类型', 18 | choices: [ 19 | { 20 | value: 1, 21 | name: 'global', 22 | }, 23 | { 24 | value: 0, 25 | name: 'operated by 21Vianet in China', 26 | }, 27 | ], 28 | }, 29 | { 30 | type: 'list', 31 | name: 'deploy_type', 32 | message: '请选择部署类型', 33 | choices: [ 34 | { 35 | value: 1, 36 | name: 'onedrive', 37 | }, 38 | { 39 | value: 0, 40 | name: 'sharepoint', 41 | }, 42 | ], 43 | }, 44 | { 45 | type: 'input', 46 | name: 'base_dir', 47 | message: '请输入部署目录(如 /path/public, 留空表示部署网盘根目录):\n', 48 | }, 49 | { 50 | type: 'input', 51 | name: 'client_id', 52 | message: 'client_id:', 53 | }, 54 | { 55 | type: 'input', 56 | name: 'client_secret', 57 | message: 'client_secret:', 58 | }, 59 | { 60 | type: 'input', 61 | name: 'redirect_uri', 62 | message: 'redirect_uri:', 63 | }, 64 | ] 65 | 66 | let res = await prompt(questions) 67 | 68 | const { 69 | client_id, 70 | client_secret, 71 | deploy_type, 72 | account_type, 73 | redirect_uri, 74 | base_dir, 75 | } = res 76 | 77 | const auth_endpoint = `${ 78 | account_type 79 | ? 'https://login.microsoftonline.com' 80 | : 'https://login.partner.microsoftonline.cn' 81 | }/common/oauth2/v2.0` 82 | 83 | questions = [ 84 | { 85 | type: 'input', 86 | name: 'code', 87 | message: `登录地址:\n${auth_endpoint}/authorize?${ 88 | new URLSearchParams({ 89 | client_id, 90 | scope: deploy_type 91 | ? 'Files.Read.All Files.ReadWrite.All offline_access' 92 | : 'Sites.Read.All Sites.ReadWrite.All offline_access', 93 | response_type: 'code', 94 | }).toString() 95 | }&redirect_uri=${redirect_uri}\n请输入浏览器访问后重定向的地址:\n`, 96 | }, 97 | ] 98 | 99 | res = await prompt(questions) 100 | const code = new URL(res.code).searchParams.get('code') 101 | const credentials = { 102 | account_type, 103 | deploy_type, 104 | code, 105 | client_id, 106 | client_secret, 107 | redirect_uri, 108 | auth_endpoint, 109 | base_dir, 110 | } 111 | 112 | return credentials 113 | } 114 | 115 | // Acquire token with credentials, then output it 116 | async function acquireToken(credentials) { 117 | try { 118 | const { 119 | code, 120 | client_id, 121 | client_secret, 122 | auth_endpoint, 123 | redirect_uri, 124 | } = credentials 125 | 126 | const res = await fetch(`${auth_endpoint}/token`, { 127 | method: 'POST', 128 | body: `${ 129 | new URLSearchParams({ 130 | grant_type: 'authorization_code', 131 | code, 132 | client_id, 133 | client_secret, 134 | }).toString() 135 | }&redirect_uri=${redirect_uri}`, 136 | headers, 137 | }) 138 | if (res.ok) { 139 | const data = await res.json() 140 | credentials.refresh_token = data.refresh_token 141 | credentials.access_token = data.access_token 142 | } 143 | } catch (e) { 144 | console.warn(e) 145 | } 146 | return credentials 147 | } 148 | 149 | async function getDriveApi(credentials) { 150 | const { account_type, deploy_type, access_token } = credentials 151 | const graphApi = account_type 152 | ? 'https://graph.microsoft.com/v1.0' 153 | : 'https://microsoftgraph.chinacloudapi.cn/v1.0' 154 | 155 | // SharePoint 156 | if (!deploy_type) { 157 | questions = [ 158 | { 159 | type: 'input', 160 | name: 'hostName', 161 | message: 162 | '为获取 SharePoint 网站的 ID, 需提供如下数据: \nSharePoint 网站的 host (如: cent.sharepoint.com)', 163 | }, 164 | { 165 | type: 'input', 166 | name: 'sitePath', 167 | message: 'SharePoint 站点的位置 (如: /sites/centUser)', 168 | }, 169 | ] 170 | let res = await prompt(questions) 171 | console.log('get site-id from graph') 172 | res = await fetch( 173 | `${graphApi}/sites/${res.hostName}:${res.sitePath}?$select=id`, 174 | { 175 | headers: { 176 | Authorization: `bearer ${access_token}`, 177 | }, 178 | }, 179 | ) 180 | 181 | if (res.ok) { 182 | data = await res.json() 183 | credentials.drive_api = `${graphApi}/sites/${data.id}/drive` 184 | } 185 | } else { 186 | // Onedrive 187 | credentials.drive_api = `${graphApi}/me/drive` 188 | } 189 | } 190 | 191 | function delKey(credentials) { 192 | delete credentials.code 193 | delete credentials.account_type 194 | delete credentials.deploy_type 195 | delete credentials.access_token 196 | } 197 | 198 | ;(async () => { 199 | const credentials = await acquireToken(await init()) 200 | await getDriveApi(credentials) 201 | delKey(credentials) 202 | writeFileSync( 203 | path.resolve('./.env'), 204 | Object.keys(credentials).reduce((env, e) => { 205 | return `${env}${e} = ${credentials[e]}${EOL}` 206 | }, ''), 207 | ) 208 | console.warn('环境变量已自动配置 🎉, 文件已保存至 ./.env') 209 | })() 210 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 简介 2 | 3 | **S**erverless **O**neDrive & **S**harePoint **F**unction. 4 | 5 | 或许是**国内**访问最快的 OneDrive **免服务器**图床程序(或视频床、音乐床、...床),专为**世纪互联**用户打造 6 | 7 | > 注:SharePoint 文档储存功能和 OneDrive 网盘类似,本说明将他们统称为 OneDrive。 8 | 9 | ## 演示 10 | 11 | SOSF 的前端 DEMO 页面:[sosf.vercel.app][sosf-demo-link] 12 | 13 | ![SOSF][sosf-demo] 14 | 15 | ## 特点 16 | 17 | - 使用 [`sstore`](https://github.com/beetcb/sstore) 项目缓存 `access_token`,省去复杂的数据库配置和不必要带宽开销 18 | 19 | - 与现有免费图床服务的区别:我们有 OneDrive 😎,所以 sosf 可以托管任何文件(图片、视频、下载链接),并且无储存空间限制(几乎,你甚至还可以用 SharePoint 扩展空间) 20 | 21 | - 提供 API 接口模块 [sor](./sor/),良好的 DTS 支持,方便二次开发。 22 | 例如:
一个列出 OneDrive 根目录所有文件的示例 23 | 24 | ```js 25 | const { getToken, listChildren } = require('@beetcb/sor') 26 | 27 | async function handler() { 28 | const access_token = await getToken() 29 | const resource = await listChildren('/', access_token) 30 | if (resource) { 31 | return resource 32 | } 33 | } 34 | 35 | exports.main = handler 36 | ``` 37 | 38 |
39 | 40 | - 访问速度快:`sosf` 使用国内 Serverless 供应商提供的免费服务(一般带有 CDN),访问国内的世纪互联,速度自然有质的飞跃 41 | 42 | - CLI 配置,简单快速:微软 Graph 的授权过程比较麻烦,为此我提供了一个 CLI 工具来加快部署。用户填入所有的配置项后,该工具自动写入配置文件,无需多余操作 43 | 44 | - 设计从简:`sosf` 只验证并获取 Onedrive 文件直链,并重定向过去(为方便文件查找,前端提供了简单的文件查找列表界面,让你简单高效地查找文件并复制文件链接) 45 | 46 | -
47 | 多平台部署支持:腾讯云开发 、Vercel 48 | 49 | - [腾讯云开发免费额度 (⚡)][tcb-console]:就速度而言它应该是最快的,缺点是每月有使用量限制 `执行内存(GB) * 执行时间(s)` 为 1000 GBs,云函数公网访问月流量限制为 1 GB,详见 [免费额度][tcb-price]。如果你觉得服务不错,也可按量付费表示支持 50 | 51 | - [Vercel Serverless Func (🌸)][vercel-func]:它是国外服务器,速度不如前两家;不过国内访问速度也不错,不需要备案,免费额度也绝对够用:云函数使用量限制 `执行内存(GB) * 执行时间(h)` 为 100 GB-Hrs,月流量 100 GB, 详见 [Fair Use Policy][fair-use] 52 |
53 | 54 | - 遵守[合理使用](fair-use)规范:在我们使用这些云服务商表示支持的同时,也要~~优雅薅羊毛~~合理使用 55 | 56 | ## 部署指南 57 | 58 | ### OneDrive 配置并授权 59 | 60 | 1. Azure 控制台顶栏搜索`应用注册`⇢ 新注册 ⇢ 受支持的账户类型填入`任何组织目录(任何 Azure AD 目录 - 多租户)中的帐户`⇢ 重定向 uri 填入 `http://localhost`⇢ 获取 `应用程序(客户端) ID (client_id)` 61 | 62 | 2. 授权 63 | 64 | - OneDrive 用户左管理栏 API 权限 ⇢ 添加权限 `offine-access`、`files.read.all`、`files.read.write.all`⇢ 左管理栏证书和密码 ⇢ 创建并获取 `客户端密码 client-secret` 65 | - SharePoint 用户左管理栏 API 权限 ⇢ 添加权限 `offine-access`、`sites.read.all`、`sites.read.write.all`⇢ 左管理栏证书和密码 ⇢ 创建并获取 `客户端密码 (client-secret)` ⇢ 创建并获取 client-secret 和以下两项额外参数: 66 | 67 | - hostName: 你的 SharePoint Host,比如 `cos.sharepoint.cn` 68 | - sitePath: 你的 SharePoint 网站相对位置,比如 `/sites/cos` 69 | 70 | 比如我的 SharePoint 访问网址为 `https://odbeet.sharepoint.cn/sites/beet`,则 `hostName` 值为 `odbeet.sharepoint.cn`,`sitePath` 值为 `/sites/beet`,这是最快判断上述两者取值的方法 71 | 72 | 3. 得到上述配置参数后,请保存好留作后用 73 | 74 | ### 云平台配置并部署 75 | 76 | > 请在以下三种平台中根据你的需求任选其一: 77 | 78 | #### 一. 腾讯云开发 tcb 79 | 80 | > **未开通云开发&新注册用户**需要先开通云开发,具体过程为:在 [此地址][tcb-console] 注册登录,完成后再进入 [开通地址][tcb-console] 开通 ⇢ 不创建环境(请勾选),其它默认 ⇢ 跳转到授权界面并授权,开通成功 0. 点击此按钮一键部署:
81 | 82 | [![][tcb-btn]][tcb-btn-link] 83 | 84 | - [ ] 使用免费资源(记得勾选) 85 | 86 | **注意**:直接部署计费模式为**按量计费** + 免费额度,如果你需要使用包月类型的免费额度,请参考手动部署教程:
点击展开手动部署教程 87 | 88 | 0. 配置机密环境变量: 89 | 90 | ```bash 91 | git clone https://github.com/beetcb/sosf.git 92 | npm i 93 | npm run auth 94 | # 在此根据提示开始配置 95 | ``` 96 | 97 | 配置完成后,该工具会创建一个 `.env` 文件,内容大致如下: 98 | 99 | ```text 100 | client_id = xxx 101 | client_secret = xxx 102 | redirect_uri = http://localhost 103 | auth_endpoint = https://login.partner.microsoftonline.cn/common/oauth2/v2.0 104 | refresh_token = 0.AAAAQnNGiYmnI0CvMoN0bxMYyyzk3YwiZepNl9MOI6F2AGAB 105 | drive_api = https://microsoftgraph.chinacloudapi.cn/v1.0/sites/xx.sharepoint.cn,b4df3221/drive 106 | ``` 107 | 108 | **你可以在此增加配置项**,详见 [部署配置](#部署配置) 109 | 110 | 1. 进入云开发[控制台][tcb-console] ⇢ 空模板 ⇢ 确保选择计费方式`包年包月`, 套餐版本`免费版`(这样能够确保免费额度超出后不继续扣费,当然如果你觉得服务不错,请付费表示支持) ⇢ 进入控制台 111 | 112 | 2. 安装 tcb cli 并授权登录: 113 | 114 | ```bash 115 | npm i -g @cloudbase/cli 116 | tcb login 117 | ``` 118 | 119 | 此时需手动修改云开发[配置文件][tcb]中 envId 项为你的环境 ID 120 | 121 | 3. 部署云函数: 122 | 123 | ```bash 124 | tcb fn deploy 125 | ``` 126 | 127 | 4. 指定 HTTP 访问路径: 128 | ```bash 129 | tcb service create -p / -f sosf 130 | # 让函数在根目录触发 131 | ``` 132 | 5. 等待几分钟,就可以开始预览了,访问示例:`https://your.app/path/to/file.md` 133 | 134 |
135 | 136 | 0. 本地获取机密环境变量: 137 | 138 | ```bash 139 | git clone https://github.com/beetcb/sosf.git 140 | npm i 141 | npm run auth 142 | # 在此根据提示开始配置 143 | ``` 144 | 145 | 配置完成后,该工具会创建一个 `.env` 文件,内容大致如下: 146 | 147 | ```text 148 | client_id = xxx 149 | client_secret = xxx 150 | redirect_uri = http://localhost 151 | auth_endpoint = https://login.partner.microsoftonline.cn/common/oauth2/v2.0 152 | refresh_token = 0.AAAAQnNGiYmnI0CvMoN0bxMYyyzk3YwiZepNl9MOI6F2AGAB 153 | drive_api = https://microsoftgraph.chinacloudapi.cn/v1.0/sites/xx.sharepoint.cn,b4df3221/drive 154 | ``` 155 | 156 | **你可以在此增加配置项**,详见 [部署配置](#部署配置) 157 | 158 | 1. 进入刚刚创建的环境 ⇢ 左栏云函数 ⇢ 在线代码编辑器 ⇢ 将本地 `.env` 文件里的内容粘贴到在线编辑的 `.env` 文件中并保存,然后点击测试,无报错则配置成功 159 | 160 | 2. 到此,应该部署成功了,如需自定义域名,请配置 [HTTP 访问服务][tcb-http]。访问示例:`https://domain.com/path/to/file.md` 161 | 162 | 3. (可选)配置 GitHub 持续部署,更新功能更便捷:`fork` 本项目,在 Actions 栏下开启 `sosf` ,下一步需要配置部署机密,在 Settings -> Secrets -> 依次添加以下四个 Secrets: 163 | 164 | ```text 165 | DOTENV 之前生成的 .env 文件的内容 166 | ENVID 云开发 ENVID 167 | SECRETID 腾讯云的访问密钥 ID 168 | SECRETKEY 腾讯云的访问密钥 KEY 169 | ``` 170 | 171 | ![github-actions][github-actions-secret] 172 | 173 | 然后请通过 [push] 来测试,成功以后每次的 [push] 操作都会部署 sosf 到你的云开发环境 174 | 175 | #### 二. Vercel Serverless Func 176 | 177 | 0. 本地获取机密环境变量: 178 | 179 | ```bash 180 | git clone https://github.com/beetcb/sosf.git 181 | npm i 182 | npm run auth 183 | # 在此根据提示开始配置 184 | ``` 185 | 186 | 配置完成后,该工具会创建一个 `.env` 文件,内容大致如下: 187 | 188 | ```text 189 | client_id = xxx 190 | client_secret = xxx 191 | redirect_uri = http://localhost 192 | auth_endpoint = https://login.partner.microsoftonline.cn/common/oauth2/v2.0 193 | refresh_token = 0.AAAAQnNGiYmnI0CvMoN0bxMYyyzk3YwiZepNl9MOI6F2AGAB 194 | drive_api = https://microsoftgraph.chinacloudapi.cn/v1.0/sites/xx.sharepoint.cn,b4df3221/drive 195 | ``` 196 | 197 | **你可以在此增加配置项**,详见 [部署配置](#部署配置) 198 | 199 | 1. 安装 vercel cli 并登录: 200 | 201 | ```bash 202 | npm i -g vercel 203 | vercel login 204 | ``` 205 | 206 | 2. 部署: 207 | 208 | ```bash 209 | vercel --prod 210 | ``` 211 | 212 | 到此部署完成,访问地址可以在命令行或 vercel 官网看到。需要使用自定义域名,请参考 [custom-domains][vercel-custom-domains] 213 | 214 | 3. 访问地址示例:https://your.app/?path=/path/to/file.md 215 | 216 | 4. (可选)配置 Vercel 持续部署,更新功能更便捷:`fork` 本项目,在 Vercel 导入新的 GitHub 项目: 217 | 218 | ![vercel-import][vercel-import] 219 | 220 | `ROOT DIRECTORY` 为 `platforms/vercel/sosf`,Environmental Variables 填入之前生成的 `.env` 中的键值对 (也就是说你需要手动添加多次,目前还没有找到好的解决方案,欢迎开 Issue 讨论解法) 221 | 222 | ![vercel-import-success][vercel-import-success] 223 | 224 | ### 部署配置 225 | 226 | 你可以直接使用 `.env` 文件自定义配置如下内容: 227 | 228 | ```text 229 | base_dir = 需要部署的根目录文件夹,默认为 / 230 | access_key = 前端界面鉴权密钥,持有此密钥才能访问文件夹内容,默认为空 231 | ``` 232 | 233 | > 使用 access_key 访问前端列表的方法:https://static.beetcb.com?key={{access_key}} 234 | 235 | ## 作者 236 | 237 | 作者:[`beetcb`][beetcb] 238 | 239 | 邮箱: `i@beetcb.com` 240 | 241 | `sosf` - Licensed under under [MIT][license] 242 | 243 | ## 鸣谢 244 | 245 | - [Tencent CloudBase][tcb-thanks] 246 | - [Vercel][vercel-thanks] 247 | 248 | 249 | [sosf-demo]: https://i.imgur.com/D9bnNzw.png 250 | [sosf-demo-link]: http://sosf.vercel.app/ 251 | [tcb-thanks]: https://github.com/TencentCloudBase 252 | [vercel-thanks]: https://github.com/vercel/vercel 253 | [vercel-func]: https://vercel.com/docs/serverless-functions/introduction 254 | [license]: https://github.com/beetcb/sosf/blob/tcb-scf/LICENSE 255 | [beetcb]: https://www.beetcb.com 256 | [tcb-http]: https://console.cloud.tencent.com/tcb/env/access?rid=4 257 | [tcb]: ./cloudbaserc.json 258 | [tcb-price]: https://cloud.tencent.com/document/product/876/39095 259 | [tcb-console]: https://console.cloud.tencent.com/tcb 260 | [tcb-btn]: https://main.qcloudimg.com/raw/67f5a389f1ac6f3b4d04c7256438e44f.svg 261 | [tcb-btn-link]: https://console.cloud.tencent.com/tcb/env/index?action=CreateAndDeployCloudBaseProject&appUrl=https%3A%2F%2Fgithub.com%2Fbeetcb%2Fsosf&branch=main 262 | [fair-use]: https://vercel.com/docs/platform/fair-use-policy 263 | [github-actions-secret]: https://i.imgur.com/sY9Vthc.png 264 | [vercel-import]: https://i.imgur.com/LpozMqw.png 265 | [vercel-custom-domains]: https://vercel.com/docs/custom-domains# 266 | [vercel-import-success]: https://i.imgur.com/K1WbX6Q.png 267 | --------------------------------------------------------------------------------