├── .idea └── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── package.json ├── .devcontainer └── devcontainer.json ├── .gitignore ├── config.sample.toml ├── receive.html ├── assets ├── receive.css ├── homepage.css ├── receive.ts └── homepage.ts ├── LICENSE ├── minify.sh ├── homepage.html ├── api.md ├── README.md ├── auth.html ├── go.mod ├── docs ├── Private-App.md └── auth.ps1 ├── .github └── workflows │ └── CI.yml ├── virtual-downloader.ts ├── go.sum └── onesend.go /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "html-minifier": "^4.0.0", 4 | "typescript": "^5.1.3", 5 | "uglifycss": "^0.0.29", 6 | "webpack": "^5.88.0", 7 | "webpack-cli": "^5.1.4" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. 2 | { 3 | "name": "onesend - golang", 4 | "image": "mcr.microsoft.com/devcontainers/javascript-node", 5 | "features": { 6 | "ghcr.io/devcontainers/features/go:1": { 7 | "version": "1.20" 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # MacOS 18 | .DS_Store 19 | 20 | # Editors 21 | .vscode 22 | .idea/* 23 | # track codeStyles configuration files 24 | !.idea/codeStyles 25 | 26 | # Binaries 27 | dist 28 | 29 | # Nodejs 30 | node_modules 31 | 32 | # Track typescirpt only 33 | *.js -------------------------------------------------------------------------------- /config.sample.toml: -------------------------------------------------------------------------------- 1 | [Onedrive] 2 | ClientID = "" 3 | ClientSecret = "" 4 | # Leave Empty for default 5 | 6 | # Global or Personal 7 | AccountArea = "global" 8 | # # United States government 9 | # AccountArea = "gov" 10 | # # Germany 11 | # AccountArea = "de" 12 | # # China (operated by 21Vianet) 13 | # AccountArea = "cn" 14 | 15 | # path in drive 16 | SavePath = "/onesend" 17 | 18 | # OneDrive 19 | Drive = "/me/drive" 20 | # # sepecific by drive id 21 | # Drive = "/drives/!bxxxxxxxxxx" 22 | # # sharepoint document 23 | # Drive = "/sites/xxx.sharepoint.com:/sites/xxxxx" 24 | # # another user's onedrive 25 | # Drive = "/users/xxxxx_xxxx_onmicrosoft_com/drive" 26 | 27 | [Sender] 28 | Listen = "0.0.0.0:7777" 29 | -------------------------------------------------------------------------------- /receive.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Receive file 7 | 8 | 9 | 10 | 11 | 12 |

You have received:

13 |
loading...
14 |
15 | 16 |
17 |
18 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /assets/receive.css: -------------------------------------------------------------------------------- 1 | * { 2 | -webkit-box-sizing: border-box; 3 | -moz-box-sizing: border-box; 4 | box-sizing: border-box; 5 | } 6 | 7 | h1, 8 | footer { 9 | text-align: center; 10 | } 11 | 12 | #file-list { 13 | text-align: center; 14 | width: min-content; 15 | margin: 100px auto; 16 | } 17 | 18 | #cli-operation { 19 | text-align: center; 20 | width: min-content; 21 | margin: auto; 22 | } 23 | 24 | .file-item { 25 | width: max-content; 26 | } 27 | 28 | .link-like { 29 | cursor: pointer; 30 | color: blue; 31 | text-decoration: underline; 32 | } 33 | 34 | @media (prefers-color-scheme: dark) { 35 | * { 36 | background: #1e1f23; 37 | color: #c4c5c9; 38 | } 39 | .link-like, 40 | :link, 41 | :visited { 42 | color: #58a6ff; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 9 | 10 | 14 | 15 | 16 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021 yuudi 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /minify.sh: -------------------------------------------------------------------------------- 1 | npm install 2 | 3 | mv assets assets.src 4 | mkdir assets 5 | 6 | npx html-minifier --collapse-whitespace --minify-css true --minify-js true homepage.html >homepage.min.html 7 | mv homepage.min.html homepage.html 8 | npx html-minifier --collapse-whitespace --minify-css true --minify-js true receive.html >receive.min.html 9 | mv receive.min.html receive.html 10 | npx html-minifier --collapse-whitespace --minify-css true --minify-js true auth.html >auth.min.html 11 | mv auth.min.html auth.html 12 | 13 | npx uglifycss assets.src/homepage.css >assets/homepage.css 14 | npx uglifycss assets.src/receive.css >assets/receive.css 15 | 16 | npx tsc ./assets.src/homepage.ts --target es2017 17 | npx webpack ./assets.src/homepage.js -o ./dist --mode production 18 | mv dist/main.js assets/homepage.js 19 | npx tsc ./assets.src/receive.ts --target es2017 20 | npx webpack ./assets.src/receive.js -o ./dist --mode production 21 | mv dist/main.js assets/receive.js 22 | npx tsc ./virtual-downloader.ts --target es2017 23 | sed -i '/export {};/d' ./virtual-downloader.js 24 | npx webpack ./virtual-downloader.js -o ./dist --mode production 25 | mv dist/main.js virtual-downloader.js 26 | -------------------------------------------------------------------------------- /homepage.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Share file 7 | 8 | 9 | 10 | 11 | 12 |

Share your files

13 | 14 |
15 |
16 | 17 | 18 |
19 | 22 | 25 |
26 |
27 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /api.md: -------------------------------------------------------------------------------- 1 | # api 2 | 3 | ## create share 4 | 5 | POST /api/v1/share 6 | 7 | **request body**: none 8 | 9 | **response body**: 10 | 11 | write_id: a secret id to upload file 12 | read_id: a public id to share file 13 | 14 | ## create file 15 | 16 | POST /api/v1/attachment 17 | 18 | **request body**: 19 | 20 | write_id: a secret id to upload file 21 | name: filename 22 | 23 | **response body**: 24 | 25 | upload_url: a url to upload file 26 | 27 | ## upload file 28 | 29 | > see [onedrive docs](https://docs.microsoft.com/onedrive/developer/rest-api/api/driveitem_createuploadsession#upload-bytes-to-the-upload-session) 30 | 31 | upload file by chunks, file chunk must be multiples of 320KiB (327680 bytes) and no larger than 60MiB 32 | 33 | PUT _upload_url_ 34 | 35 | **request header**: 36 | 37 | Content-Length: total size of file 38 | Content-Range: uploaded part range of file 39 | 40 | **request body**: part of file content 41 | 42 | **response status**: 43 | 44 | 202: continue uploading 45 | 200: file uploaded 46 | 47 | ## get share 48 | 49 | GET /api/v1/share/ 50 | 51 | **response body**: 52 | 53 | value: 54 | ├ name: filename 55 | ├ size: file size (in bytes) 56 | └ @microsoft.graph.downloadUrl: url for download 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Onesend 2 | 3 | send your file through onedrive 4 | 5 | ## Features 6 | 7 | - Upload file without login, share anytime! 8 | - End-to-end encryption, security matters! 9 | - Onedrive storage, **No** traffic passthrough, free your server! 10 | - CLI command generation, easy for linux command-line download! 11 | 12 | ## Demo 13 | 14 | 15 | 16 | ## How does it work 17 | 18 | 1. Open the website 19 | 1. Upload your file(s) 20 | 1. Get the link and share it 21 | 1. Others download from the link 22 | 23 | ## Limitations 24 | 25 | - **MUST** hosted on https site (because service worker only works on https) 26 | - Cannot work in Firefox InPrivate window (because service worker are disabled) 27 | - Leaving downloading page will interrupt downloading (because downloading is done inside service worker) 28 | 29 | ## Deploy 30 | 31 | 1. download from release and unzip 32 | 1. run program 33 | 34 | ## Configuration 35 | 36 | **SavePath**: where to save files in your onedrive 37 | **Listen**: how the program bind address 38 | 39 | if you want to use your private client_id and client_secret to setup this app, you can check [This Instruction](./docs/Private-App.md) 40 | 41 | ## Build 42 | 43 | If you want to build from source, you need to install [go](https://golang.org/) and run `go build` in the project directory 44 | -------------------------------------------------------------------------------- /assets/homepage.css: -------------------------------------------------------------------------------- 1 | * { 2 | -webkit-box-sizing: border-box; 3 | -moz-box-sizing: border-box; 4 | box-sizing: border-box; 5 | } 6 | 7 | h1, 8 | #main-display, 9 | footer { 10 | text-align: center; 11 | } 12 | 13 | #create-share-group { 14 | text-align: center; 15 | } 16 | 17 | .action-button { 18 | color: #fff; 19 | background-color: #409eff; 20 | border: unset; 21 | border-radius: 4px; 22 | font-size: 14px; 23 | cursor: pointer; 24 | padding: 12px 20px; 25 | } 26 | 27 | .action-button[disabled] { 28 | background-color: #a0cfff; 29 | cursor: not-allowed; 30 | } 31 | 32 | #create-share { 33 | text-align: center; 34 | margin: 100px; 35 | } 36 | 37 | #history-link { 38 | text-align: center; 39 | font-size: 12px; 40 | } 41 | 42 | #sharing { 43 | width: min-content; 44 | min-width: 240px; 45 | text-align: center; 46 | margin: 100px auto; 47 | } 48 | 49 | #file-table { 50 | width: max-content; 51 | } 52 | 53 | #share-history { 54 | text-align: center; 55 | margin: 10px auto; 56 | } 57 | 58 | .link-like { 59 | cursor: pointer; 60 | color: blue; 61 | text-decoration: underline; 62 | } 63 | 64 | @media (prefers-color-scheme: dark) { 65 | * { 66 | background: #1e1f23; 67 | color: #c4c5c9; 68 | } 69 | .link-like, 70 | :link, 71 | :visited { 72 | color: #58a6ff; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /auth.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Authorize 8 | 9 | 10 | 11 | 12 |

Authorize Onedrive Access

13 |

Click the button below to authorize access to your Onedrive account.

14 | 15 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/yuudi/onesend 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.3.2 7 | github.com/gin-gonic/gin v1.9.1 8 | github.com/robfig/cron/v3 v3.0.1 9 | golang.org/x/oauth2 v0.9.0 10 | ) 11 | 12 | require ( 13 | github.com/bytedance/sonic v1.9.1 // indirect 14 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect 15 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 16 | github.com/gin-contrib/sse v0.1.0 // indirect 17 | github.com/go-playground/locales v0.14.1 // indirect 18 | github.com/go-playground/universal-translator v0.18.1 // indirect 19 | github.com/go-playground/validator/v10 v10.14.0 // indirect 20 | github.com/goccy/go-json v0.10.2 // indirect 21 | github.com/golang/protobuf v1.5.2 // indirect 22 | github.com/json-iterator/go v1.1.12 // indirect 23 | github.com/klauspost/cpuid/v2 v2.2.4 // indirect 24 | github.com/leodido/go-urn v1.2.4 // indirect 25 | github.com/mattn/go-isatty v0.0.19 // indirect 26 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 27 | github.com/modern-go/reflect2 v1.0.2 // indirect 28 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect 29 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 30 | github.com/ugorji/go/codec v1.2.11 // indirect 31 | golang.org/x/arch v0.3.0 // indirect 32 | golang.org/x/crypto v0.10.0 // indirect 33 | golang.org/x/net v0.11.0 // indirect 34 | golang.org/x/sys v0.9.0 // indirect 35 | golang.org/x/text v0.10.0 // indirect 36 | google.golang.org/appengine v1.6.7 // indirect 37 | google.golang.org/protobuf v1.30.0 // indirect 38 | gopkg.in/yaml.v3 v3.0.1 // indirect 39 | ) 40 | -------------------------------------------------------------------------------- /docs/Private-App.md: -------------------------------------------------------------------------------- 1 | # Private App 2 | 3 | If you want to use your private client_id and client_secret to setup this app, you can follow the steps below. 4 | 5 | ## Authorization 6 | 7 | 1. Open and then click `New registration`. 8 | 1. Enter a name for your app, choose account type `Accounts in any organizational directory (Any Azure AD directory - Multitenant) and personal Microsoft accounts (e.g. Skype, Xbox)`, select `Web` in `Redirect URI`, then type `http://localhost:53682/` and click Register. Copy and keep the `Application (client) ID` under the app name for later use. 9 | 1. Under `manage` select `Certificates & secrets`, click `New client secret`. Copy and keep that secret value for later use (secret value, not secret ID). 10 | 1. Under `manage` select `API permissions`, click `Add a permission` and select `Microsoft Graph` then select `delegated permissions`. 11 | 1. Search and select the following permissions: `Files.ReadWrite.All`. Once selected click `Add permissions` at the bottom. 12 | 1. Download [this script](./auth.ps1) on your Windows computer, click `run in powershell` in the right-click menu, enter your `client id` and `client secret`, and follow the instruction to get `refresh_token`. (if the script is forbidden, execute in powershell as administrator `Start-Process -Wait -Verb RunAs powershell.exe -Args "-executionpolicy bypass -command Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Force`) 13 | 1. When finished, `token.txt` is saved on your desktop. 14 | 15 | ## Configuration 16 | 17 | open `config.toml` and fill in the following fields: 18 | 19 | **ClientID**: client id 20 | **ClientSecret**: client secret 21 | **AccountArea**: the area of your onedrive account, can be ("global" | "gov" | "de" | "cn") 22 | **Drive**: the drive path to use. default: "/me/drive" 23 | 24 | open `token.txt` and copy the refresh token to the file. 25 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: 9 | - opened 10 | - synchronize 11 | workflow_dispatch: {} 12 | 13 | jobs: 14 | build: 15 | name: Build Go Binaries 16 | runs-on: ubuntu-20.04 17 | 18 | steps: 19 | - name: Checkout the repo 20 | uses: actions/checkout@v2 21 | with: 22 | fetch-depth: 0 23 | submodules: true 24 | 25 | - name: Setup go 26 | uses: actions/setup-go@v2 27 | with: 28 | go-version: "1.20" 29 | 30 | - name: Cache go modules 31 | uses: actions/cache@v2 32 | with: 33 | path: ~/go/pkg/mod 34 | key: ${{ runner.os }}-go-${{ hashFiles('./go.sum') }} 35 | restore-keys: | 36 | ${{ runner.os }}-go- 37 | 38 | - name: Build 39 | run: | 40 | sh minify.sh 41 | 42 | export CGO_ENABLED=0 43 | export GOOS=linux 44 | export GOARCH=amd64 45 | go build -trimpath -ldflags="-s -w" -o "dist/onesend-amd64" . 46 | 47 | export GOOS=linux 48 | export GOARCH=arm64 49 | go build -trimpath -ldflags="-s -w" -o "dist/onesend-arm64" . 50 | 51 | export GOOS=windows 52 | export GOARCH=amd64 53 | go build -trimpath -ldflags="-s -w" -o "dist/onesend.exe" . 54 | 55 | cp config.sample.toml dist/config.toml 56 | touch dist/token.txt 57 | 58 | cd dist 59 | mv onesend-amd64 onesend 60 | tar Jcf onesend-ci-${GITHUB_SHA:0:7}-linux-x86_64.tar.xz onesend config.toml token.txt 61 | mv onesend-arm64 onesend 62 | tar Jcf onesend-ci-${GITHUB_SHA:0:7}-linux-arm64.tar.xz onesend config.toml token.txt 63 | zip onesend-ci-${GITHUB_SHA:0:7}-windows-x86_64.zip onesend.exe config.toml token.txt 64 | 65 | - name: Upload artifact 66 | uses: actions/upload-artifact@v2 67 | with: 68 | name: executable 69 | path: | 70 | dist/*.tar.xz 71 | dist/*.zip 72 | -------------------------------------------------------------------------------- /docs/auth.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = "Stop" 2 | 3 | Write-Output "choose the account area 4 | 1> Global 5 | 2> United States government 6 | 3> Germany 7 | 4> China (operated by 21Vianet) 8 | 9 | " 10 | $account_area = Read-Host 'account type' 11 | switch ($account_area) { 12 | 1 { $auth_host = "login.microsoftonline.com" } 13 | 2 { $auth_host = "login.microsoftonline.us" } 14 | 3 { $auth_host = "login.microsoftonline.de" } 15 | 4 { $auth_host = "login.chinacloudapi.cn" } 16 | Default { exit } 17 | } 18 | 19 | $client_id = Read-Host 'client_id' 20 | $client_secret = Read-Host 'client_secret' 21 | 22 | $auth_url = "https://${auth_host}/common/oauth2/v2.0/authorize?client_id=${client_id}&response_type=code&redirect_uri=http://localhost:53682/&response_mode=query&scope=offline_access%20Files.ReadWrite.All" 23 | $auth_code = "" 24 | 25 | $http = [System.Net.HttpListener]::new() 26 | $http.Prefixes.Add("http://localhost:53682/") 27 | $http.Start() 28 | 29 | Start-Process $auth_url 30 | 31 | while ($http.IsListening) { 32 | Start-Sleep -Seconds 0.1 33 | $context = $http.GetContext() 34 | $code = $context.Request.QueryString.Get("code") 35 | if ($code) { 36 | $auth_code = $code 37 | [string]$html = "

success, now you can close this window

" 38 | $buffer = [System.Text.Encoding]::UTF8.GetBytes($html) 39 | $context.Response.ContentLength64 = $buffer.Length 40 | $context.Response.OutputStream.Write($buffer, 0, $buffer.Length) 41 | $context.Response.OutputStream.Close() 42 | break 43 | } 44 | else { 45 | [string]$html = "

error, please continue here

" 46 | $buffer = [System.Text.Encoding]::UTF8.GetBytes($html) 47 | $context.Response.ContentLength64 = $buffer.Length 48 | $context.Response.OutputStream.Write($buffer, 0, $buffer.Length) 49 | $context.Response.OutputStream.Close() 50 | } 51 | } 52 | 53 | Write-Output "code received, start fetching token" 54 | 55 | $reqdata = "client_id=${client_id}&client_secret=${client_secret}&grant_type=authorization_code&code=${auth_code}&redirect_uri=http://localhost:53682/&scope=offline_access%20Files.ReadWrite.All" 56 | 57 | $res = Invoke-RestMethod "https://${auth_host}/common/oauth2/v2.0/token" -Method "POST" -Body $reqdata 58 | $refresh_token = $res.refresh_token 59 | 60 | $desktop = [Environment]::GetFolderPath("Desktop") 61 | New-Item -Path $desktop\token.txt -ItemType File -Value $refresh_token 62 | 63 | Write-Output @" 64 | ========== 65 | ${refresh_token} 66 | ========== 67 | this is your refresh_token, keep it safe 68 | it has been saved on your desktop 69 | "@ 70 | 71 | cmd /C PAUSE 72 | -------------------------------------------------------------------------------- /virtual-downloader.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | export type {}; // make typescript shut up, this line should be deleted after transpiled 5 | declare let self: ServiceWorkerGlobalScope; 6 | 7 | const CACHE_KEY = "v1.0.1"; 8 | 9 | let FilesData = {}; 10 | 11 | async function decrypt_file_part( 12 | key: CryptoKey, 13 | cipher: BufferSource, 14 | nonce: Uint8Array, 15 | file_id: number, 16 | counter: number 17 | ) { 18 | let counter_array = new Uint8Array(new Uint32Array([counter]).buffer); 19 | let file_id_array = new Uint8Array( 20 | new Uint32Array([file_id * 2 + 1]).buffer 21 | ); 22 | let CTR = new Uint8Array([ 23 | ...nonce, 24 | ...file_id_array.reverse(), 25 | ...counter_array.reverse(), 26 | ]); 27 | let plain = await crypto.subtle.decrypt( 28 | { 29 | name: "AES-CTR", 30 | counter: CTR, 31 | length: 128, 32 | }, 33 | key, 34 | cipher 35 | ); 36 | return plain; 37 | } 38 | 39 | class Chunker { 40 | done = false; 41 | private remaining: Uint8Array | undefined; 42 | remainingSize = 0; 43 | private reader: ReadableStreamDefaultReader; 44 | 45 | constructor(stream: ReadableStream, private size = 16) { 46 | this.reader = stream.getReader(); 47 | } 48 | 49 | async read(): Promise< 50 | { done: true; value: undefined } | { done: false; value: Uint8Array } 51 | > { 52 | if (this.done) { 53 | return { done: true, value: undefined }; 54 | } 55 | const { done, value } = await this.reader.read(); 56 | if (done || value === undefined) { 57 | this.done = true; 58 | if (this.remaining === undefined) { 59 | return { done: true, value: undefined }; 60 | } else { 61 | return { done: false, value: this.remaining }; 62 | } 63 | } 64 | const inSize = value.byteLength + this.remainingSize; 65 | const remainingSize = inSize % this.size; 66 | const outSize = inSize - remainingSize; 67 | let out: Uint8Array; 68 | if (this.remaining !== undefined) { 69 | out = new Uint8Array(outSize); 70 | out.set(this.remaining); 71 | out.set( 72 | value.slice(0, value.byteLength - remainingSize), 73 | this.remainingSize 74 | ); 75 | } else { 76 | out = value.slice(0, value.byteLength - remainingSize); 77 | } 78 | 79 | this.remainingSize = remainingSize; 80 | if (remainingSize > 0) { 81 | this.remaining = value.slice(value.byteLength - remainingSize); 82 | } else { 83 | this.remaining = undefined; 84 | } 85 | 86 | return { done: false, value: out }; 87 | } 88 | } 89 | 90 | self.addEventListener("activate", function (event) { 91 | event.waitUntil(self.clients.claim()); 92 | }); 93 | 94 | self.addEventListener("message", function (event) { 95 | if (event.data.request === "add_file") { 96 | FilesData[event.data.file_info.file_path] = event.data.file_info; 97 | } 98 | }); 99 | 100 | self.addEventListener("fetch", function (event) { 101 | let request = event.request; 102 | let url = new URL(request.url); 103 | if (request.method !== "GET") { 104 | return; 105 | } 106 | let path = url.pathname; 107 | if (path.startsWith("/s/download")) { 108 | event.respondWith(virtual_downloading_response(request)); 109 | return; 110 | } 111 | if (path.startsWith("/s/")) { 112 | request = new Request("/s/*"); 113 | } 114 | event.respondWith(cached_response(request)); 115 | }); 116 | 117 | function rangeOf(request: Request) { 118 | let range = request.headers.get("Range"); 119 | if (range === null) { 120 | return null; 121 | } 122 | let range_match = range.match(/^bytes=(\d+)-(\d+)$/); 123 | if (range_match === null) { 124 | return null; 125 | } 126 | let start = parseInt(range_match[1]); 127 | let end = parseInt(range_match[2]); 128 | return [start, end]; 129 | } 130 | 131 | async function virtual_downloading_response(request: Request) { 132 | const path = new URL(request.url).pathname; 133 | let path_list = path.split("/"); 134 | let file_path = path_list[path_list.length - 1]; 135 | let file_info = FilesData[file_path]; 136 | if (file_info === undefined) { 137 | return new Response("404 NOT FOUND", { 138 | status: 404, 139 | statusText: "Not Found", 140 | }); 141 | } 142 | let headers = new Headers(); 143 | // let range = rangeOf(request); 144 | // let start: number; 145 | // if (range !== null) { 146 | // start = range[0]; 147 | // } else { 148 | // start = 0; 149 | // } 150 | // if (range !== null) { 151 | // headers.set("Range", `bytes=${range[0]}-${range[1]}`); 152 | // } 153 | //// TODO: handle cases when range does not start from multiple of 16 154 | let { abort, signal } = new AbortController(); 155 | let response = await fetch(file_info.download_url, { headers, signal }); 156 | let body = response.body; 157 | if (body === null) { 158 | return response; 159 | } 160 | let reader = new Chunker(body, 16); // chunk stream to size of multiple of 16 bytes 161 | let decrypted_readable_stream = new ReadableStream({ 162 | async start(controller) { 163 | let offset = 0; 164 | while (true) { 165 | let readResult = await reader.read(); 166 | if (readResult.done) { 167 | break; 168 | } 169 | let plain = await decrypt_file_part( 170 | file_info.key, 171 | readResult.value, 172 | file_info.nonce, 173 | file_info.file_id, 174 | offset / 16 175 | ); 176 | offset += readResult.value.byteLength; 177 | controller.enqueue(new Uint8Array(plain)); 178 | } 179 | controller.close(); 180 | }, 181 | cancel() { 182 | abort(); 183 | }, 184 | }); 185 | // let decrypted_readable_stream = body.pipeThrough( 186 | // new TransformStream({ 187 | // async transform(chunk, controller) { 188 | // let plain = await decrypt_file_part( 189 | // file_info.key, 190 | // chunk, 191 | // file_info.nonce, 192 | // file_info.file_id, 193 | // start / 16 194 | // ); 195 | // start += chunk.byteLength; 196 | // controller.enqueue(new Uint8Array(plain)); 197 | // }, 198 | // }) 199 | // ); 200 | return new Response(decrypted_readable_stream, { 201 | headers: { 202 | "Content-Length": file_info.file_size, 203 | "Content-Type": "application/octet-stream", 204 | "Content-Disposition": `attachment; filename*=UTF-8''${encodeURIComponent( 205 | file_info.filename 206 | )}`, 207 | }, 208 | }); 209 | } 210 | 211 | async function cached_response(request: Request) { 212 | if (request.method !== "GET") { 213 | return fetch(request); 214 | } 215 | let resp = await caches.match(request); 216 | if (resp !== undefined) { 217 | return resp; 218 | } 219 | let response = await fetch(request); 220 | let cache = await caches.open(CACHE_KEY); 221 | cache.put(request, response.clone()); 222 | return response; 223 | } 224 | -------------------------------------------------------------------------------- /assets/receive.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | function sleep(ms: number) { 4 | return new Promise((resolve) => setTimeout(resolve, ms)); 5 | } 6 | 7 | function humanFileSize(bytes: number, si = false, dp = 1) { 8 | const thresh = si ? 1000 : 1024; 9 | if (Math.abs(bytes) < thresh) { 10 | return bytes + " B"; 11 | } 12 | const units = si 13 | ? ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] 14 | : ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]; 15 | let u = -1; 16 | const r = 10 ** dp; 17 | do { 18 | bytes /= thresh; 19 | ++u; 20 | } while ( 21 | Math.round(Math.abs(bytes) * r) / r >= thresh && 22 | u < units.length - 1 23 | ); 24 | return bytes.toFixed(dp) + " " + units[u]; 25 | } 26 | 27 | async function recover_aes_ctr_key(key_base64: string, nonce_base64: string) { 28 | if (key_base64.length !== 43) { 29 | throw new Error("key is broken"); 30 | } 31 | if (nonce_base64.length !== 11) { 32 | throw new Error("nonce is broken"); 33 | } 34 | let original_key_base64 = 35 | key_base64.replaceAll("-", "+").replaceAll("_", "/") + "="; 36 | let original_nonce_base64 = 37 | nonce_base64.replaceAll("-", "+").replaceAll("_", "/") + "="; 38 | let key_array = atob(original_key_base64) 39 | .split("") 40 | .map((c) => c.charCodeAt(0)); 41 | let nonce_array = atob(original_nonce_base64) 42 | .split("") 43 | .map((c) => c.charCodeAt(0)); 44 | let key_hex = [...key_array] 45 | .map((x) => x.toString(16).padStart(2, "0")) 46 | .join(""); 47 | let nonce_hex = [...nonce_array] 48 | .map((x) => x.toString(16).padStart(2, "0")) 49 | .join(""); 50 | let key = await crypto.subtle.importKey( 51 | "raw", 52 | new Uint8Array(key_array), 53 | { 54 | name: "AES-CTR", 55 | }, 56 | false, 57 | ["encrypt", "decrypt"] 58 | ); 59 | return { 60 | key: key, 61 | key_hex: key_hex, 62 | nonce: new Uint8Array(nonce_array), 63 | nonce_hex: nonce_hex, 64 | }; 65 | } 66 | 67 | async function decrypt_file_name( 68 | key: CryptoKey, 69 | name_encrypted: string, 70 | nonce: Uint8Array, 71 | file_id: number 72 | ) { 73 | let file_id_array = new Uint8Array(new Uint32Array([file_id * 2]).buffer); 74 | let padding_equals = name_encrypted.length % 4; 75 | if (padding_equals !== 0) { 76 | padding_equals = 4 - padding_equals; 77 | } 78 | let name_encrypted_original_base64 = 79 | name_encrypted.replaceAll("-", "+").replaceAll("_", "/") + 80 | "=".repeat(padding_equals); 81 | let name_encrypted_array = atob(name_encrypted_original_base64) 82 | .split("") 83 | .map((c) => c.charCodeAt(0)); 84 | let CTR = new Uint8Array([ 85 | ...nonce, 86 | ...file_id_array.reverse(), 87 | 0, 88 | 0, 89 | 0, 90 | 0, 91 | ]); 92 | let plain_filename_array = await crypto.subtle.decrypt( 93 | { name: "AES-CTR", counter: CTR, length: 128 }, 94 | key, 95 | new Uint8Array(name_encrypted_array) 96 | ); 97 | let dec = new TextDecoder(); 98 | return dec.decode(plain_filename_array); 99 | } 100 | 101 | function throwError(message: string): never { 102 | throw new Error(message); 103 | } 104 | 105 | (async function () { 106 | let file_list = 107 | document.getElementById("file-list") ?? 108 | throwError("file-list not found"); 109 | let notice_area = 110 | document.getElementById("notice") ?? throwError("notice not found"); 111 | let cli_command_input = 112 | (document.getElementById("cli-command") as HTMLInputElement) ?? 113 | throwError("cli-command not found"); 114 | const serviceWorker = navigator.serviceWorker; 115 | if (serviceWorker === undefined) { 116 | file_list.innerText = 117 | "Your browser dose not support service-worker or you are in private window, please switch to Chrome/Edge/Firefox"; 118 | return; 119 | } 120 | let reg = await serviceWorker.register("/sw.js", { scope: "/" }); 121 | let current_downloading = 0; 122 | // window.addEventListener("beforeunload", function (event) { 123 | // if (current_downloading > 0) { 124 | // event.preventDefault(); 125 | // let message = "Leaving pages will stop downloading. Continue?"; 126 | // event.returnValue = message; 127 | // return message; 128 | // } 129 | // }); 130 | serviceWorker.addEventListener("message", function (event) { 131 | if (event.data.request === "download_finished") { 132 | current_downloading -= 1; 133 | } 134 | }); 135 | let path_list = location.pathname.split("/"); 136 | let read_id = path_list[path_list.length - 1]; 137 | if (read_id === "") { 138 | (document.querySelector("h1") ?? throwError("h1 not found")).innerText = 139 | "404 NOT FOUND"; 140 | file_list.innerText = "there is nothing here"; 141 | return; 142 | } 143 | let [key_base64, nonce_base64] = location.hash.substring(1).split("."); 144 | if (key_base64.length !== 43 || nonce_base64.length !== 11) { 145 | file_list.innerText = "oops, share link is broken"; 146 | return; 147 | } 148 | let { key, key_hex, nonce, nonce_hex } = await recover_aes_ctr_key( 149 | key_base64, 150 | nonce_base64 151 | ); 152 | let response = await fetch("/api/v1/share/" + read_id); 153 | if (response.status >= 400) { 154 | (document.querySelector("h1") ?? throwError("h1 not found")).innerText = 155 | "404 NOT FOUND"; 156 | file_list.innerText = "there is nothing here"; 157 | return; 158 | } 159 | let list = await response.json(); 160 | file_list.innerText = ""; 161 | for (let i = 0; ; i++) { 162 | if (serviceWorker.controller !== null) { 163 | break; 164 | } 165 | await sleep(100); 166 | if (i >= 50) { 167 | file_list.innerText = "ERROR: service worker controller is null"; 168 | return; 169 | } 170 | } 171 | for (let file_info of list.value) { 172 | let encrypted_filename = file_info.name; 173 | if (!encrypted_filename.endsWith(".send")) { 174 | continue; 175 | } 176 | let info = document.createElement("div"); 177 | let a = document.createElement("span"); 178 | a.classList.add("link-like"); 179 | let download_url = file_info["@microsoft.graph.downloadUrl"]; 180 | let [file_id, file_name_encrypted, ext] = file_info.name.split(".", 2); 181 | file_id = Number(file_id); 182 | let filename = await decrypt_file_name( 183 | key, 184 | file_name_encrypted, 185 | nonce, 186 | file_id 187 | ); 188 | a.addEventListener("click", async function () { 189 | current_downloading += 1; 190 | await serviceWorker.controller?.postMessage({ 191 | request: "add_file", 192 | file_info: { 193 | file_path: encrypted_filename, 194 | download_url: download_url, 195 | key: key, 196 | nonce: nonce, 197 | filename: filename, 198 | file_size: file_info.size, 199 | file_id: file_id, 200 | }, 201 | }); 202 | let file_link = document.createElement("a"); 203 | file_link.href = "/s/download/" + encrypted_filename; 204 | file_link.click(); 205 | }); 206 | setInterval(function () { 207 | // keep service work alive 208 | serviceWorker.controller?.postMessage({ request: "ping" }); 209 | }, 100); 210 | a.innerText = filename; 211 | let readable_size = humanFileSize(file_info.size, true, 2); 212 | let size_node = document.createTextNode(` (${readable_size}) `); 213 | info.append(a); 214 | info.append(size_node); 215 | let nonce_offset_hex = (file_id * 2 + 1).toString(16).padStart(8, "0"); 216 | let cli_downloader = document.createElement("span"); 217 | cli_downloader.innerText = "CLI"; 218 | cli_downloader.classList.add("link-like"); 219 | cli_downloader.addEventListener("click", async function () { 220 | let cli_command = `wget "${download_url}" -O - | openssl enc -d -aes-256-ctr -K "${key_hex}" -iv "${nonce_hex}${nonce_offset_hex}00000000" -out "${filename}"`; 221 | await navigator.clipboard.writeText(cli_command); 222 | cli_command_input.value = cli_command; 223 | cli_command_input.hidden = false; 224 | cli_command_input.select(); 225 | notice_area.innerText = "command copied"; 226 | setTimeout(function () { 227 | notice_area.innerText = ""; 228 | }, 2000); 229 | }); 230 | info.append(cli_downloader); 231 | info.classList.add("file-item"); 232 | file_list.append(info); 233 | } 234 | })(); 235 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= 2 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 3 | github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= 4 | github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= 5 | github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= 6 | github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= 7 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= 8 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= 13 | github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= 14 | github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= 15 | github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= 16 | github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= 17 | github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= 18 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 19 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 20 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 21 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 22 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 23 | github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= 24 | github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= 25 | github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= 26 | github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 27 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 28 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 29 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 30 | github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 31 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 32 | github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= 33 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 34 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 35 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 36 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 37 | github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= 38 | github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= 39 | github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= 40 | github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= 41 | github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= 42 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 43 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 44 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 45 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 46 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 47 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 48 | github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= 49 | github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= 50 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 51 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 52 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= 53 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 54 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 55 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 56 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 57 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 58 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 59 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 60 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 61 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 62 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 63 | github.com/stretchr/testify v1.8.3 h1:RP3t2pwF7cMEbC1dqtB6poj3niw/9gnV4Cjg5oW5gtY= 64 | github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 65 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 66 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 67 | github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= 68 | github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 69 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 70 | golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= 71 | golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 72 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 73 | golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM= 74 | golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= 75 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= 76 | golang.org/x/net v0.11.0 h1:Gi2tvZIJyBtO9SDr1q9h5hEQCp/4L2RQ+ar0qjx2oNU= 77 | golang.org/x/net v0.11.0/go.mod h1:2L/ixqYpgIVXmeoSA/4Lu7BzTG4KIyPIryS4IsOd1oQ= 78 | golang.org/x/oauth2 v0.9.0 h1:BPpt2kU7oMRq3kCHAA1tbSEshXRw1LpG2ztgDwrzuAs= 79 | golang.org/x/oauth2 v0.9.0/go.mod h1:qYgFZaFiu6Wg24azG8bdV52QJXJGbZzIIsRCdVKzbLw= 80 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 81 | golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 82 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 83 | golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= 84 | golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 85 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 86 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 87 | golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= 88 | golang.org/x/text v0.10.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 89 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 90 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 91 | google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= 92 | google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= 93 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 94 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 95 | google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= 96 | google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 97 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 98 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 99 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 100 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 101 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 102 | rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= 103 | -------------------------------------------------------------------------------- /assets/homepage.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | function humanFileSize(bytes: number, si = false, dp = 1) { 4 | const thresh = si ? 1000 : 1024; 5 | if (Math.abs(bytes) < thresh) { 6 | return bytes + " B"; 7 | } 8 | const units = si 9 | ? ["kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"] 10 | : ["KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]; 11 | let u = -1; 12 | const r = 10 ** dp; 13 | do { 14 | bytes /= thresh; 15 | ++u; 16 | } while ( 17 | Math.round(Math.abs(bytes) * r) / r >= thresh && 18 | u < units.length - 1 19 | ); 20 | return bytes.toFixed(dp) + " " + units[u]; 21 | } 22 | 23 | async function generate_aes_ctr_keys() { 24 | let key_array = new Uint8Array(32); // 256 bits 25 | let nonce_array = new Uint8Array(8); // 64 bits 26 | crypto.getRandomValues(key_array); 27 | crypto.getRandomValues(nonce_array); 28 | let key = await crypto.subtle.importKey( 29 | "raw", 30 | key_array, 31 | { 32 | name: "AES-CTR", 33 | }, 34 | false, 35 | ["encrypt", "decrypt"] 36 | ); 37 | return { 38 | key: key, 39 | key_base64: btoa(String.fromCharCode.apply(null, key_array)) 40 | .replaceAll("+", "-") 41 | .replaceAll("/", "_") 42 | .replaceAll("=", ""), 43 | nonce: nonce_array, 44 | nonce_base64: btoa(String.fromCharCode.apply(null, nonce_array)) 45 | .replaceAll("+", "-") 46 | .replaceAll("/", "_") 47 | .replaceAll("=", ""), 48 | }; 49 | } 50 | 51 | async function encrypt_file_name( 52 | key: CryptoKey, 53 | filename: string, 54 | nonce: Uint8Array, 55 | file_id: number 56 | ) { 57 | let file_id_array = new Uint8Array(new Uint32Array([file_id * 2]).buffer); 58 | let CTR = new Uint8Array([ 59 | ...nonce, 60 | ...file_id_array.reverse(), 61 | 0, 62 | 0, 63 | 0, 64 | 0, 65 | ]); 66 | let enc = new TextEncoder(); 67 | let encrypted_filename_array = await crypto.subtle.encrypt( 68 | { 69 | name: "AES-CTR", 70 | counter: CTR, 71 | length: 128, 72 | }, 73 | key, 74 | enc.encode(filename) 75 | ); 76 | let encrypted_filename_base64 = btoa( 77 | String.fromCharCode.apply( 78 | null, 79 | new Uint8Array(encrypted_filename_array) 80 | ) 81 | ) 82 | .replaceAll("+", "-") 83 | .replaceAll("/", "_") 84 | .replaceAll("=", ""); 85 | return file_id + "." + encrypted_filename_base64; 86 | } 87 | 88 | async function encrypt_file_part( 89 | key: CryptoKey, 90 | plain: BufferSource, 91 | nonce: Uint8Array, 92 | file_id: number, 93 | counter: number 94 | ) { 95 | let counter_array = new Uint8Array(new Uint32Array([counter]).buffer); 96 | let file_id_array = new Uint8Array( 97 | new Uint32Array([file_id * 2 + 1]).buffer 98 | ); 99 | let CTR = new Uint8Array([ 100 | ...nonce, 101 | ...file_id_array.reverse(), 102 | ...counter_array.reverse(), 103 | ]); 104 | let cipher = await crypto.subtle.encrypt( 105 | { 106 | name: "AES-CTR", 107 | counter: CTR, 108 | length: 128, 109 | }, 110 | key, 111 | plain 112 | ); 113 | return cipher; 114 | } 115 | 116 | function share_history_append( 117 | name: string, 118 | read_id: string, 119 | write_id: string, 120 | keys: string 121 | ) { 122 | let history_json = localStorage.sender_history; 123 | if (history_json === undefined) { 124 | history_json = "[]"; 125 | } 126 | let history = JSON.parse(history_json); 127 | history.push({ 128 | name: name, 129 | read_id: read_id, 130 | write_id: write_id, 131 | keys: keys, 132 | }); 133 | localStorage.sender_history = JSON.stringify(history); 134 | } 135 | 136 | function getElementByIdOrThrowError(id: string) { 137 | let element = document.getElementById(id); 138 | if (element === null) { 139 | throw new Error(`Element with id ${id} not found`); 140 | } 141 | return element; 142 | } 143 | 144 | interface Share { 145 | name: string; 146 | read_id: string; 147 | write_id: string; 148 | keys: string; 149 | } 150 | 151 | (function () { 152 | const serviceWorker = navigator.serviceWorker; 153 | serviceWorker.register("/sw.js", { scope: "/" }); 154 | 155 | const size_unit = 327680; 156 | const max_size = 192; 157 | let read_id: string, write_id: string; 158 | let key: CryptoKey, 159 | key_base64: string, 160 | nonce: Uint8Array, 161 | nonce_base64: string; 162 | let history_link = getElementByIdOrThrowError("history-link"); 163 | let share_history = getElementByIdOrThrowError("share-history"); 164 | let sharing = getElementByIdOrThrowError("sharing"); 165 | let select_button = getElementByIdOrThrowError("select-button"); 166 | let file_list = getElementByIdOrThrowError("file-list"); 167 | let upload_button = getElementByIdOrThrowError( 168 | "upload-button" 169 | ) as HTMLButtonElement; 170 | let main_display = getElementByIdOrThrowError("main-display"); 171 | let files_selected: { 172 | file: File; 173 | dom: HTMLTableRowElement; 174 | process: HTMLTableCellElement; 175 | file_id: number; 176 | }[] = []; 177 | let file_id_counter = 0; 178 | history_link.addEventListener("click", function () { 179 | share_history.textContent = ""; 180 | let history_json = localStorage.sender_history; 181 | if (!history_json) { 182 | share_history.innerText = "No sharing history"; 183 | return; 184 | } 185 | let history: Share[] = JSON.parse(history_json); 186 | for (let share of history) { 187 | let info = document.createElement("div"); 188 | let read = document.createElement("a"); 189 | info.innerText = share.name; 190 | read.innerText = "view"; 191 | read.href = "/s/" + share.read_id + "#" + share.keys; 192 | read.target = "_blank"; 193 | info.append(document.createTextNode(" ")); 194 | info.append(read); 195 | info.classList.add("share-item"); 196 | share_history.append(info); 197 | } 198 | }); 199 | select_button.addEventListener("click", function () { 200 | let selector = document.createElement("input"); 201 | selector.type = "file"; 202 | selector.multiple = true; 203 | selector.addEventListener("change", function () { 204 | if (!selector.files) { 205 | return; 206 | } 207 | for (let f of selector.files) { 208 | let row = document.createElement("tr"); 209 | let name = document.createElement("td"); 210 | name.innerText = f.name; 211 | row.append(name); 212 | let size = document.createElement("td"); 213 | size.innerText = "(" + humanFileSize(f.size, true, 2) + ")"; 214 | row.append(size); 215 | let file_upload_process = document.createElement("td"); 216 | row.append(file_upload_process); 217 | file_list.append(row); 218 | files_selected.push({ 219 | file: f, 220 | dom: row, 221 | process: file_upload_process, 222 | file_id: file_id_counter, 223 | }); 224 | file_id_counter += 1; 225 | } 226 | upload_button.disabled = false; 227 | }); 228 | selector.click(); 229 | }); 230 | upload_button.addEventListener("click", async function () { 231 | upload_button.disabled = true; 232 | upload_button.innerText = "Uploading..."; 233 | select_button.innerText = "Add more files"; 234 | if (write_id === undefined) { 235 | let response = await fetch("/api/v1/share", { 236 | method: "POST", 237 | }); 238 | let payload = await response.json(); 239 | read_id = payload.read_id; 240 | write_id = payload.write_id; 241 | ({ key, key_base64, nonce, nonce_base64 } = 242 | await generate_aes_ctr_keys()); 243 | } 244 | let files = files_selected; 245 | files_selected = []; 246 | let total_file_count = files.length; 247 | if (total_file_count === 0) { 248 | window.alert("select file!"); 249 | return; 250 | } 251 | for (let i = 0; i < total_file_count; i++) { 252 | let file_upload = files[i].file; 253 | let encrypted_filename = await encrypt_file_name( 254 | key, 255 | file_upload.name, 256 | nonce, 257 | files[i].file_id 258 | ); 259 | let response = await fetch("/api/v1/attachment", { 260 | method: "POST", 261 | body: JSON.stringify({ 262 | name: encrypted_filename + ".send", 263 | write_id: write_id, 264 | }), 265 | }); 266 | let payload = await response.json(); 267 | let upload_url = payload.upload_url; 268 | if (upload_url === undefined) { 269 | window.alert("upload error"); 270 | return; 271 | } 272 | files[i].process.innerText = "0 %"; 273 | let file_size = file_upload.size; 274 | let slices_count = Math.floor(file_size / size_unit); 275 | let transfer_count = 4; 276 | for (let j = 0; j <= slices_count; ) { 277 | let begin = j * size_unit; 278 | let end: number; 279 | if (j + transfer_count > slices_count) { 280 | end = file_size - 1; 281 | } else { 282 | end = (j + transfer_count) * size_unit - 1; 283 | } 284 | let file_part = await file_upload 285 | .slice(begin, end + 1) 286 | .arrayBuffer(); 287 | let encrypted_part = await encrypt_file_part( 288 | key, 289 | file_part, 290 | nonce, 291 | files[i].file_id, 292 | begin / 16 293 | ); 294 | let start_time = performance.now(); 295 | await (function () { 296 | return new Promise(function (resolve, reject) { 297 | let upload_part = new XMLHttpRequest(); 298 | upload_part.open("PUT", upload_url); 299 | upload_part.setRequestHeader( 300 | "Content-Range", 301 | `bytes ${begin}-${end}/${file_size}` 302 | ); 303 | upload_part.upload.addEventListener( 304 | "progress", 305 | function (e) { 306 | let percentage = 307 | (100 * (begin + e.loaded)) / file_size; 308 | files[i].process.innerText = 309 | percentage.toFixed(2) + " %"; 310 | } 311 | ); 312 | upload_part.addEventListener("load", function () { 313 | if (this.status >= 200 && this.status < 300) { 314 | resolve(upload_part.response); 315 | } else { 316 | reject(upload_part.response); 317 | } 318 | }); 319 | upload_part.addEventListener("error", function (e) { 320 | reject(e); 321 | }); 322 | upload_part.send(encrypted_part); 323 | }); 324 | })(); 325 | j += transfer_count; 326 | let transfer_time = performance.now() - start_time; 327 | transfer_count = Math.ceil( 328 | (transfer_count * 10000) / transfer_time 329 | ); // adjust each part to 10s 330 | if (transfer_count > max_size) { 331 | transfer_count = max_size; 332 | } 333 | } 334 | files[i].process.innerText = "100.00 %"; 335 | } 336 | main_display.innerText = 337 | "your share has been created. you can send this link to your friends"; 338 | share_history_append( 339 | files[0].file.name + (files.length > 1 ? " and other files" : ""), 340 | read_id, 341 | write_id, 342 | key_base64 + "." + nonce_base64 343 | ); 344 | let share_url = 345 | location.origin + 346 | "/s/" + 347 | read_id + 348 | "#" + 349 | key_base64 + 350 | "." + 351 | nonce_base64; 352 | let link = document.createElement("a"); 353 | link.innerText = "Open Link"; 354 | link.href = share_url; 355 | link.target = "_blank"; 356 | let copy_link = document.createElement("span"); 357 | copy_link.innerText = "Copy Link"; 358 | copy_link.classList.add("link-like"); 359 | copy_link.addEventListener("click", function () { 360 | navigator.clipboard.writeText(share_url); 361 | copy_link.innerText = "Link Copied"; 362 | }); 363 | main_display.append(document.createElement("br")); 364 | main_display.append(link); 365 | main_display.append(document.createTextNode(" ")); 366 | main_display.append(copy_link); 367 | upload_button.disabled = false; 368 | upload_button.innerText = "Upload"; 369 | window.alert("Upload Successfully!"); 370 | }); 371 | })(); 372 | -------------------------------------------------------------------------------- /onesend.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "crypto/hmac" 7 | cryptoRand "crypto/rand" 8 | "crypto/sha1" 9 | "embed" 10 | "encoding/base64" 11 | "encoding/json" 12 | "errors" 13 | "fmt" 14 | "math/rand" 15 | "net/http" 16 | "net/url" 17 | "os" 18 | "runtime" 19 | "strings" 20 | "time" 21 | 22 | "github.com/BurntSushi/toml" 23 | "github.com/gin-gonic/gin" 24 | "github.com/robfig/cron/v3" 25 | "golang.org/x/oauth2" 26 | ) 27 | 28 | //go:embed homepage.html 29 | var publicIndex []byte 30 | 31 | //go:embed auth.html 32 | var publicAuth []byte 33 | 34 | //go:embed receive.html 35 | var publicReceive []byte 36 | 37 | //go:embed virtual-downloader.js 38 | var publicVirtualDownloader []byte 39 | 40 | //go:embed assets 41 | var publicAssets embed.FS 42 | 43 | var defaultClientID = "5114220a-e543-4bc0-b1aa-c84fced70454" 44 | var defaultClientSecret = "VMV8Q~dHlk2uuYcTddJmXtFYrPIhvKDgetassb-G" 45 | 46 | type sessionCreate struct { 47 | WriteID string `json:"write_id"` 48 | Name string `json:"name"` 49 | } 50 | 51 | type ConfigFile struct { 52 | Onedrive struct { 53 | ClientID string 54 | ClientSecret string 55 | AccountArea string 56 | Drive string 57 | SavePath string 58 | } 59 | Sender struct { 60 | Listen string 61 | } 62 | } 63 | 64 | type IDStruct struct { 65 | ID string `json:"id"` 66 | } 67 | 68 | type uploadURLStruct struct { 69 | UploadUrl string `json:"uploadUrl"` 70 | } 71 | 72 | type folderChildren struct { 73 | Message string `json:"message"` 74 | Value []struct { 75 | Name string `json:"name"` 76 | Size int64 `json:"size"` 77 | DownloadUrl string `json:"@microsoft.graph.downloadUrl"` 78 | } `json:"value"` 79 | } 80 | 81 | type refreshTokenStruct struct { 82 | AccessToken string `json:"access_token"` 83 | RefreshToken string `json:"refresh_token"` 84 | } 85 | 86 | // generate random string 87 | func random6() string { 88 | letters := []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") 89 | r := make([]byte, 6) 90 | for i := range r { 91 | r[i] = letters[rand.Intn(len(letters))] 92 | } 93 | return string(r) 94 | } 95 | 96 | func main() { 97 | err := entry() 98 | if err != nil { 99 | println(err.Error()) 100 | if runtime.GOOS == "windows" { 101 | fmt.Println("press enter to continue...") 102 | _, _ = fmt.Scanln() 103 | } 104 | os.Exit(1) 105 | } 106 | } 107 | 108 | func entry() error { 109 | // read config file 110 | var configFile ConfigFile 111 | _, err := toml.DecodeFile("config.toml", &configFile) 112 | if err != nil { 113 | return errors.New("error parsing configuration file " + err.Error()) 114 | } 115 | 116 | var authURL, tokenURL, apiBase string 117 | switch configFile.Onedrive.AccountArea { 118 | case "global": 119 | authURL = "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" 120 | tokenURL = "https://login.microsoftonline.com/common/oauth2/v2.0/token" 121 | apiBase = "https://graph.microsoft.com/v1.0" 122 | case "cn": 123 | authURL = "https://login.chinacloudapi.cn/common/oauth2/v2.0/authorize" 124 | tokenURL = "https://login.chinacloudapi.cn/common/oauth2/v2.0/token" 125 | apiBase = "https://microsoftgraph.chinacloudapi.cn/v1.0" 126 | case "gov": 127 | authURL = "https://login.microsoftonline.us/common/oauth2/v2.0/authorize" 128 | tokenURL = "https://login.microsoftonline.us/common/oauth2/v2.0/token" 129 | apiBase = "https://graph.microsoft.us/v1.0" 130 | case "de": 131 | authURL = "https://login.microsoftonline.de/common/oauth2/v2.0/authorize" 132 | tokenURL = "https://login.microsoftonline.de/common/oauth2/v2.0/token" 133 | apiBase = "https://graph.microsoft.de/v1.0" 134 | default: 135 | return errors.New("unknown account area " + configFile.Onedrive.AccountArea) 136 | } 137 | // padding slash to path 138 | if configFile.Onedrive.SavePath[0] != '/' { 139 | configFile.Onedrive.SavePath = "/" + configFile.Onedrive.SavePath 140 | } 141 | if configFile.Onedrive.SavePath[len(configFile.Onedrive.SavePath)-1] != '/' { 142 | configFile.Onedrive.SavePath = configFile.Onedrive.SavePath + "/" 143 | } 144 | 145 | // get drive 146 | drive := configFile.Onedrive.Drive 147 | if drive == "" { 148 | drive = "/me/drive" 149 | } 150 | 151 | // get secret 152 | secret, err := getSecret() 153 | if err != nil { 154 | return err 155 | } 156 | shortMac := getShortMacFunc(secret) 157 | 158 | clientID := defaultClientID 159 | if len(configFile.Onedrive.ClientID) != 0 { 160 | clientID = configFile.Onedrive.ClientID 161 | } 162 | clientSecret := defaultClientSecret 163 | if len(configFile.Onedrive.ClientSecret) != 0 { 164 | clientSecret = configFile.Onedrive.ClientSecret 165 | } 166 | 167 | var client *http.Client = nil // waiting for token 168 | 169 | // read token 170 | savedRefreshToken, err := os.ReadFile("token.txt") 171 | if err == nil && len(savedRefreshToken) != 0 { 172 | // saved token available 173 | client, err = setupOAuthClient(string(savedRefreshToken), clientID, clientSecret, authURL, tokenURL) 174 | if err != nil { 175 | return errors.New("error setting up oauth client " + err.Error()) 176 | } 177 | // test create file 178 | req, err := http.NewRequest("PUT", fmt.Sprintf("%s%s/root:%smeta.txt:/content", apiBase, drive, configFile.Onedrive.SavePath), strings.NewReader("this folder is managed by onesender")) 179 | if err != nil { 180 | return errors.New("error test create file " + err.Error()) 181 | } 182 | res, err := client.Do(req) 183 | if err != nil { 184 | return errors.New("error test create file " + err.Error()) 185 | } 186 | b := new(bytes.Buffer) 187 | _, err = b.ReadFrom(res.Body) 188 | if err != nil { 189 | return errors.New("error test create file " + err.Error()) 190 | } 191 | if res.StatusCode >= 400 { 192 | // fail 193 | return errors.New("error test create file " + b.String()) 194 | } 195 | } else { 196 | fmt.Println("token is not ready, please visit the site and follow the instructions") 197 | } 198 | 199 | // cache folder id 200 | folders := make(map[string]string) 201 | 202 | // setup web server 203 | gin.SetMode(gin.ReleaseMode) 204 | r := gin.Default() 205 | r.GET("/", func(c *gin.Context) { 206 | if client == nil { 207 | c.Redirect(302, "/auth.html") 208 | return 209 | } 210 | c.Header("Cache-Control", "public, max-age=604800") 211 | c.Data(200, "text/html", publicIndex) 212 | }) 213 | r.GET("/index.html", func(c *gin.Context) { 214 | if client == nil { 215 | c.Redirect(302, "/auth.html") 216 | return 217 | } 218 | c.Header("Cache-Control", "public, max-age=604800") 219 | c.Data(200, "text/html", publicIndex) 220 | }) 221 | r.GET("/auth.html", func(c *gin.Context) { 222 | if client != nil { 223 | c.Redirect(302, "/") 224 | return 225 | } 226 | c.Header("Cache-Control", "public, max-age=604800") 227 | c.Data(200, "text/html", publicAuth) 228 | }) 229 | r.GET("/s/:read_id", func(c *gin.Context) { 230 | c.Header("Cache-Control", "public, max-age=604800") 231 | c.Data(200, "text/html", publicReceive) 232 | }) 233 | r.GET("/sw.js", func(c *gin.Context) { 234 | c.Header("Cache-Control", "public, max-age=604800") 235 | c.Data(200, "application/javascript", publicVirtualDownloader) 236 | }) 237 | r.GET("/assets/*filename", func(c *gin.Context) { 238 | filename := c.Param("filename") 239 | c.Header("Cache-Control", "public, max-age=2592000") 240 | c.FileFromFS("/assets/"+filename, http.FS(publicAssets)) 241 | }) 242 | r.GET("/robots.txt", func(c *gin.Context) { 243 | c.Header("Cache-Control", "public, max-age=2592000") 244 | c.Data(200, "text/plain", []byte("User-agent: *\nDisallow: /")) 245 | }) 246 | r.GET("/oauth", func(c *gin.Context) { 247 | if client != nil { 248 | // pretend this is not a endpoint 249 | c.Data(404, "text/plain", []byte("404 Not Found")) 250 | return 251 | } 252 | code := c.Query("code") 253 | if len(code) == 0 { 254 | c.Data(404, "text/plain", []byte("404 Not Found")) 255 | return 256 | } 257 | // get token 258 | body := url.Values{} 259 | body.Set("client_id", clientID) 260 | body.Set("client_secret", clientSecret) 261 | body.Set("grant_type", "authorization_code") 262 | body.Set("code", code) 263 | body.Set("redirect_uri", "https://yuudi.github.io/onesend/oauth/index.html") 264 | body.Set("scope", "offline_access files.readwrite.all") 265 | res, err := http.Post(tokenURL, "application/x-www-form-urlencoded", strings.NewReader(body.Encode())) 266 | if err != nil { 267 | c.Data(400, "text/plain", []byte("error fetching token "+err.Error())) 268 | return 269 | } 270 | var tokens refreshTokenStruct 271 | b := new(bytes.Buffer) 272 | _, err = b.ReadFrom(res.Body) 273 | if err != nil { 274 | c.Data(500, "text/plain", []byte("error fetching token "+err.Error())) 275 | return 276 | } 277 | err = json.Unmarshal(b.Bytes(), &tokens) 278 | if err != nil { 279 | c.Data(500, "text/plain", []byte("error fetching token "+err.Error())) 280 | return 281 | } 282 | if err != nil { 283 | c.Data(500, "text/plain", []byte("error fetching token "+err.Error())) 284 | return 285 | } 286 | err = os.WriteFile("token.txt", []byte(tokens.RefreshToken), 0600) 287 | if err != nil { 288 | c.Data(500, "text/plain", []byte("error saving token "+err.Error())) 289 | return 290 | } 291 | client, err = setupOAuthClient(tokens.RefreshToken, clientID, clientSecret, authURL, tokenURL) 292 | if err != nil { 293 | c.Data(500, "text/plain", []byte("error setting up oauth client "+err.Error())) 294 | return 295 | } 296 | err = os.WriteFile("token.txt", []byte(tokens.RefreshToken), 0600) 297 | if err != nil { 298 | c.Data(500, "text/plain", []byte("error saving token "+err.Error())) 299 | return 300 | } 301 | c.Data(201, "text/plain", []byte("success")) 302 | }) 303 | r.POST("/api/v1/share", func(c *gin.Context) { 304 | if client == nil { 305 | c.Data(500, "text/plain", []byte("not ready")) 306 | return 307 | } 308 | now := time.Now() 309 | dateFolder := fmt.Sprintf("%d.%02d.%02d", now.Year(), int(now.Month()), now.Day()) 310 | folderID, ok := folders[dateFolder] 311 | if !ok { 312 | // get folder id 313 | res, err := client.Get(fmt.Sprintf("%s%s/root:%s%s", apiBase, drive, configFile.Onedrive.SavePath, dateFolder)) 314 | if err != nil { 315 | c.Data(500, "text/plain", []byte("error fetching folder "+err.Error())) 316 | return 317 | } 318 | b := new(bytes.Buffer) 319 | _, err = b.ReadFrom(res.Body) 320 | if err != nil { 321 | c.Data(500, "text/plain", []byte("error reading response fetching folder "+err.Error())) 322 | return 323 | } 324 | if res.StatusCode == 404 { 325 | payload := fmt.Sprintf("{\"name\":\"%s\",\"folder\":{},\"@microsoft.graph.conflictBehavior\":\"rename\"}", dateFolder) 326 | resCreate, err := client.Post(fmt.Sprintf("%s%s/root:%s:/children", apiBase, drive, strings.TrimSuffix(configFile.Onedrive.SavePath, "/")), "application/json", strings.NewReader(payload)) 327 | if err != nil { 328 | c.Data(500, "text/plain", []byte("error creating folder "+err.Error())) 329 | return 330 | } 331 | b := new(bytes.Buffer) 332 | _, err = b.ReadFrom(resCreate.Body) 333 | if err != nil { 334 | c.Data(500, "text/plain", []byte("error reading response creating folder "+err.Error())) 335 | return 336 | } 337 | var idStruct IDStruct 338 | err = json.Unmarshal(b.Bytes(), &idStruct) 339 | if err != nil || idStruct.ID == "" { 340 | c.Data(500, "text/plain", []byte("error creating folder "+b.String())) 341 | return 342 | } 343 | folderID = idStruct.ID 344 | folders[dateFolder] = folderID 345 | } else if res.StatusCode == 200 { 346 | var idStruct IDStruct 347 | err = json.Unmarshal(b.Bytes(), &idStruct) 348 | if err != nil || idStruct.ID == "" { 349 | c.Data(500, "text/plain", []byte("error fetching folder "+b.String())) 350 | return 351 | } 352 | folderID = idStruct.ID 353 | folders[dateFolder] = folderID 354 | } else { 355 | c.Data(500, "text/plain", []byte("error fetching folder "+b.String())) 356 | return 357 | } 358 | } 359 | payload := fmt.Sprintf("{\"name\":\"%s\",\"folder\":{},\"@microsoft.graph.conflictBehavior\":\"rename\"}", random6()) 360 | res, err := client.Post( 361 | fmt.Sprintf("%s%s/items/%s/children", apiBase, drive, folderID), 362 | "application/json", 363 | strings.NewReader(payload), 364 | ) 365 | if err != nil { 366 | c.Data(500, "text/plain", []byte("error create file "+err.Error())) 367 | return 368 | } 369 | b := new(bytes.Buffer) 370 | if _, err = b.ReadFrom(res.Body); err != nil { 371 | c.Data(500, "text/plain", []byte("error read response "+err.Error())) 372 | return 373 | } 374 | if res.StatusCode != 201 { 375 | c.Data(500, "text/plain", []byte("error create file "+b.String())) 376 | return 377 | } 378 | var idStruct IDStruct 379 | err = json.Unmarshal(b.Bytes(), &idStruct) 380 | if err != nil { 381 | c.Data(500, "text/plain", []byte("error parsing json response "+err.Error())) 382 | return 383 | } 384 | //id:=idStruct.ID 385 | rID := "R." + idStruct.ID 386 | wID := "W." + idStruct.ID 387 | rSum := shortMac([]byte(rID)) 388 | wSum := shortMac([]byte(wID)) 389 | c.JSON(201, gin.H{ 390 | "read_id": rID + "." + rSum, 391 | "write_id": wID + "." + wSum, 392 | }) 393 | }) 394 | r.POST("/api/v1/attachment", func(c *gin.Context) { 395 | if client == nil { 396 | c.Data(500, "text/plain", []byte("not ready")) 397 | return 398 | } 399 | var sc sessionCreate 400 | if err := c.BindJSON(&sc); err != nil { 401 | c.Data(400, "text/plain", []byte("request json error "+err.Error())) 402 | return 403 | } 404 | // check signing 405 | writeID := strings.Split(sc.WriteID, ".") 406 | if len(writeID) != 3 { 407 | c.JSON(400, gin.H{ 408 | "error": "invalid write_id", 409 | }) 410 | return 411 | } 412 | if writeID[0] != "W" { 413 | c.JSON(400, gin.H{ 414 | "error": "invalid write_id", 415 | }) 416 | return 417 | } 418 | signed := shortMac([]byte("W." + writeID[1])) 419 | if writeID[2] != signed { 420 | c.JSON(400, gin.H{ 421 | "error": "invalid write_id", 422 | }) 423 | return 424 | } 425 | if !strings.HasSuffix(sc.Name, ".send") { 426 | c.JSON(400, gin.H{ 427 | "error": "invalid filename", 428 | }) 429 | return 430 | } 431 | req, err := http.NewRequest("POST", fmt.Sprintf("%s%s/items/%s:/%s:/createUploadSession", apiBase, drive, writeID[1], sc.Name), strings.NewReader("")) 432 | if err != nil { 433 | c.Data(500, "text/plain", []byte("error create file "+err.Error())) 434 | return 435 | } 436 | res, err := client.Do(req) 437 | if err != nil { 438 | c.Data(500, "text/plain", []byte("error create file "+err.Error())) 439 | return 440 | } 441 | b := new(bytes.Buffer) 442 | if _, err = b.ReadFrom(res.Body); err != nil { 443 | c.Data(500, "text/plain", []byte("error read response "+err.Error())) 444 | return 445 | } 446 | if res.StatusCode >= 400 { 447 | c.Data(500, "text/plain", []byte("error read response "+b.String())) 448 | return 449 | } 450 | var uploadUrl uploadURLStruct 451 | err = json.Unmarshal(b.Bytes(), &uploadUrl) 452 | if err != nil { 453 | c.Data(500, "text/plain", []byte("error parsing json response "+err.Error())) 454 | return 455 | } 456 | c.JSON(201, gin.H{ 457 | "upload_url": uploadUrl.UploadUrl, 458 | }) 459 | }) 460 | r.GET("/api/v1/share/:read_id", func(c *gin.Context) { 461 | if client == nil { 462 | c.Data(500, "text/plain", []byte("not ready")) 463 | return 464 | } 465 | readID := strings.Split(c.Param("read_id"), ".") 466 | if len(readID) != 3 { 467 | c.JSON(400, gin.H{ 468 | "error": "invalid read_id", 469 | }) 470 | return 471 | } 472 | if !(readID[0] == "R" || readID[0] == "W") { 473 | c.JSON(400, gin.H{ 474 | "error": "invalid read_id", 475 | }) 476 | return 477 | } 478 | signed := shortMac([]byte(readID[0] + "." + readID[1])) 479 | if readID[2] != signed { 480 | c.JSON(400, gin.H{ 481 | "error": "invalid read_id", 482 | }) 483 | return 484 | } 485 | res, err := client.Get(fmt.Sprintf("%s%s/items/%s/children", apiBase, drive, readID[1])) 486 | if err != nil { 487 | c.Data(500, "text/plain", []byte("error fetching folder "+err.Error())) 488 | return 489 | } 490 | b := new(bytes.Buffer) 491 | _, err = b.ReadFrom(res.Body) 492 | if err != nil { 493 | c.Data(500, "text/plain", []byte("error reading response fetching folder "+err.Error())) 494 | return 495 | } 496 | var children folderChildren 497 | err = json.Unmarshal(b.Bytes(), &children) 498 | if err != nil { 499 | c.Data(500, "text/plain", []byte("error parsing json fetching folder "+err.Error())) 500 | return 501 | } 502 | if res.StatusCode >= 400 { 503 | c.JSON(res.StatusCode, children.Message) 504 | return 505 | } 506 | c.Header("Cache-Control", "private, max-age=1800") 507 | c.JSON(200, children) 508 | }) 509 | return r.Run(configFile.Sender.Listen) 510 | } 511 | 512 | func setupOAuthClient(refreshToken, clientID, clientSecret, authURL, tokenURL string) (*http.Client, error) { 513 | // create cron job for refresh token 514 | crontab := cron.New() 515 | ctx := context.Background() 516 | 517 | conf := &oauth2.Config{ 518 | ClientID: clientID, 519 | ClientSecret: clientSecret, 520 | Scopes: []string{"Files.ReadWrite.All", "offline_access"}, 521 | Endpoint: oauth2.Endpoint{ 522 | AuthURL: authURL, 523 | TokenURL: tokenURL, 524 | }, 525 | } 526 | 527 | token := new(oauth2.Token) 528 | token.AccessToken = "" 529 | token.TokenType = "Bearer" 530 | token.RefreshToken = refreshToken 531 | tokenSource := conf.TokenSource(ctx, token) 532 | 533 | _, err := crontab.AddFunc("@daily", func() { 534 | t, e := tokenSource.Token() 535 | if e != nil { 536 | return 537 | } 538 | _ = os.WriteFile("token.txt", []byte(t.RefreshToken), 0644) 539 | }) 540 | if err != nil { 541 | return nil, err 542 | } 543 | crontab.Start() 544 | 545 | return oauth2.NewClient(ctx, tokenSource), nil 546 | } 547 | 548 | func getSecret() ([]byte, error) { 549 | s, err := os.ReadFile("secret.dat") 550 | if err != nil { 551 | if os.IsNotExist(err) { 552 | return createSecret() 553 | } 554 | return nil, err 555 | } 556 | return s, nil 557 | } 558 | 559 | func createSecret() ([]byte, error) { 560 | s := make([]byte, 16) 561 | _, err := cryptoRand.Read(s) 562 | if err != nil { 563 | return nil, err 564 | } 565 | err = os.WriteFile("secret.dat", s, 0600) 566 | if err != nil { 567 | return nil, err 568 | } 569 | return s, nil 570 | } 571 | 572 | func getShortMacFunc(secret []byte) func([]byte) string { 573 | return func(i []byte) string { 574 | mac := hmac.New(sha1.New, secret) 575 | mac.Write(i) 576 | sum := mac.Sum(nil) 577 | sumStr := base64.RawURLEncoding.EncodeToString(sum) 578 | return sumStr[0:8] 579 | } 580 | } 581 | --------------------------------------------------------------------------------