├── .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 |
4 |
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 |
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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
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 |
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 |
--------------------------------------------------------------------------------