voidnote 0.1.0__tar.gz → 0.2.0__tar.gz
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.
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: voidnote
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Official SDK for VoidNote — zero-knowledge self-destructing notes
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official SDK for VoidNote — zero-knowledge self-destructing notes and live encrypted streams
|
|
5
5
|
Project-URL: Homepage, https://voidnote.net
|
|
6
6
|
Project-URL: Repository, https://github.com/quantum-encoding/voidnote-python
|
|
7
7
|
Project-URL: Documentation, https://voidnote.net/docs
|
|
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "voidnote"
|
|
7
|
-
version = "0.
|
|
8
|
-
description = "Official SDK for VoidNote — zero-knowledge self-destructing notes"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
description = "Official SDK for VoidNote — zero-knowledge self-destructing notes and live encrypted streams"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
requires-python = ">=3.9"
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""
|
|
2
|
+
VoidNote SDK — Python
|
|
3
|
+
|
|
4
|
+
pip install voidnote
|
|
5
|
+
|
|
6
|
+
Works with Python 3.9+. Requires: cryptography
|
|
7
|
+
|
|
8
|
+
pip install voidnote cryptography
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import urllib.request
|
|
17
|
+
import json
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Optional, Generator, Literal
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
23
|
+
_HAS_CRYPTO = True
|
|
24
|
+
except ImportError:
|
|
25
|
+
_HAS_CRYPTO = False
|
|
26
|
+
|
|
27
|
+
DEFAULT_BASE = "https://voidnote.net"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class ReadResult:
|
|
32
|
+
content: str
|
|
33
|
+
title: Optional[str]
|
|
34
|
+
view_count: int
|
|
35
|
+
max_views: int
|
|
36
|
+
destroyed: bool
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class CreateResult:
|
|
41
|
+
url: str
|
|
42
|
+
expires_at: str
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# --- internal helpers ---
|
|
46
|
+
|
|
47
|
+
def _extract_token(url_or_token: str) -> str:
|
|
48
|
+
match = re.search(r"[0-9a-f]{64}", url_or_token, re.IGNORECASE)
|
|
49
|
+
if not match:
|
|
50
|
+
raise ValueError("No valid 64-char token found in input")
|
|
51
|
+
return match.group(0).lower()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _generate_token() -> tuple[str, str, str]:
|
|
55
|
+
token_bytes = os.urandom(32)
|
|
56
|
+
full_token = token_bytes.hex()
|
|
57
|
+
return full_token, full_token[:32], full_token[32:]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _encrypt(content: str, secret: str) -> tuple[str, str]:
|
|
61
|
+
if not _HAS_CRYPTO:
|
|
62
|
+
raise ImportError(
|
|
63
|
+
"The 'cryptography' package is required to create notes: "
|
|
64
|
+
"pip install cryptography"
|
|
65
|
+
)
|
|
66
|
+
secret_bytes = bytes.fromhex(secret)
|
|
67
|
+
key = hashlib.sha256(secret_bytes).digest()
|
|
68
|
+
iv = os.urandom(12)
|
|
69
|
+
aesgcm = AESGCM(key)
|
|
70
|
+
ciphertext = aesgcm.encrypt(iv, content.encode("utf-8"), None)
|
|
71
|
+
return ciphertext.hex(), iv.hex()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _fetch(url: str, *, method: str = "GET", headers: dict | None = None, body: bytes | None = None):
|
|
75
|
+
req = urllib.request.Request(url, data=body, method=method, headers=headers or {})
|
|
76
|
+
with urllib.request.urlopen(req) as resp:
|
|
77
|
+
return json.loads(resp.read().decode("utf-8")), resp.status
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --- public API ---
|
|
81
|
+
|
|
82
|
+
def read(url_or_token: str, base: str = DEFAULT_BASE) -> ReadResult:
|
|
83
|
+
"""
|
|
84
|
+
Read a VoidNote. Consumes one view.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
url_or_token: Full VoidNote URL or raw 64-char hex token
|
|
88
|
+
base: Override base URL (default: https://voidnote.net)
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
ReadResult with .content, .title, .view_count, .max_views, .destroyed
|
|
92
|
+
|
|
93
|
+
Example:
|
|
94
|
+
result = voidnote.read("https://voidnote.net/note/abc123...")
|
|
95
|
+
print(result.content)
|
|
96
|
+
"""
|
|
97
|
+
token = _extract_token(url_or_token)
|
|
98
|
+
data, status = _fetch(f"{base}/api/note/{token}")
|
|
99
|
+
if status >= 400:
|
|
100
|
+
raise RuntimeError(data.get("error", f"HTTP {status}"))
|
|
101
|
+
return ReadResult(
|
|
102
|
+
content=data["content"],
|
|
103
|
+
title=data.get("title"),
|
|
104
|
+
view_count=data["viewCount"],
|
|
105
|
+
max_views=data["maxViews"],
|
|
106
|
+
destroyed=data["destroyed"],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def create(
|
|
111
|
+
content: str,
|
|
112
|
+
*,
|
|
113
|
+
api_key: str,
|
|
114
|
+
title: Optional[str] = None,
|
|
115
|
+
max_views: int = 1,
|
|
116
|
+
base: str = DEFAULT_BASE,
|
|
117
|
+
) -> CreateResult:
|
|
118
|
+
"""
|
|
119
|
+
Create a VoidNote. Encrypts content client-side — the server never sees plaintext.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
content: The secret text to encrypt and store
|
|
123
|
+
api_key: Your API key from dashboard (vn_...)
|
|
124
|
+
title: Optional plaintext title (keep non-sensitive)
|
|
125
|
+
max_views: How many times the note can be read (1–100)
|
|
126
|
+
base: Override base URL (default: https://voidnote.net)
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
CreateResult with .url (shareable link) and .expires_at
|
|
130
|
+
|
|
131
|
+
Example:
|
|
132
|
+
result = voidnote.create("my-secret-api-key", api_key="vn_...")
|
|
133
|
+
print(result.url)
|
|
134
|
+
"""
|
|
135
|
+
if not 1 <= max_views <= 100:
|
|
136
|
+
raise ValueError("max_views must be between 1 and 100")
|
|
137
|
+
|
|
138
|
+
full_token, token_id, secret = _generate_token()
|
|
139
|
+
encrypted_content, iv = _encrypt(content, secret)
|
|
140
|
+
|
|
141
|
+
payload = {
|
|
142
|
+
"tokenId": token_id,
|
|
143
|
+
"encryptedContent": encrypted_content,
|
|
144
|
+
"iv": iv,
|
|
145
|
+
"maxViews": max_views,
|
|
146
|
+
}
|
|
147
|
+
if title:
|
|
148
|
+
payload["title"] = title
|
|
149
|
+
|
|
150
|
+
body = json.dumps(payload).encode("utf-8")
|
|
151
|
+
headers = {
|
|
152
|
+
"Content-Type": "application/json",
|
|
153
|
+
"Authorization": f"Bearer {api_key}",
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
data, status = _fetch(f"{base}/api/notes", method="POST", headers=headers, body=body)
|
|
157
|
+
if status >= 400 or not data.get("success"):
|
|
158
|
+
raise RuntimeError(data.get("error", f"HTTP {status}"))
|
|
159
|
+
|
|
160
|
+
return CreateResult(
|
|
161
|
+
url=f"{data['siteUrl']}/note/{full_token}",
|
|
162
|
+
expires_at=data["expiresAt"],
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# Void Stream — live encrypted real-time channels
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
@dataclass
|
|
171
|
+
class StreamHandle:
|
|
172
|
+
"""
|
|
173
|
+
A live Void Stream. Write encrypted messages, watch them appear in real time,
|
|
174
|
+
close to self-destruct.
|
|
175
|
+
|
|
176
|
+
Returned by create_stream(). The URL contains the decryption key — share it
|
|
177
|
+
with viewers. The server never sees plaintext.
|
|
178
|
+
|
|
179
|
+
Example:
|
|
180
|
+
stream = voidnote.create_stream(api_key="vn_...", title="Deploy run #42")
|
|
181
|
+
print(stream.url) # share with your viewer
|
|
182
|
+
stream.write("Deployment starting…")
|
|
183
|
+
stream.write("Build complete — 47/47 passed")
|
|
184
|
+
stream.close()
|
|
185
|
+
"""
|
|
186
|
+
url: str
|
|
187
|
+
expires_at: str
|
|
188
|
+
_full_token: str = field(repr=False)
|
|
189
|
+
_secret: str = field(repr=False)
|
|
190
|
+
_base: str = field(repr=False, default=DEFAULT_BASE)
|
|
191
|
+
|
|
192
|
+
def write(self, content: str) -> None:
|
|
193
|
+
"""Encrypt and push a message to the stream."""
|
|
194
|
+
if not _HAS_CRYPTO:
|
|
195
|
+
raise ImportError("pip install cryptography")
|
|
196
|
+
encrypted_content, iv = _encrypt(content, self._secret)
|
|
197
|
+
body = json.dumps({"encryptedContent": encrypted_content, "iv": iv}).encode()
|
|
198
|
+
data, status = _fetch(
|
|
199
|
+
f"{self._base}/api/stream/{self._full_token}/write",
|
|
200
|
+
method="POST",
|
|
201
|
+
headers={"Content-Type": "application/json"},
|
|
202
|
+
body=body,
|
|
203
|
+
)
|
|
204
|
+
if status >= 400 or not data.get("success"):
|
|
205
|
+
raise RuntimeError(data.get("error", f"HTTP {status}"))
|
|
206
|
+
|
|
207
|
+
def close(self) -> None:
|
|
208
|
+
"""Close the stream — viewers see a closed event and all content self-destructs."""
|
|
209
|
+
_fetch(
|
|
210
|
+
f"{self._base}/api/stream/{self._full_token}/close",
|
|
211
|
+
method="POST",
|
|
212
|
+
headers={"Content-Type": "application/json"},
|
|
213
|
+
body=b"{}",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def watch(self) -> Generator[str, None, None]:
|
|
217
|
+
"""
|
|
218
|
+
Watch this stream locally (writer-side). Yields decrypted messages.
|
|
219
|
+
Useful for testing or observing your own stream.
|
|
220
|
+
"""
|
|
221
|
+
yield from watch(self.url, base=self._base)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def create_stream(
|
|
225
|
+
*,
|
|
226
|
+
api_key: str,
|
|
227
|
+
title: Optional[str] = None,
|
|
228
|
+
ttl: Literal[3600, 21600, 86400] = 3600,
|
|
229
|
+
base: str = DEFAULT_BASE,
|
|
230
|
+
) -> StreamHandle:
|
|
231
|
+
"""
|
|
232
|
+
Create a new Void Stream. Costs 1 credit.
|
|
233
|
+
|
|
234
|
+
Args:
|
|
235
|
+
api_key: Your API key (vn_...) from the dashboard
|
|
236
|
+
title: Optional plaintext title
|
|
237
|
+
ttl: Lifetime in seconds: 3600 (1h), 21600 (6h), or 86400 (24h)
|
|
238
|
+
base: Override base URL
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
StreamHandle with .url, .write(), .close(), .watch()
|
|
242
|
+
|
|
243
|
+
Example:
|
|
244
|
+
stream = voidnote.create_stream(api_key="vn_...", title="Deploy #42")
|
|
245
|
+
print(stream.url)
|
|
246
|
+
stream.write("Starting deployment...")
|
|
247
|
+
stream.close()
|
|
248
|
+
"""
|
|
249
|
+
full_token, token_id, secret = _generate_token()
|
|
250
|
+
|
|
251
|
+
payload = {"tokenId": token_id, "ttl": ttl}
|
|
252
|
+
if title:
|
|
253
|
+
payload["title"] = title
|
|
254
|
+
|
|
255
|
+
data, status = _fetch(
|
|
256
|
+
f"{base}/api/stream",
|
|
257
|
+
method="POST",
|
|
258
|
+
headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"},
|
|
259
|
+
body=json.dumps(payload).encode(),
|
|
260
|
+
)
|
|
261
|
+
if status >= 400 or not data.get("success"):
|
|
262
|
+
raise RuntimeError(data.get("error", f"HTTP {status}"))
|
|
263
|
+
|
|
264
|
+
url = f"{data['siteUrl']}/stream/{full_token}"
|
|
265
|
+
return StreamHandle(
|
|
266
|
+
url=url,
|
|
267
|
+
expires_at=data.get("expiresAt", ""),
|
|
268
|
+
_full_token=full_token,
|
|
269
|
+
_secret=secret,
|
|
270
|
+
_base=base,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def watch(url_or_token: str, base: str = DEFAULT_BASE) -> Generator[str, None, None]:
|
|
275
|
+
"""
|
|
276
|
+
Watch a Void Stream. Yields decrypted messages as they arrive.
|
|
277
|
+
Blocks until the stream is closed or expired.
|
|
278
|
+
Automatically reconnects on disconnect using Last-Event-ID.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
url_or_token: Full stream URL or raw 64-char token
|
|
282
|
+
base: Override base URL
|
|
283
|
+
|
|
284
|
+
Example:
|
|
285
|
+
for message in voidnote.watch("https://voidnote.net/stream/abc123..."):
|
|
286
|
+
print(message)
|
|
287
|
+
"""
|
|
288
|
+
token = _extract_token(url_or_token)
|
|
289
|
+
secret = token[32:64]
|
|
290
|
+
key = hashlib.sha256(bytes.fromhex(secret)).digest()
|
|
291
|
+
|
|
292
|
+
last_id: Optional[str] = None
|
|
293
|
+
|
|
294
|
+
while True:
|
|
295
|
+
headers: dict = {"Accept": "text/event-stream", "Cache-Control": "no-cache"}
|
|
296
|
+
if last_id is not None:
|
|
297
|
+
headers["Last-Event-ID"] = last_id
|
|
298
|
+
|
|
299
|
+
req = urllib.request.Request(f"{base}/api/stream/{token}/events", headers=headers)
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
|
303
|
+
event_id: Optional[str] = None
|
|
304
|
+
event_data: Optional[str] = None
|
|
305
|
+
|
|
306
|
+
for raw_line in resp:
|
|
307
|
+
line = raw_line.decode("utf-8").rstrip("\r\n")
|
|
308
|
+
|
|
309
|
+
if line.startswith("id: "):
|
|
310
|
+
event_id = line[4:]
|
|
311
|
+
elif line.startswith("data: "):
|
|
312
|
+
event_data = line[6:]
|
|
313
|
+
elif line == "" and event_data is not None:
|
|
314
|
+
if event_id is not None:
|
|
315
|
+
last_id = event_id
|
|
316
|
+
|
|
317
|
+
try:
|
|
318
|
+
data = json.loads(event_data)
|
|
319
|
+
except json.JSONDecodeError:
|
|
320
|
+
event_data = None
|
|
321
|
+
event_id = None
|
|
322
|
+
continue
|
|
323
|
+
|
|
324
|
+
if data.get("type") in ("closed", "expired"):
|
|
325
|
+
return
|
|
326
|
+
|
|
327
|
+
if "enc" in data and "iv" in data:
|
|
328
|
+
try:
|
|
329
|
+
content = _decrypt_stream_msg(data["enc"], data["iv"], key)
|
|
330
|
+
yield content
|
|
331
|
+
except Exception:
|
|
332
|
+
pass # tampered or wrong key
|
|
333
|
+
|
|
334
|
+
event_data = None
|
|
335
|
+
event_id = None
|
|
336
|
+
except Exception:
|
|
337
|
+
if last_id is None:
|
|
338
|
+
raise
|
|
339
|
+
# Reconnect on transient failure
|
|
340
|
+
continue
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _decrypt_stream_msg(enc_hex: str, iv_hex: str, key: bytes) -> str:
|
|
344
|
+
"""Decrypt a single stream message using pre-derived AES key."""
|
|
345
|
+
if not _HAS_CRYPTO:
|
|
346
|
+
raise ImportError("pip install cryptography")
|
|
347
|
+
iv = bytes.fromhex(iv_hex)
|
|
348
|
+
ciphertext = bytes.fromhex(enc_hex)
|
|
349
|
+
aesgcm = AESGCM(key)
|
|
350
|
+
plaintext = aesgcm.decrypt(iv, ciphertext, None)
|
|
351
|
+
return plaintext.decode("utf-8")
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
__all__ = [
|
|
355
|
+
"read", "create", "ReadResult", "CreateResult",
|
|
356
|
+
"create_stream", "watch", "StreamHandle",
|
|
357
|
+
]
|
voidnote-0.1.0/.gitignore
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# Dependencies
|
|
2
|
-
node_modules/
|
|
3
|
-
|
|
4
|
-
# Claude
|
|
5
|
-
.claude/
|
|
6
|
-
# Build output
|
|
7
|
-
dist/
|
|
8
|
-
|
|
9
|
-
# Astro
|
|
10
|
-
.astro/
|
|
11
|
-
|
|
12
|
-
# Wrangler
|
|
13
|
-
.wrangler/
|
|
14
|
-
|
|
15
|
-
# Environment
|
|
16
|
-
.env
|
|
17
|
-
.env.*
|
|
18
|
-
.dev.vars
|
|
19
|
-
|
|
20
|
-
# OS
|
|
21
|
-
.DS_Store
|
|
22
|
-
|
|
23
|
-
# IDE
|
|
24
|
-
.vscode/
|
|
25
|
-
.idea/
|
|
26
|
-
node_modules
|
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
VoidNote SDK — Python
|
|
3
|
-
|
|
4
|
-
pip install voidnote
|
|
5
|
-
|
|
6
|
-
Works with Python 3.9+. Requires: cryptography
|
|
7
|
-
|
|
8
|
-
pip install voidnote cryptography
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
from __future__ import annotations
|
|
12
|
-
|
|
13
|
-
import hashlib
|
|
14
|
-
import os
|
|
15
|
-
import re
|
|
16
|
-
import urllib.request
|
|
17
|
-
import json
|
|
18
|
-
from dataclasses import dataclass
|
|
19
|
-
from typing import Optional
|
|
20
|
-
|
|
21
|
-
try:
|
|
22
|
-
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
23
|
-
_HAS_CRYPTO = True
|
|
24
|
-
except ImportError:
|
|
25
|
-
_HAS_CRYPTO = False
|
|
26
|
-
|
|
27
|
-
DEFAULT_BASE = "https://voidnote.net"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
@dataclass
|
|
31
|
-
class ReadResult:
|
|
32
|
-
content: str
|
|
33
|
-
title: Optional[str]
|
|
34
|
-
view_count: int
|
|
35
|
-
max_views: int
|
|
36
|
-
destroyed: bool
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
@dataclass
|
|
40
|
-
class CreateResult:
|
|
41
|
-
url: str
|
|
42
|
-
expires_at: str
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
# --- internal helpers ---
|
|
46
|
-
|
|
47
|
-
def _extract_token(url_or_token: str) -> str:
|
|
48
|
-
match = re.search(r"[0-9a-f]{64}", url_or_token, re.IGNORECASE)
|
|
49
|
-
if not match:
|
|
50
|
-
raise ValueError("No valid 64-char token found in input")
|
|
51
|
-
return match.group(0).lower()
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _generate_token() -> tuple[str, str, str]:
|
|
55
|
-
token_bytes = os.urandom(32)
|
|
56
|
-
full_token = token_bytes.hex()
|
|
57
|
-
return full_token, full_token[:32], full_token[32:]
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _encrypt(content: str, secret: str) -> tuple[str, str]:
|
|
61
|
-
if not _HAS_CRYPTO:
|
|
62
|
-
raise ImportError(
|
|
63
|
-
"The 'cryptography' package is required to create notes: "
|
|
64
|
-
"pip install cryptography"
|
|
65
|
-
)
|
|
66
|
-
secret_bytes = bytes.fromhex(secret)
|
|
67
|
-
key = hashlib.sha256(secret_bytes).digest()
|
|
68
|
-
iv = os.urandom(12)
|
|
69
|
-
aesgcm = AESGCM(key)
|
|
70
|
-
ciphertext = aesgcm.encrypt(iv, content.encode("utf-8"), None)
|
|
71
|
-
return ciphertext.hex(), iv.hex()
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def _fetch(url: str, *, method: str = "GET", headers: dict | None = None, body: bytes | None = None):
|
|
75
|
-
req = urllib.request.Request(url, data=body, method=method, headers=headers or {})
|
|
76
|
-
with urllib.request.urlopen(req) as resp:
|
|
77
|
-
return json.loads(resp.read().decode("utf-8")), resp.status
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
# --- public API ---
|
|
81
|
-
|
|
82
|
-
def read(url_or_token: str, base: str = DEFAULT_BASE) -> ReadResult:
|
|
83
|
-
"""
|
|
84
|
-
Read a VoidNote. Consumes one view.
|
|
85
|
-
|
|
86
|
-
Args:
|
|
87
|
-
url_or_token: Full VoidNote URL or raw 64-char hex token
|
|
88
|
-
base: Override base URL (default: https://voidnote.net)
|
|
89
|
-
|
|
90
|
-
Returns:
|
|
91
|
-
ReadResult with .content, .title, .view_count, .max_views, .destroyed
|
|
92
|
-
|
|
93
|
-
Example:
|
|
94
|
-
result = voidnote.read("https://voidnote.net/note/abc123...")
|
|
95
|
-
print(result.content)
|
|
96
|
-
"""
|
|
97
|
-
token = _extract_token(url_or_token)
|
|
98
|
-
data, status = _fetch(f"{base}/api/note/{token}")
|
|
99
|
-
if status >= 400:
|
|
100
|
-
raise RuntimeError(data.get("error", f"HTTP {status}"))
|
|
101
|
-
return ReadResult(
|
|
102
|
-
content=data["content"],
|
|
103
|
-
title=data.get("title"),
|
|
104
|
-
view_count=data["viewCount"],
|
|
105
|
-
max_views=data["maxViews"],
|
|
106
|
-
destroyed=data["destroyed"],
|
|
107
|
-
)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def create(
|
|
111
|
-
content: str,
|
|
112
|
-
*,
|
|
113
|
-
api_key: str,
|
|
114
|
-
title: Optional[str] = None,
|
|
115
|
-
max_views: int = 1,
|
|
116
|
-
base: str = DEFAULT_BASE,
|
|
117
|
-
) -> CreateResult:
|
|
118
|
-
"""
|
|
119
|
-
Create a VoidNote. Encrypts content client-side — the server never sees plaintext.
|
|
120
|
-
|
|
121
|
-
Args:
|
|
122
|
-
content: The secret text to encrypt and store
|
|
123
|
-
api_key: Your API key from dashboard (vn_...)
|
|
124
|
-
title: Optional plaintext title (keep non-sensitive)
|
|
125
|
-
max_views: How many times the note can be read (1–100)
|
|
126
|
-
base: Override base URL (default: https://voidnote.net)
|
|
127
|
-
|
|
128
|
-
Returns:
|
|
129
|
-
CreateResult with .url (shareable link) and .expires_at
|
|
130
|
-
|
|
131
|
-
Example:
|
|
132
|
-
result = voidnote.create("my-secret-api-key", api_key="vn_...")
|
|
133
|
-
print(result.url)
|
|
134
|
-
"""
|
|
135
|
-
if not 1 <= max_views <= 100:
|
|
136
|
-
raise ValueError("max_views must be between 1 and 100")
|
|
137
|
-
|
|
138
|
-
full_token, token_id, secret = _generate_token()
|
|
139
|
-
encrypted_content, iv = _encrypt(content, secret)
|
|
140
|
-
|
|
141
|
-
payload = {
|
|
142
|
-
"tokenId": token_id,
|
|
143
|
-
"encryptedContent": encrypted_content,
|
|
144
|
-
"iv": iv,
|
|
145
|
-
"maxViews": max_views,
|
|
146
|
-
}
|
|
147
|
-
if title:
|
|
148
|
-
payload["title"] = title
|
|
149
|
-
|
|
150
|
-
body = json.dumps(payload).encode("utf-8")
|
|
151
|
-
headers = {
|
|
152
|
-
"Content-Type": "application/json",
|
|
153
|
-
"Authorization": f"Bearer {api_key}",
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
data, status = _fetch(f"{base}/api/notes", method="POST", headers=headers, body=body)
|
|
157
|
-
if status >= 400 or not data.get("success"):
|
|
158
|
-
raise RuntimeError(data.get("error", f"HTTP {status}"))
|
|
159
|
-
|
|
160
|
-
return CreateResult(
|
|
161
|
-
url=f"{data['siteUrl']}/note/{full_token}",
|
|
162
|
-
expires_at=data["expiresAt"],
|
|
163
|
-
)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
__all__ = ["read", "create", "ReadResult", "CreateResult"]
|
|
File without changes
|