@arcadiasystems/morse-uploader 0.3.0 → 0.4.1
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/CHANGELOG.md +38 -0
- package/README.md +13 -5
- package/dist/{chunk-K2J3ZILI.js → chunk-ZV3GFXNW.js} +265 -28
- package/dist/chunk-ZV3GFXNW.js.map +1 -0
- package/dist/index.d.ts +57 -14
- package/dist/index.js +4 -2
- package/dist/index.js.map +1 -1
- package/dist/{types-C6w30zCM.d.ts → types-C5hwiHjr.d.ts} +63 -6
- package/dist/ui.d.ts +5 -4
- package/dist/ui.js +2 -3
- package/dist/ui.js.map +1 -1
- package/package.json +3 -3
- package/dist/chunk-K2J3ZILI.js.map +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `@arcadiasystems/morse-uploader` will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
4
4
|
|
|
5
|
+
## [0.4.1] - 2026-10-30
|
|
6
|
+
|
|
7
|
+
Accepts `@arcadiasystems/morse-sdk` 0.10. No code changed: none of 0.10's breaking changes reach this package, and the Mysten peer ranges are the same as 0.4.0's.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **Peer dependency.** `@arcadiasystems/morse-sdk` `>=0.9.0 <0.11.0` (was `>=0.9.0 <0.10.0`), so an app can install SDK 0.10 next to this package without a peer conflict, and an app on 0.9.x can take this release without moving the SDK. The README peer install now names `@arcadiasystems/morse-sdk@~0.10.0`.
|
|
12
|
+
- **On SDK 0.10, download and upload errors read the way the SDK now words them.** `downloadErrorMessage` and `uploadErrorMessage` hand every SDK error to `formatUserMessage`, so the copy follows the SDK the app installed. A missing blob is still titled "Content unavailable", but its description is now "Walrus has no stored copy of this content. (id: ...)" with the bare blob id, instead of 0.9's "The Walrus blob is not retrievable. The storage operator may be having issues, or the blob expired. (id: Walrus aggregator: blob ... not found)". A `BlobExpiredError` is titled "Content expired" and names both epochs. The download hooks read through `walrusRead.readBlob`, never `readBlobByObjectId`, so the default aggregator adapter never raises one; only a custom `walrusRead` that raises it shows that message.
|
|
13
|
+
- **Nothing else 0.10 broke reaches this package.** It implements no `PublicationReader`, so the new required `isPublisherCapRevoked` does not apply. It never tests a Walrus read failure's class (`NoBlobMetadataReceivedError` is now a `TransportError`, `BlobNotCertifiedError` a `NotFoundError`) and never reads `NotFoundError.identifier`. It does not import `BlobExpiredError`, which 0.9 does not export.
|
|
14
|
+
- **The dev dependency and the example** pin `@arcadiasystems/morse-sdk` 0.10.0, so CI tests the newest version the range admits. 0.9.0, the lower bound, was checked locally: lint, typecheck, the test suite, build and `check:exports` pass, and the built entries load in Node.
|
|
15
|
+
|
|
16
|
+
## [0.4.0] - 2026-10-20
|
|
17
|
+
|
|
18
|
+
Downloads now tell an encrypted file from a public one by its on-chain Seal prefix instead of trusting the share link, and uploads refuse a file too large for the relay's tip cap before reading it. Peer dependencies are unchanged. This is a minor bump because it adds public exports and narrows `walrusWriteConfig.uploadRelay.sendTip` from `unknown`, so a value `@mysten/walrus` would not accept no longer type-checks.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **A share link that lost its Seal material served an encrypted file as if it were public.** `useFileDownload` decided whether to decrypt from the link alone, so a link that arrived without its `p` and `n` parameters (a chat client trimming the fragment, a URL copied by hand, a link rebuilt from the file id) read the bytes straight from Walrus and handed back ciphertext as the file, with no error anywhere. `load` and `download` now first read the file's Seal prefix from chain with the SDK's `getRecipientFileSealPrefix`, the only reliable sign that a file is encrypted (`members` is not one, because the owner is always a member). A file with an on-chain prefix opened from a link without Seal material now fails with a `ShareLinkSealError` and serves nothing, and `MorseFileDownloader` shows "Link is incomplete" with "This file is encrypted, and the link is missing the details needed to decrypt it. Ask the sender for the full link."
|
|
23
|
+
- **A link whose Seal prefix is not the file's is refused before the wallet is asked to sign.** Such a link cannot decrypt the file, but it used to cost a SessionKey signature and then fail at the key servers with what reads as a permissions error, blaming the wallet for a damaged link. It now fails with `ShareLinkSealError` reason `"mismatch"`, shown as "Link does not match this file". The on-chain prefix is deliberately not substituted for the link's: the nonce is not on chain, so a link with one damaged field cannot be told apart from a link to a different file, and the uploader already refuses half-damaged links rather than guessing (see 0.2.0's `parseShareLink` fix).
|
|
24
|
+
|
|
25
|
+
A file with no on-chain prefix behaves as before. Files encrypted before the prefix was stored on chain have none, and their links still carry the Seal material, so a missing prefix does not prove a file is public and the link still decides.
|
|
26
|
+
|
|
27
|
+
The prefix is read only when the bytes are fetched, so a page that renders just the metadata pays nothing extra; a public download costs one more round trip and an encrypted one two. The read is aborted when the component unmounts, and an aborted fetch stops before reading bytes or prompting the wallet.
|
|
28
|
+
- **A file too large for the relay's tip cap was read, encrypted and encoded before the upload failed, and the failure read as a network issue.** The Walrus Mainnet relay's tip grows with the blob's encoded size, so `sendTip.max` also caps the file size: 10,000,000 MIST, the cap the README and the example use, allows 42,327,820 bytes (40.37 MiB). `@mysten/walrus` enforces the cap only when it builds the register transaction, after encoding the whole file, and the SDK passed its "Tip amount (N) exceeds the maximum allowed tip (M)" on as a `TransportError`, which `uploadErrorMessage` titled "Network issue". `useFileUpload` now reads the relay's `/v1/tip-config` first, derives the largest file the cap allows with the SDK's encoded-size formula, and refuses a larger file with a `FileTooLargeError` before reading it. `MorseFileUploader` shows "File too large" with the file's size, the limit and the cap.
|
|
29
|
+
|
|
30
|
+
An encrypted file that fits, but that Seal's envelope takes over the limit, is refused after encryption and before Walrus encodes it, since the envelope's size depends on the key servers. A cap below the tip for even the smallest file (the example's 1,000,000 MIST before 0.2.0) now fails with a `ConfigurationError` saying so. Testnet's relay charges a flat 105 MIST, so there the cap sets no size limit. Each upload through a relay with a `sendTip.max` costs one extra request for the tip config; without a relay or a cap, nothing is requested.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **`downloadErrorMessage(error)`**, used by `MorseFileDownloader` and exported for custom UIs built on `useFileDownload`. It titles a `ShareLinkSealError` by its reason and defers everything else to the SDK's `formatUserMessage`, which would title it "Unexpected error".
|
|
35
|
+
- **`ShareLinkSealError`**, with `reason: ShareLinkSealErrorReason` (`"missing" | "mismatch"`). `useFileDownload` sets it as `error` when the link cannot open an encrypted file.
|
|
36
|
+
- **`FileTooLargeError`**, with `sizeBytes`, `afterEncryption` and `limit: UploadSizeLimit` (`maxBytes`, `maxTipMist`). `useFileUpload` throws it when a file is too large for the relay's tip cap, and `uploadErrorMessage` titles it "File too large".
|
|
37
|
+
- **`uploadRelay` on `MorseFilesHandles`**, the relay `walrusWrite` sends blobs through. `useMorseFiles` sets it from `walrusWriteConfig.uploadRelay` when it builds the write adapter. It stays unset for a prebuilt `walrusWrite`, whose relay the package cannot see, so the size check is skipped and the SDK's own tip check still applies. Hand-built handles can set it to get the check.
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- **`walrusWriteConfig.uploadRelay.sendTip` is typed** as `@mysten/walrus`'s `UploadRelayConfig["sendTip"]` instead of `unknown`, and the relay options are exported as `UploadRelayOptions`. Every value `@mysten/walrus` accepts still type-checks; the package now reads `max` from it.
|
|
42
|
+
|
|
5
43
|
## [0.3.0] - 2026-10-18
|
|
6
44
|
|
|
7
45
|
Moves to `@arcadiasystems/morse-sdk` 0.9 and the Mysten lines it requires. The public API is unchanged. This is a minor bump because the new peer ranges exclude every version 0.2.x accepted, so upgrading means upgrading the SDK and all three Mysten packages with it.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @arcadiasystems/morse-uploader
|
|
2
2
|
|
|
3
|
-
Headless-first React components and hooks for encrypted file sharing on Sui and Walrus, built on [`@arcadiasystems/morse-sdk`](https://github.com/arcadiasystems/morse-dcms) 0.9. Upload a file to Walrus, optionally encrypt it for a set of recipient wallets so only they can decrypt, and share a link. The connected wallet pays for and signs everything.
|
|
3
|
+
Headless-first React components and hooks for encrypted file sharing on Sui and Walrus, built on [`@arcadiasystems/morse-sdk`](https://github.com/arcadiasystems/morse-dcms) 0.9 or 0.10. Upload a file to Walrus, optionally encrypt it for a set of recipient wallets so only they can decrypt, and share a link. The connected wallet pays for and signs everything.
|
|
4
4
|
|
|
5
5
|
- **Headless-first**: composable hooks for every step (`useMorseFiles`, `useFileUpload`, `useFileDownload`, `useFileDecrypt`, `useRecipientFile`), plus an optional styled component set.
|
|
6
6
|
- **Recipient-scoped encryption**: each file carries its own recipient set; an encrypted upload to N recipients is **2 wallet popups**, regardless of N. The connected wallet is always a recipient, so the owner can always decrypt. Public (unencrypted) uploads need no recipients.
|
|
@@ -16,9 +16,11 @@ npm install @arcadiasystems/morse-uploader
|
|
|
16
16
|
Peer dependencies (install in your app, pinned to the same ranges as `@arcadiasystems/morse-sdk` so you do not end up with duplicate Sui/Walrus/Seal instances):
|
|
17
17
|
|
|
18
18
|
```sh
|
|
19
|
-
npm install react react-dom @arcadiasystems/morse-sdk@~0.
|
|
19
|
+
npm install react react-dom @arcadiasystems/morse-sdk@~0.10.0 @mysten/sui@~2.36.0 @mysten/walrus@~1.2.35 @mysten/seal@~1.5.0
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
The `@arcadiasystems/morse-sdk` peer range is `>=0.9.0 <0.11.0`, so an app already on 0.9.x can keep it.
|
|
23
|
+
|
|
22
24
|
## Quick start
|
|
23
25
|
|
|
24
26
|
### 1. Build the handles from your wallet
|
|
@@ -60,6 +62,7 @@ function FilesApp({ account }) {
|
|
|
60
62
|
uploadRelay: {
|
|
61
63
|
host: "https://upload-relay.testnet.walrus.space",
|
|
62
64
|
// Caps the per-upload tip the Walrus Mainnet relay charges, in MIST (0.01 SUI).
|
|
65
|
+
// The tip grows with file size, so this also caps the file: see "Notes on Walrus".
|
|
63
66
|
sendTip: { max: 10_000_000 },
|
|
64
67
|
},
|
|
65
68
|
},
|
|
@@ -113,8 +116,8 @@ All hooks must be used inside `MorseFilesProvider`.
|
|
|
113
116
|
| Hook | Purpose |
|
|
114
117
|
|---|---|
|
|
115
118
|
| `useMorseFiles(options)` | Build the handles from a connected wallet. Used once, above the provider. |
|
|
116
|
-
| `useFileUpload()` | Upload one file: `upload({ file, epochs, recipients?, encrypt? })`. Encrypted when `encrypt` is set. Returns `{ fileId, blobId, encrypted, sealIdPrefix, sealNonce, shareLink }` and exposes a phase stepper. |
|
|
117
|
-
| `useFileDownload(fileId, seal)` | Drive a download page: loads metadata, then `load()` / `download()` reads (public, `seal` null) or decrypts (encrypted, `seal` = `{ sealIdPrefix, sealNonce }`). |
|
|
119
|
+
| `useFileUpload()` | Upload one file: `upload({ file, epochs, recipients?, encrypt? })`. Encrypted when `encrypt` is set. Returns `{ fileId, blobId, encrypted, sealIdPrefix, sealNonce, shareLink }` and exposes a phase stepper. Refuses a file too large for the relay's tip cap with `FileTooLargeError` before reading it. |
|
|
120
|
+
| `useFileDownload(fileId, seal)` | Drive a download page: loads metadata, then `load()` / `download()` reads (public, `seal` null) or decrypts (encrypted, `seal` = `{ sealIdPrefix, sealNonce }`). It first reads the file's Seal prefix from chain, so a file that has one, opened with `seal` null or with another file's prefix, fails with `ShareLinkSealError` instead of serving ciphertext. |
|
|
118
121
|
| `useFileDecrypt()` | Lower-level decrypt: `decrypt({ file, sealIdPrefix, sealNonce })` returns plaintext bytes (one SessionKey signature, reused across files). |
|
|
119
122
|
| `useRecipientFile(fileId)` | Load a `RecipientFile`'s on-chain metadata (name, contentType, size, members, blobId). |
|
|
120
123
|
| `useStorageCostEstimate({ sizeBytes, epochs, encrypt })` | USD storage-cost estimate using Walrus [predictable pricing](https://blog.walrus.xyz/announcing-predictable-pricing-in-usd-on-walrus/) ($0.023/GB/month). |
|
|
@@ -123,7 +126,11 @@ All hooks must be used inside `MorseFilesProvider`.
|
|
|
123
126
|
|
|
124
127
|
### Rendering an upload error
|
|
125
128
|
|
|
126
|
-
`uploadErrorMessage(error, network)` returns the SDK's `{ title, description, cause }` for any failure, with
|
|
129
|
+
`uploadErrorMessage(error, network)` returns the SDK's `{ title, description, cause }` for any failure, with two exceptions. An insufficient-balance failure arrives as raw `@mysten/sui` text (a coin type, an address, two unscaled integers) that the SDK can only classify as a network problem. This restates it as "Not enough WAL", with the amounts in whole coins and the network's way of topping up. A `FileTooLargeError` (see [Notes on Walrus](#notes-on-walrus)) is titled "File too large", with the file's size, the limit and the tip cap. `MorseFileUploader` already uses it; call it directly if you render your own errors.
|
|
130
|
+
|
|
131
|
+
### Rendering a download error
|
|
132
|
+
|
|
133
|
+
`downloadErrorMessage(error)` does the same for `useFileDownload`'s `error`. A `ShareLinkSealError` means the file is encrypted and the link cannot open it: `reason` is `"missing"` when the link has no Seal material and `"mismatch"` when its prefix is not the file's. These are titled "Link is incomplete" and "Link does not match this file", and both ask the reader to get the full link from the sender. Everything else goes to the SDK's `formatUserMessage`. `MorseFileDownloader` already uses it.
|
|
127
134
|
|
|
128
135
|
## How encryption works
|
|
129
136
|
|
|
@@ -152,6 +159,7 @@ const parsed = parseShareLink(window.location.href);
|
|
|
152
159
|
|
|
153
160
|
- Storage is a **lease**, capped at 53 epochs ahead on both networks. An epoch is ~1 day on testnet and ~2 weeks on mainnet.
|
|
154
161
|
- Blobs are raw bytes with no filename or MIME type; this package stores the name and content type in the on-chain file record so downloads come back correctly named and typed.
|
|
162
|
+
- **The relay's tip cap is also a file-size cap.** The Walrus Mainnet relay charges a tip that grows with the blob's encoded size (its `/v1/tip-config` asks for 40 MIST per encoded KiB), and `@mysten/walrus` refuses a tip above `sendTip.max`. At the 10,000,000 MIST cap above, the largest file is 42,327,820 bytes (40.37 MiB). `useFileUpload` reads the relay's tip config before each upload and refuses a larger file with a `FileTooLargeError` before reading it, rather than letting the SDK encode the whole file first. An encrypted file is checked again after encryption, since Seal's envelope adds a few hundred bytes. Raise `sendTip.max` to allow larger files. Testnet's relay charges a flat 105 MIST, so there the cap sets no size limit. Handles built around a prebuilt `walrusWrite` have no `uploadRelay`, so this check is skipped, and the SDK's own check still refuses the tip before anything is signed.
|
|
155
163
|
|
|
156
164
|
## Seal on mainnet
|
|
157
165
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatUserMessage, buildRecipientFileSealId, SealError, uploadEncryptedRecipientFileFromBytes, uploadRecipientFileFromBytes } from '@arcadiasystems/morse-sdk';
|
|
1
|
+
import { formatUserMessage, buildRecipientFileSealId, SealError, uploadEncryptedRecipientFileFromBytes, uploadRecipientFileFromBytes, ConfigurationError, TransportError } from '@arcadiasystems/morse-sdk';
|
|
2
2
|
import { createContext, useContext, useState, useRef, useCallback, useEffect, useMemo } from 'react';
|
|
3
3
|
import { SessionKey, UserError } from '@mysten/seal';
|
|
4
4
|
import { normalizeSuiAddress } from '@mysten/sui/utils';
|
|
@@ -49,6 +49,34 @@ function buildShareLink(args) {
|
|
|
49
49
|
return `${base}${path}${query}`;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// src/files/share-link-seal-error.ts
|
|
53
|
+
var MESSAGES = {
|
|
54
|
+
missing: "This file is encrypted, and the link is missing the details needed to decrypt it. Ask the sender for the full link.",
|
|
55
|
+
mismatch: "This file is encrypted, and the link's decryption details are for a different file. The link may have been damaged or mixed up with another one. Ask the sender for the full link."
|
|
56
|
+
};
|
|
57
|
+
var ShareLinkSealError = class extends Error {
|
|
58
|
+
reason;
|
|
59
|
+
constructor(reason) {
|
|
60
|
+
super(MESSAGES[reason]);
|
|
61
|
+
this.name = "ShareLinkSealError";
|
|
62
|
+
this.reason = reason;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
var TITLES = {
|
|
66
|
+
missing: "Link is incomplete",
|
|
67
|
+
mismatch: "Link does not match this file"
|
|
68
|
+
};
|
|
69
|
+
function downloadErrorMessage(error) {
|
|
70
|
+
if (!(error instanceof ShareLinkSealError)) {
|
|
71
|
+
return formatUserMessage(error);
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
title: TITLES[error.reason],
|
|
75
|
+
description: error.message,
|
|
76
|
+
cause: error
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
52
80
|
// src/files/epoch-duration-ms.ts
|
|
53
81
|
var ONE_DAY_MS = 24 * 60 * 60 * 1e3;
|
|
54
82
|
var EPOCH_DURATION_MS = {
|
|
@@ -73,7 +101,53 @@ function estimateStorageUsd(args) {
|
|
|
73
101
|
const rate = args.usdPerGbPerMonth ?? DEFAULT_USD_PER_GB_PER_MONTH;
|
|
74
102
|
return { usd: gb * months * rate, gb, months, days, sizeBytes };
|
|
75
103
|
}
|
|
104
|
+
|
|
105
|
+
// src/utils/format-bytes.ts
|
|
106
|
+
var UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
|
|
107
|
+
var BASE = 1024;
|
|
108
|
+
function formatBytes(bytes, fractionDigits = 2) {
|
|
109
|
+
if (!Number.isFinite(bytes) || bytes <= 0) {
|
|
110
|
+
return "0 B";
|
|
111
|
+
}
|
|
112
|
+
const exponent = Math.min(
|
|
113
|
+
Math.floor(Math.log(bytes) / Math.log(BASE)),
|
|
114
|
+
UNITS.length - 1
|
|
115
|
+
);
|
|
116
|
+
const value = bytes / BASE ** exponent;
|
|
117
|
+
const unit = UNITS[exponent] ?? "B";
|
|
118
|
+
if (exponent === 0) {
|
|
119
|
+
return `${value} ${unit}`;
|
|
120
|
+
}
|
|
121
|
+
return `${value.toFixed(fractionDigits)} ${unit}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// src/files/file-too-large-error.ts
|
|
125
|
+
var FileTooLargeError = class extends Error {
|
|
126
|
+
sizeBytes;
|
|
127
|
+
afterEncryption;
|
|
128
|
+
limit;
|
|
129
|
+
constructor(args) {
|
|
130
|
+
const subject = args.afterEncryption ? "Encrypted, this file is" : "This file is";
|
|
131
|
+
super(
|
|
132
|
+
`${subject} ${describeBytes(args.sizeBytes)}, over the ${describeBytes(args.limit.maxBytes)} limit for one upload. The Walrus upload relay's tip grows with file size, and this app caps it at ${args.limit.maxTipMist.toLocaleString("en-US")} MIST.`
|
|
133
|
+
);
|
|
134
|
+
this.name = "FileTooLargeError";
|
|
135
|
+
this.sizeBytes = args.sizeBytes;
|
|
136
|
+
this.afterEncryption = args.afterEncryption;
|
|
137
|
+
this.limit = args.limit;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
function describeBytes(bytes) {
|
|
141
|
+
return `${formatBytes(bytes)} (${bytes.toLocaleString("en-US")} bytes)`;
|
|
142
|
+
}
|
|
76
143
|
function uploadErrorMessage(error, network) {
|
|
144
|
+
if (error instanceof FileTooLargeError) {
|
|
145
|
+
return {
|
|
146
|
+
title: "File too large",
|
|
147
|
+
description: error.message,
|
|
148
|
+
cause: error
|
|
149
|
+
};
|
|
150
|
+
}
|
|
77
151
|
const shortfall = parseInsufficientBalance(errorText(error));
|
|
78
152
|
if (shortfall === null) {
|
|
79
153
|
return formatUserMessage(error);
|
|
@@ -334,29 +408,65 @@ function useRecipientFile(fileId) {
|
|
|
334
408
|
}, [refresh, fileId]);
|
|
335
409
|
return { status, file, error, refresh };
|
|
336
410
|
}
|
|
411
|
+
|
|
412
|
+
// src/utils/bytes-equal.ts
|
|
413
|
+
function bytesEqual(a, b) {
|
|
414
|
+
if (a.length !== b.length) {
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
return a.every((byte, index) => byte === b[index]);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// src/files/assert-share-link-opens-file.ts
|
|
421
|
+
function assertShareLinkOpensFile(onChainPrefix, seal) {
|
|
422
|
+
if (onChainPrefix === null) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (seal === null) {
|
|
426
|
+
throw new ShareLinkSealError("missing");
|
|
427
|
+
}
|
|
428
|
+
if (!bytesEqual(seal.sealIdPrefix, onChainPrefix)) {
|
|
429
|
+
throw new ShareLinkSealError("mismatch");
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// src/files/use-file-download.ts
|
|
337
434
|
function useFileDownload(fileId, seal) {
|
|
338
|
-
const { walrusRead } = useMorseFilesContext();
|
|
435
|
+
const { reader, walrusRead } = useMorseFilesContext();
|
|
339
436
|
const { status: metaStatus, file } = useRecipientFile(fileId);
|
|
340
437
|
const { decrypt } = useFileDecrypt();
|
|
341
438
|
const [status, setStatus] = useState("idle");
|
|
342
439
|
const [objectUrl, setObjectUrl] = useState(null);
|
|
343
440
|
const [error, setError] = useState(null);
|
|
344
441
|
const urlRef = useRef(null);
|
|
442
|
+
const lifetimeRef = useRef(null);
|
|
345
443
|
useEffect(() => {
|
|
346
444
|
if (objectUrl === null) {
|
|
347
445
|
return;
|
|
348
446
|
}
|
|
349
447
|
return () => URL.revokeObjectURL(objectUrl);
|
|
350
448
|
}, [objectUrl]);
|
|
449
|
+
useEffect(() => {
|
|
450
|
+
const controller = new AbortController();
|
|
451
|
+
lifetimeRef.current = controller;
|
|
452
|
+
return () => controller.abort();
|
|
453
|
+
}, []);
|
|
351
454
|
const encrypted = seal !== null;
|
|
352
455
|
const isImage = file === null ? false : isPreviewableImage(file.contentType);
|
|
353
456
|
const fetchToUrl = useCallback(async () => {
|
|
354
457
|
if (file === null) {
|
|
355
458
|
return null;
|
|
356
459
|
}
|
|
460
|
+
const signal = lifetimeRef.current?.signal;
|
|
357
461
|
setStatus("loading");
|
|
358
462
|
setError(null);
|
|
359
463
|
try {
|
|
464
|
+
const onChainPrefix = await reader.getRecipientFileSealPrefix(
|
|
465
|
+
file.id,
|
|
466
|
+
signal
|
|
467
|
+
);
|
|
468
|
+
signal?.throwIfAborted();
|
|
469
|
+
assertShareLinkOpensFile(onChainPrefix, seal);
|
|
360
470
|
let bytes;
|
|
361
471
|
if (seal !== null) {
|
|
362
472
|
bytes = await decrypt({
|
|
@@ -375,11 +485,14 @@ function useFileDownload(fileId, seal) {
|
|
|
375
485
|
setStatus("ready");
|
|
376
486
|
return url;
|
|
377
487
|
} catch (err) {
|
|
488
|
+
if (signal?.aborted === true) {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
378
491
|
setError(err);
|
|
379
492
|
setStatus("error");
|
|
380
493
|
return null;
|
|
381
494
|
}
|
|
382
|
-
}, [file, seal, decrypt, walrusRead]);
|
|
495
|
+
}, [file, seal, reader, decrypt, walrusRead]);
|
|
383
496
|
const load = useCallback(async () => {
|
|
384
497
|
await fetchToUrl();
|
|
385
498
|
}, [fetchToUrl]);
|
|
@@ -407,10 +520,145 @@ function useFileDownload(fileId, seal) {
|
|
|
407
520
|
download
|
|
408
521
|
};
|
|
409
522
|
}
|
|
523
|
+
var OPERATION = "walrus.relay.tipConfig";
|
|
524
|
+
var TIMEOUT_MS = 3e4;
|
|
525
|
+
async function fetchRelayTipStrategy(host) {
|
|
526
|
+
let response;
|
|
527
|
+
try {
|
|
528
|
+
response = await fetch(`${host}/v1/tip-config`, {
|
|
529
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
530
|
+
});
|
|
531
|
+
} catch (cause) {
|
|
532
|
+
throw new TransportError(
|
|
533
|
+
`Could not reach the upload relay at ${host} to read its tip config.`,
|
|
534
|
+
{ cause, operation: OPERATION }
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
if (!response.ok) {
|
|
538
|
+
throw new TransportError(
|
|
539
|
+
`The upload relay at ${host} answered HTTP ${response.status} when asked for its tip config.`,
|
|
540
|
+
{ operation: OPERATION }
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
let body;
|
|
544
|
+
try {
|
|
545
|
+
body = await response.json();
|
|
546
|
+
} catch (cause) {
|
|
547
|
+
throw unreadable(host, cause);
|
|
548
|
+
}
|
|
549
|
+
if (typeof body === "string") {
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
const kind = field(field(body, "send_tip"), "kind");
|
|
553
|
+
const constant = field(kind, "const");
|
|
554
|
+
if (isMist(constant)) {
|
|
555
|
+
return { const: constant };
|
|
556
|
+
}
|
|
557
|
+
const linear = field(kind, "linear");
|
|
558
|
+
const base = field(linear, "base");
|
|
559
|
+
const perEncodedKib = field(linear, "encoded_size_mul_per_kib");
|
|
560
|
+
if (isMist(base) && isMist(perEncodedKib)) {
|
|
561
|
+
return { linear: { base, perEncodedKib } };
|
|
562
|
+
}
|
|
563
|
+
throw unreadable(host);
|
|
564
|
+
}
|
|
565
|
+
function field(value, key) {
|
|
566
|
+
if (typeof value !== "object" || value === null) {
|
|
567
|
+
return void 0;
|
|
568
|
+
}
|
|
569
|
+
return value[key];
|
|
570
|
+
}
|
|
571
|
+
function isMist(value) {
|
|
572
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
|
|
573
|
+
}
|
|
574
|
+
function unreadable(host, cause) {
|
|
575
|
+
return new TransportError(
|
|
576
|
+
`The upload relay at ${host} sent a tip config this version cannot read.`,
|
|
577
|
+
cause === void 0 ? { operation: OPERATION } : { cause, operation: OPERATION }
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// src/files/max-blob-bytes-for-tip.ts
|
|
582
|
+
var N_SHARDS = 1e3;
|
|
583
|
+
var MAX_FAULTY_SHARDS = Math.floor((N_SHARDS - 1) / 3);
|
|
584
|
+
var PRIMARY_SYMBOLS = N_SHARDS - 2 * MAX_FAULTY_SHARDS;
|
|
585
|
+
var SECONDARY_SYMBOLS = N_SHARDS - MAX_FAULTY_SHARDS;
|
|
586
|
+
var BYTES_PER_SYMBOL = BigInt(PRIMARY_SYMBOLS * SECONDARY_SYMBOLS);
|
|
587
|
+
var ENCODED_BYTES_PER_SYMBOL = BigInt(
|
|
588
|
+
(PRIMARY_SYMBOLS + SECONDARY_SYMBOLS) * N_SHARDS
|
|
589
|
+
);
|
|
590
|
+
var METADATA_BYTES = BigInt(N_SHARDS * (N_SHARDS * 32 * 2 + 32));
|
|
591
|
+
var BYTES_PER_KIB = 1024n;
|
|
592
|
+
function maxBlobBytesForTip(strategy, maxTipMist) {
|
|
593
|
+
if ("const" in strategy) {
|
|
594
|
+
return BigInt(strategy.const) <= maxTipMist ? null : 0;
|
|
595
|
+
}
|
|
596
|
+
const base = BigInt(strategy.linear.base);
|
|
597
|
+
const perEncodedKib = BigInt(strategy.linear.perEncodedKib);
|
|
598
|
+
if (perEncodedKib === 0n) {
|
|
599
|
+
return base <= maxTipMist ? null : 0;
|
|
600
|
+
}
|
|
601
|
+
const maxEncodedBytes = (maxTipMist - base) / perEncodedKib * BYTES_PER_KIB;
|
|
602
|
+
if (maxEncodedBytes < METADATA_BYTES) {
|
|
603
|
+
return 0;
|
|
604
|
+
}
|
|
605
|
+
const symbols = (maxEncodedBytes - METADATA_BYTES) / ENCODED_BYTES_PER_SYMBOL;
|
|
606
|
+
const evenSymbols = symbols - symbols % 2n;
|
|
607
|
+
return Number(evenSymbols * BYTES_PER_SYMBOL);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// src/files/resolve-upload-size-limit.ts
|
|
611
|
+
async function resolveUploadSizeLimit(relay) {
|
|
612
|
+
if (relay === void 0) {
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
const { sendTip } = relay;
|
|
616
|
+
if (sendTip === void 0 || sendTip === null) {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
if (sendTip.max === void 0 || !Number.isFinite(sendTip.max)) {
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
const maxTipMist = BigInt(Math.floor(sendTip.max));
|
|
623
|
+
const strategy = "kind" in sendTip ? sendTip.kind : await fetchRelayTipStrategy(relay.host);
|
|
624
|
+
if (strategy === null) {
|
|
625
|
+
return null;
|
|
626
|
+
}
|
|
627
|
+
const maxBytes = maxBlobBytesForTip(strategy, maxTipMist);
|
|
628
|
+
if (maxBytes === null) {
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
if (maxBytes === 0) {
|
|
632
|
+
throw new ConfigurationError(
|
|
633
|
+
`sendTip.max is ${maxTipMist.toLocaleString("en-US")} MIST, below the tip the upload relay at ${relay.host} charges for even the smallest file, so it would refuse every upload. Raise sendTip.max in walrusWriteConfig.uploadRelay.`
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
return { maxBytes, maxTipMist };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// src/files/with-ciphertext-size-limit.ts
|
|
640
|
+
function withCiphertextSizeLimit(walrus, limit) {
|
|
641
|
+
return {
|
|
642
|
+
uploadBlob: (data, options) => walrus.uploadBlob(data, options),
|
|
643
|
+
uploadQuilt: (patches, options) => walrus.uploadQuilt(patches, options),
|
|
644
|
+
startBlobUpload: async (data, options) => {
|
|
645
|
+
if (data.length > limit.maxBytes) {
|
|
646
|
+
throw new FileTooLargeError({
|
|
647
|
+
sizeBytes: data.length,
|
|
648
|
+
afterEncryption: true,
|
|
649
|
+
limit
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
return walrus.startBlobUpload(data, options);
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// src/files/use-file-upload.ts
|
|
410
658
|
var SEAL_UNAVAILABLE2 = "No Seal key servers are configured, so encrypted files are unavailable. Testnet pins an open set; mainnet pins none, because every operator there is commercial. Pass `sealKeyServers` to MorseFilesProvider, starting from the SDK's MAINNET_SEAL_COMMITTEE with the apiKeyName and apiKey your operator issues.";
|
|
411
659
|
var FALLBACK_CONTENT_TYPE = "application/octet-stream";
|
|
412
660
|
function useFileUpload() {
|
|
413
|
-
const { adapter, config, walrusWrite, seal, network } = useMorseFilesContext();
|
|
661
|
+
const { adapter, config, walrusWrite, uploadRelay, seal, network } = useMorseFilesContext();
|
|
414
662
|
const [status, setStatus] = useState("idle");
|
|
415
663
|
const [phase, setPhase] = useState(null);
|
|
416
664
|
const [data, setData] = useState(null);
|
|
@@ -428,6 +676,14 @@ function useFileUpload() {
|
|
|
428
676
|
setData(null);
|
|
429
677
|
setError(null);
|
|
430
678
|
try {
|
|
679
|
+
const sizeLimit = await resolveUploadSizeLimit(uploadRelay);
|
|
680
|
+
if (sizeLimit !== null && input.file.size > sizeLimit.maxBytes) {
|
|
681
|
+
throw new FileTooLargeError({
|
|
682
|
+
sizeBytes: input.file.size,
|
|
683
|
+
afterEncryption: false,
|
|
684
|
+
limit: sizeLimit
|
|
685
|
+
});
|
|
686
|
+
}
|
|
431
687
|
const bytes = new Uint8Array(await input.file.arrayBuffer());
|
|
432
688
|
const contentType = input.file.type || FALLBACK_CONTENT_TYPE;
|
|
433
689
|
const recipients = input.recipients ?? [];
|
|
@@ -445,7 +701,7 @@ function useFileUpload() {
|
|
|
445
701
|
adapter,
|
|
446
702
|
config,
|
|
447
703
|
{
|
|
448
|
-
walrus: walrusWrite,
|
|
704
|
+
walrus: sizeLimit === null ? walrusWrite : withCiphertextSizeLimit(walrusWrite, sizeLimit),
|
|
449
705
|
seal,
|
|
450
706
|
plaintext: bytes,
|
|
451
707
|
recipients,
|
|
@@ -496,7 +752,7 @@ function useFileUpload() {
|
|
|
496
752
|
throw err;
|
|
497
753
|
}
|
|
498
754
|
},
|
|
499
|
-
[adapter, config, walrusWrite, seal, network]
|
|
755
|
+
[adapter, config, walrusWrite, uploadRelay, seal, network]
|
|
500
756
|
);
|
|
501
757
|
return { status, phase, data, error, upload, reset };
|
|
502
758
|
}
|
|
@@ -628,25 +884,6 @@ function useDropzone(options) {
|
|
|
628
884
|
return { isDragging, open, getRootProps, getInputProps, inputRef };
|
|
629
885
|
}
|
|
630
886
|
|
|
631
|
-
// src/utils/format-bytes.ts
|
|
632
|
-
var UNITS = ["B", "KB", "MB", "GB", "TB", "PB"];
|
|
633
|
-
var BASE = 1024;
|
|
634
|
-
function formatBytes(bytes, fractionDigits = 2) {
|
|
635
|
-
if (!Number.isFinite(bytes) || bytes <= 0) {
|
|
636
|
-
return "0 B";
|
|
637
|
-
}
|
|
638
|
-
const exponent = Math.min(
|
|
639
|
-
Math.floor(Math.log(bytes) / Math.log(BASE)),
|
|
640
|
-
UNITS.length - 1
|
|
641
|
-
);
|
|
642
|
-
const value = bytes / BASE ** exponent;
|
|
643
|
-
const unit = UNITS[exponent] ?? "B";
|
|
644
|
-
if (exponent === 0) {
|
|
645
|
-
return `${value} ${unit}`;
|
|
646
|
-
}
|
|
647
|
-
return `${value.toFixed(fractionDigits)} ${unit}`;
|
|
648
|
-
}
|
|
649
|
-
|
|
650
887
|
// src/utils/format-usd.ts
|
|
651
888
|
var TINY_FLOOR = 1e-6;
|
|
652
889
|
function formatUsd(usd) {
|
|
@@ -665,6 +902,6 @@ function formatUsd(usd) {
|
|
|
665
902
|
})}`;
|
|
666
903
|
}
|
|
667
904
|
|
|
668
|
-
export { DEFAULT_ENCRYPTION_OVERHEAD_BYTES, MorseFilesContext, buildShareLink, bytesToHex, epochDurationMs, estimateStorageUsd, formatBytes, formatUsd, isPreviewableImage, resolveOrigin, triggerDownload, uploadErrorMessage, uploadPhaseLabel, uploadPhases, useClipboard, useDropzone, useFileDecrypt, useFileDownload, useFileUpload, useMorseFilesContext, useRecipientFile, useStorageCostEstimate };
|
|
669
|
-
//# sourceMappingURL=chunk-
|
|
670
|
-
//# sourceMappingURL=chunk-
|
|
905
|
+
export { DEFAULT_ENCRYPTION_OVERHEAD_BYTES, FileTooLargeError, MorseFilesContext, ShareLinkSealError, buildShareLink, bytesToHex, downloadErrorMessage, epochDurationMs, estimateStorageUsd, formatBytes, formatUsd, isPreviewableImage, resolveOrigin, triggerDownload, uploadErrorMessage, uploadPhaseLabel, uploadPhases, useClipboard, useDropzone, useFileDecrypt, useFileDownload, useFileUpload, useMorseFilesContext, useRecipientFile, useStorageCostEstimate };
|
|
906
|
+
//# sourceMappingURL=chunk-ZV3GFXNW.js.map
|
|
907
|
+
//# sourceMappingURL=chunk-ZV3GFXNW.js.map
|