bytifi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -0
- package/bin/bytifi.js +189 -0
- package/lib/base64url.js +14 -0
- package/lib/crypto.js +148 -0
- package/lib/upload.js +242 -0
- package/package.json +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Bytifi CLI
|
|
2
|
+
|
|
3
|
+
Official command-line tool for encrypting and uploading files to [Bytifi](https://bytifi.com).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g bytifi
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires **Node.js 18+**.
|
|
12
|
+
|
|
13
|
+
Or install from source:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
git clone https://github.com/jpwcguy/Bytifi.git
|
|
17
|
+
cd Bytifi
|
|
18
|
+
npm link
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Setup
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
export BYTIFI_API_KEY=usk_your_api_key_here
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Create an API key in **Account → API** on bytifi.com.
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bytifi upload ./photo.png
|
|
33
|
+
bytifi upload ./photo.png --expires 60 --json
|
|
34
|
+
bytifi upload ./large.iso -q
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Options
|
|
38
|
+
|
|
39
|
+
| Flag | Description |
|
|
40
|
+
|------|-------------|
|
|
41
|
+
| `-k, --api-key` | API key (default: `BYTIFI_API_KEY`) |
|
|
42
|
+
| `-e, --expires` | Link lifetime in minutes: `5`, `15`, `30`, `60`, `120` |
|
|
43
|
+
| `--delete-on-download` | Delete after first download |
|
|
44
|
+
| `--json` | Machine-readable JSON output |
|
|
45
|
+
| `-q, --quiet` | Print only the share URL |
|
|
46
|
+
| `--mime-type` | Override MIME type detection |
|
|
47
|
+
|
|
48
|
+
## How it works
|
|
49
|
+
|
|
50
|
+
1. Encrypts the file locally with AES-GCM (same format as the website)
|
|
51
|
+
2. Uploads encrypted bytes via the public API
|
|
52
|
+
3. Prints a share URL including `#token=...`
|
|
53
|
+
|
|
54
|
+
The server never receives plaintext or the decryption token.
|
|
55
|
+
|
|
56
|
+
## Development
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
node bin/bytifi.js upload ./file.png --json
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Status
|
|
63
|
+
|
|
64
|
+
WIP — v0.1.0 direct + multipart upload supported.
|
package/bin/bytifi.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import process from 'node:process'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { BytifiApiError, BytifiNetworkError, uploadFile } from '../lib/upload.js'
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
10
|
+
|
|
11
|
+
function printHelp() {
|
|
12
|
+
process.stdout.write(`Bytifi CLI — encrypt and upload files
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
bytifi upload <file> [options]
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
-k, --api-key <key> API key (default: BYTIFI_API_KEY env var)
|
|
19
|
+
-e, --expires <minutes> Link lifetime: 5|15|30|60|120 (default: 30)
|
|
20
|
+
--delete-on-download Remove file after first download
|
|
21
|
+
--json Print machine-readable JSON to stdout
|
|
22
|
+
-q, --quiet Print only the share URL
|
|
23
|
+
--mime-type <type> Override detected MIME type
|
|
24
|
+
--base-url <url> API base URL (default: https://bytifi.com)
|
|
25
|
+
-h, --help Show this help
|
|
26
|
+
|
|
27
|
+
Examples:
|
|
28
|
+
bytifi upload ./photo.png
|
|
29
|
+
BYTIFI_API_KEY=usk_... bytifi upload report.pdf --expires 60 --json
|
|
30
|
+
`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseArgs(argv) {
|
|
34
|
+
const positional = []
|
|
35
|
+
const options = {
|
|
36
|
+
apiKey: process.env.BYTIFI_API_KEY || '',
|
|
37
|
+
expiresInMinutes: 30,
|
|
38
|
+
deleteOnDownload: false,
|
|
39
|
+
json: false,
|
|
40
|
+
quiet: false,
|
|
41
|
+
mimeType: '',
|
|
42
|
+
baseUrl: 'https://bytifi.com',
|
|
43
|
+
help: false,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
47
|
+
const arg = argv[index]
|
|
48
|
+
|
|
49
|
+
if (arg === '--help' || arg === '-h') {
|
|
50
|
+
options.help = true
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (arg === '--json') {
|
|
55
|
+
options.json = true
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (arg === '--quiet' || arg === '-q') {
|
|
60
|
+
options.quiet = true
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (arg === '--delete-on-download') {
|
|
65
|
+
options.deleteOnDownload = true
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (arg === '--api-key' || arg === '-k') {
|
|
70
|
+
options.apiKey = argv[index + 1] || ''
|
|
71
|
+
index += 1
|
|
72
|
+
continue
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (arg === '--expires' || arg === '-e') {
|
|
76
|
+
options.expiresInMinutes = Number(argv[index + 1])
|
|
77
|
+
index += 1
|
|
78
|
+
continue
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (arg === '--mime-type') {
|
|
82
|
+
options.mimeType = argv[index + 1] || ''
|
|
83
|
+
index += 1
|
|
84
|
+
continue
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (arg === '--base-url') {
|
|
88
|
+
options.baseUrl = argv[index + 1] || options.baseUrl
|
|
89
|
+
index += 1
|
|
90
|
+
continue
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (arg.startsWith('-')) {
|
|
94
|
+
throw new Error(`Unknown option: ${arg}`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
positional.push(arg)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return { positional, options }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function validateExpires(minutes) {
|
|
104
|
+
const allowed = new Set([5, 15, 30, 60, 120])
|
|
105
|
+
if (!allowed.has(minutes)) {
|
|
106
|
+
throw new Error('expires must be one of: 5, 15, 30, 60, 120')
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function runUpload(filePath, options) {
|
|
111
|
+
validateExpires(options.expiresInMinutes)
|
|
112
|
+
|
|
113
|
+
if (!options.apiKey) {
|
|
114
|
+
throw new Error('Missing API key. Pass --api-key or set BYTIFI_API_KEY.')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const resolvedPath = path.resolve(filePath)
|
|
118
|
+
await fs.access(resolvedPath)
|
|
119
|
+
|
|
120
|
+
const result = await uploadFile(resolvedPath, {
|
|
121
|
+
apiKey: options.apiKey,
|
|
122
|
+
baseUrl: options.baseUrl,
|
|
123
|
+
expiresInMinutes: options.expiresInMinutes,
|
|
124
|
+
deleteOnDownload: options.deleteOnDownload,
|
|
125
|
+
mimeType: options.mimeType || undefined,
|
|
126
|
+
onProgress: options.quiet || options.json
|
|
127
|
+
? undefined
|
|
128
|
+
: (percent) => {
|
|
129
|
+
process.stderr.write(`Uploading encrypted parts: ${percent}%\n`)
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
if (options.json) {
|
|
134
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (options.quiet) {
|
|
139
|
+
process.stdout.write(`${result.shareUrl}\n`)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
process.stdout.write(`Share URL:\n${result.shareUrl}\n`)
|
|
144
|
+
process.stdout.write(`Download URL:\n${result.downloadUrl}\n`)
|
|
145
|
+
process.stdout.write(`Expires: ${result.expiresAt}\n`)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function exitCodeForError(error) {
|
|
149
|
+
if (error instanceof BytifiNetworkError) return 3
|
|
150
|
+
if (error instanceof BytifiApiError) return 2
|
|
151
|
+
return 1
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function main() {
|
|
155
|
+
const [command, ...rest] = process.argv.slice(2)
|
|
156
|
+
|
|
157
|
+
if (!command || command === '--help' || command === '-h') {
|
|
158
|
+
printHelp()
|
|
159
|
+
process.exit(0)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (command !== 'upload') {
|
|
163
|
+
if (command === 'help') {
|
|
164
|
+
printHelp()
|
|
165
|
+
process.exit(0)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
throw new Error(`Unknown command: ${command}`)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const { positional, options } = parseArgs(rest)
|
|
172
|
+
|
|
173
|
+
if (options.help) {
|
|
174
|
+
printHelp()
|
|
175
|
+
process.exit(0)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const filePath = positional[0]
|
|
179
|
+
if (!filePath) {
|
|
180
|
+
throw new Error('Missing file path. Usage: bytifi upload <file>')
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await runUpload(filePath, options)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
main().catch((error) => {
|
|
187
|
+
process.stderr.write(`${error.message || 'Upload failed.'}\n`)
|
|
188
|
+
process.exit(exitCodeForError(error))
|
|
189
|
+
})
|
package/lib/base64url.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function toBase64Url(bytes) {
|
|
2
|
+
const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
|
|
3
|
+
return buffer
|
|
4
|
+
.toString('base64')
|
|
5
|
+
.replace(/\+/g, '-')
|
|
6
|
+
.replace(/\//g, '_')
|
|
7
|
+
.replace(/=+$/g, '')
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function fromBase64Url(value) {
|
|
11
|
+
const normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/')
|
|
12
|
+
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=')
|
|
13
|
+
return Buffer.from(padded, 'base64')
|
|
14
|
+
}
|
package/lib/crypto.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
import fs from 'node:fs/promises'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { fromBase64Url, toBase64Url } from './base64url.js'
|
|
5
|
+
|
|
6
|
+
export const ENCRYPTED_PART_SIZE = 32 * 1024 * 1024
|
|
7
|
+
export const PLAIN_CHUNK_SIZE = ENCRYPTED_PART_SIZE - 16
|
|
8
|
+
export const DIRECT_UPLOAD_LIMIT_BYTES = 100 * 1024 * 1024
|
|
9
|
+
|
|
10
|
+
export function buildChunkIv(noncePrefix, chunkIndex) {
|
|
11
|
+
const iv = Buffer.alloc(12)
|
|
12
|
+
noncePrefix.copy(iv, 0, 0, 8)
|
|
13
|
+
iv.writeUInt32BE(chunkIndex, 8)
|
|
14
|
+
return iv
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function encryptChunk(plainChunk, key, noncePrefix, chunkIndex) {
|
|
18
|
+
const iv = buildChunkIv(noncePrefix, chunkIndex)
|
|
19
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
|
|
20
|
+
const encrypted = Buffer.concat([cipher.update(plainChunk), cipher.final()])
|
|
21
|
+
const tag = cipher.getAuthTag()
|
|
22
|
+
return Buffer.concat([encrypted, tag])
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createEncryptionToken(tokenBytes = crypto.randomBytes(32)) {
|
|
26
|
+
if (tokenBytes.length !== 32) {
|
|
27
|
+
throw new Error('Encryption token must be 32 bytes.')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
tokenBytes,
|
|
32
|
+
token: toBase64Url(tokenBytes),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function buildClientEncryptionMeta({
|
|
37
|
+
plainChunkSize,
|
|
38
|
+
chunkCount,
|
|
39
|
+
noncePrefix,
|
|
40
|
+
originalSize,
|
|
41
|
+
mimeType,
|
|
42
|
+
}) {
|
|
43
|
+
return {
|
|
44
|
+
version: 1,
|
|
45
|
+
algorithm: 'AES-GCM',
|
|
46
|
+
chunkSize: plainChunkSize,
|
|
47
|
+
chunkCount,
|
|
48
|
+
noncePrefix: toBase64Url(noncePrefix),
|
|
49
|
+
originalSize,
|
|
50
|
+
mimeType: mimeType || 'application/octet-stream',
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function encryptFileBuffer(fileBuffer, {
|
|
55
|
+
originalName = 'upload',
|
|
56
|
+
mimeType = 'application/octet-stream',
|
|
57
|
+
tokenBytes = crypto.randomBytes(32),
|
|
58
|
+
noncePrefix = crypto.randomBytes(8),
|
|
59
|
+
plainChunkSize = PLAIN_CHUNK_SIZE,
|
|
60
|
+
} = {}) {
|
|
61
|
+
if (tokenBytes.length !== 32) throw new Error('Encryption token must be 32 bytes.')
|
|
62
|
+
if (noncePrefix.length !== 8) throw new Error('Nonce prefix must be 8 bytes.')
|
|
63
|
+
|
|
64
|
+
const key = tokenBytes
|
|
65
|
+
const chunkCount = Math.ceil(fileBuffer.length / plainChunkSize) || 1
|
|
66
|
+
const encryptedParts = []
|
|
67
|
+
let encryptedSize = 0
|
|
68
|
+
|
|
69
|
+
for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex += 1) {
|
|
70
|
+
const start = chunkIndex * plainChunkSize
|
|
71
|
+
const end = Math.min(fileBuffer.length, start + plainChunkSize)
|
|
72
|
+
const plainChunk = fileBuffer.subarray(start, end)
|
|
73
|
+
const encryptedChunk = encryptChunk(plainChunk, key, noncePrefix, chunkIndex)
|
|
74
|
+
encryptedParts.push(encryptedChunk)
|
|
75
|
+
encryptedSize += encryptedChunk.length
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const meta = buildClientEncryptionMeta({
|
|
79
|
+
plainChunkSize,
|
|
80
|
+
chunkCount,
|
|
81
|
+
noncePrefix,
|
|
82
|
+
originalSize: fileBuffer.length,
|
|
83
|
+
mimeType,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
token: toBase64Url(tokenBytes),
|
|
88
|
+
tokenBytes,
|
|
89
|
+
meta,
|
|
90
|
+
encryptedParts,
|
|
91
|
+
encryptedBuffer: Buffer.concat(encryptedParts),
|
|
92
|
+
encryptedSize,
|
|
93
|
+
originalName,
|
|
94
|
+
mimeType,
|
|
95
|
+
originalSize: fileBuffer.length,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function encryptFile(filePath, options = {}) {
|
|
100
|
+
const absolutePath = path.resolve(filePath)
|
|
101
|
+
const fileBuffer = await fs.readFile(absolutePath)
|
|
102
|
+
const originalName = options.originalName || path.basename(absolutePath)
|
|
103
|
+
const mimeType = options.mimeType || guessMimeType(originalName)
|
|
104
|
+
|
|
105
|
+
return encryptFileBuffer(fileBuffer, {
|
|
106
|
+
...options,
|
|
107
|
+
originalName,
|
|
108
|
+
mimeType,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function decryptChunk(encryptedChunk, tokenBytes, noncePrefix, chunkIndex) {
|
|
113
|
+
if (encryptedChunk.length < 16) {
|
|
114
|
+
throw new Error('Encrypted chunk is too small.')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const iv = buildChunkIv(noncePrefix, chunkIndex)
|
|
118
|
+
const ciphertext = encryptedChunk.subarray(0, encryptedChunk.length - 16)
|
|
119
|
+
const tag = encryptedChunk.subarray(encryptedChunk.length - 16)
|
|
120
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', tokenBytes, iv)
|
|
121
|
+
decipher.setAuthTag(tag)
|
|
122
|
+
return Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function importToken(token) {
|
|
126
|
+
const tokenBytes = fromBase64Url(token)
|
|
127
|
+
if (tokenBytes.length !== 32) {
|
|
128
|
+
throw new Error('Invalid encryption token length.')
|
|
129
|
+
}
|
|
130
|
+
return tokenBytes
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function guessMimeType(filename) {
|
|
134
|
+
const ext = path.extname(filename).toLowerCase()
|
|
135
|
+
const map = {
|
|
136
|
+
'.png': 'image/png',
|
|
137
|
+
'.jpg': 'image/jpeg',
|
|
138
|
+
'.jpeg': 'image/jpeg',
|
|
139
|
+
'.gif': 'image/gif',
|
|
140
|
+
'.webp': 'image/webp',
|
|
141
|
+
'.pdf': 'application/pdf',
|
|
142
|
+
'.zip': 'application/zip',
|
|
143
|
+
'.txt': 'text/plain',
|
|
144
|
+
'.json': 'application/json',
|
|
145
|
+
'.iso': 'application/octet-stream',
|
|
146
|
+
}
|
|
147
|
+
return map[ext] || 'application/octet-stream'
|
|
148
|
+
}
|
package/lib/upload.js
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { DIRECT_UPLOAD_LIMIT_BYTES } from './crypto.js'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_BASE_URL = 'https://bytifi.com'
|
|
4
|
+
|
|
5
|
+
export class BytifiApiError extends Error {
|
|
6
|
+
constructor(message, { status = 0, body = null } = {}) {
|
|
7
|
+
super(message)
|
|
8
|
+
this.name = 'BytifiApiError'
|
|
9
|
+
this.status = status
|
|
10
|
+
this.body = body
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class BytifiNetworkError extends Error {
|
|
15
|
+
constructor(message, { cause } = {}) {
|
|
16
|
+
super(message)
|
|
17
|
+
this.name = 'BytifiNetworkError'
|
|
18
|
+
this.cause = cause
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeBaseUrl(baseUrl) {
|
|
23
|
+
return String(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function readResponseBody(response) {
|
|
27
|
+
const text = await response.text()
|
|
28
|
+
if (!text) return null
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(text)
|
|
32
|
+
} catch {
|
|
33
|
+
return text
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, body = null } = {}) {
|
|
38
|
+
const url = `${normalizeBaseUrl(baseUrl)}${path}`
|
|
39
|
+
const requestHeaders = {
|
|
40
|
+
Authorization: `Bearer ${apiKey}`,
|
|
41
|
+
...headers,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let response
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
response = await fetch(url, {
|
|
48
|
+
method,
|
|
49
|
+
headers: requestHeaders,
|
|
50
|
+
body,
|
|
51
|
+
})
|
|
52
|
+
} catch (error) {
|
|
53
|
+
throw new BytifiNetworkError(error.message || 'Network request failed.', { cause: error })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const payload = await readResponseBody(response)
|
|
57
|
+
|
|
58
|
+
if (!response.ok) {
|
|
59
|
+
const message = typeof payload === 'object' && payload?.error
|
|
60
|
+
? payload.error
|
|
61
|
+
: typeof payload === 'string' && payload
|
|
62
|
+
? payload
|
|
63
|
+
: `Request failed with status ${response.status}.`
|
|
64
|
+
throw new BytifiApiError(message, { status: response.status, body: payload })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return payload
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildShareUrl(payload, encryptionToken) {
|
|
71
|
+
return `${payload.url}#token=${encodeURIComponent(encryptionToken)}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function buildResult(payload, encryption, shareUrl) {
|
|
75
|
+
return {
|
|
76
|
+
shareUrl,
|
|
77
|
+
url: payload.url,
|
|
78
|
+
downloadUrl: payload.downloadUrl,
|
|
79
|
+
token: payload.token,
|
|
80
|
+
encryptionToken: encryption.token,
|
|
81
|
+
originalName: payload.originalName || encryption.originalName,
|
|
82
|
+
size: payload.size,
|
|
83
|
+
expiresAt: payload.expiresAt,
|
|
84
|
+
deleteOnDownload: payload.deleteOnDownload,
|
|
85
|
+
clientEncrypted: payload.clientEncrypted,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function uploadDirect(encryption, {
|
|
90
|
+
apiKey,
|
|
91
|
+
baseUrl,
|
|
92
|
+
expiresInMinutes,
|
|
93
|
+
deleteOnDownload,
|
|
94
|
+
}) {
|
|
95
|
+
const formData = new FormData()
|
|
96
|
+
const blob = new Blob([encryption.encryptedBuffer], { type: 'application/octet-stream' })
|
|
97
|
+
|
|
98
|
+
formData.append('file', blob, encryption.originalName)
|
|
99
|
+
formData.append('clientEncrypted', 'true')
|
|
100
|
+
formData.append('clientEncryptionMeta', JSON.stringify(encryption.meta))
|
|
101
|
+
formData.append('deleteOnDownload', deleteOnDownload ? 'true' : 'false')
|
|
102
|
+
formData.append('expiresInMinutes', String(expiresInMinutes))
|
|
103
|
+
|
|
104
|
+
const payload = await apiFetch(baseUrl, '/api/public/upload', {
|
|
105
|
+
apiKey,
|
|
106
|
+
method: 'POST',
|
|
107
|
+
body: formData,
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
const shareUrl = buildShareUrl(payload, encryption.token)
|
|
111
|
+
return buildResult(payload, encryption, shareUrl)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal }) {
|
|
115
|
+
while (true) {
|
|
116
|
+
if (signal?.aborted) {
|
|
117
|
+
throw new Error('Upload aborted.')
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const payload = await apiFetch(
|
|
121
|
+
baseUrl,
|
|
122
|
+
`/api/public/upload/status?sessionToken=${encodeURIComponent(sessionToken)}`,
|
|
123
|
+
{ apiKey },
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
if (payload.status !== 'processing') {
|
|
127
|
+
return payload
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await new Promise((resolve) => setTimeout(resolve, 1500))
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function uploadMultipart(encryption, {
|
|
135
|
+
apiKey,
|
|
136
|
+
baseUrl,
|
|
137
|
+
expiresInMinutes,
|
|
138
|
+
deleteOnDownload,
|
|
139
|
+
onProgress,
|
|
140
|
+
signal,
|
|
141
|
+
concurrency = 3,
|
|
142
|
+
}) {
|
|
143
|
+
const partSize = encryption.meta.chunkSize + 16
|
|
144
|
+
const initPayload = await apiFetch(baseUrl, '/api/public/upload/init', {
|
|
145
|
+
apiKey,
|
|
146
|
+
method: 'POST',
|
|
147
|
+
headers: { 'Content-Type': 'application/json' },
|
|
148
|
+
body: JSON.stringify({
|
|
149
|
+
originalName: encryption.originalName,
|
|
150
|
+
mimeType: encryption.mimeType,
|
|
151
|
+
size: encryption.encryptedSize,
|
|
152
|
+
originalSize: encryption.originalSize,
|
|
153
|
+
clientEncrypted: true,
|
|
154
|
+
clientEncryptionMeta: encryption.meta,
|
|
155
|
+
partSize,
|
|
156
|
+
expiresInMinutes,
|
|
157
|
+
deleteOnDownload,
|
|
158
|
+
}),
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const sessionToken = initPayload.sessionToken
|
|
162
|
+
const totalParts = encryption.encryptedParts.length
|
|
163
|
+
let completedParts = 0
|
|
164
|
+
|
|
165
|
+
const uploadPart = async (partNumber) => {
|
|
166
|
+
const partBuffer = encryption.encryptedParts[partNumber - 1]
|
|
167
|
+
await apiFetch(
|
|
168
|
+
baseUrl,
|
|
169
|
+
`/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${partNumber}`,
|
|
170
|
+
{
|
|
171
|
+
apiKey,
|
|
172
|
+
method: 'PUT',
|
|
173
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
174
|
+
body: partBuffer,
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
completedParts += 1
|
|
179
|
+
onProgress?.(Math.round((completedParts / totalParts) * 100))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const queue = Array.from({ length: totalParts }, (_, index) => index + 1)
|
|
183
|
+
const workers = Array.from({ length: Math.min(concurrency, totalParts) }, async () => {
|
|
184
|
+
while (queue.length > 0) {
|
|
185
|
+
if (signal?.aborted) {
|
|
186
|
+
throw new Error('Upload aborted.')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const partNumber = queue.shift()
|
|
190
|
+
if (!partNumber) return
|
|
191
|
+
await uploadPart(partNumber)
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
await Promise.all(workers)
|
|
197
|
+
} catch (error) {
|
|
198
|
+
await apiFetch(baseUrl, '/api/public/upload/abort', {
|
|
199
|
+
apiKey,
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: { 'Content-Type': 'application/json' },
|
|
202
|
+
body: JSON.stringify({ sessionToken }),
|
|
203
|
+
}).catch(() => {})
|
|
204
|
+
|
|
205
|
+
throw error
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let completePayload = await apiFetch(baseUrl, '/api/public/upload/complete', {
|
|
209
|
+
apiKey,
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: { 'Content-Type': 'application/json' },
|
|
212
|
+
body: JSON.stringify({ sessionToken }),
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
if (completePayload.status === 'processing') {
|
|
216
|
+
completePayload = await pollUploadStatus(sessionToken, { apiKey, baseUrl, signal })
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const shareUrl = buildShareUrl(completePayload, encryption.token)
|
|
220
|
+
return buildResult(completePayload, encryption, shareUrl)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function uploadEncrypted(encryption, options) {
|
|
224
|
+
if (!options?.apiKey) {
|
|
225
|
+
throw new Error('API key is required.')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (encryption.encryptedSize <= DIRECT_UPLOAD_LIMIT_BYTES) {
|
|
229
|
+
return uploadDirect(encryption, options)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return uploadMultipart(encryption, options)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function uploadFile(filePath, options) {
|
|
236
|
+
const { encryptFile } = await import('./crypto.js')
|
|
237
|
+
const encryption = await encryptFile(filePath, {
|
|
238
|
+
mimeType: options?.mimeType,
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
return uploadEncrypted(encryption, options)
|
|
242
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bytifi",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Official Bytifi CLI — encrypt and upload files from the terminal",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"bytifi": "./bin/bytifi.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"upload": "node bin/bytifi.js upload"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/jpwcguy/Bytifi.git"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://bytifi.com",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/jpwcguy/Bytifi/issues"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"bytifi",
|
|
32
|
+
"cli",
|
|
33
|
+
"encryption",
|
|
34
|
+
"upload",
|
|
35
|
+
"file-sharing"
|
|
36
|
+
],
|
|
37
|
+
"license": "MIT"
|
|
38
|
+
}
|