Skip to content

Commit 3f9d428

Browse files
committed
Add --expires-in to 1time send and send-file
Accepts compact duration strings: Nd, Nh, or NdNh (e.g. 2d, 23h, 2d23h). Default 1d, max 30d, validated locally before the request. Bumps cli to 0.5.0.
1 parent bd83c58 commit 3f9d428

4 files changed

Lines changed: 330 additions & 11 deletions

File tree

cli/README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,14 @@ printf 'hello' | 1time send --host https://1time.io
104104
printf 'hello' | 1time send --host http://127.0.0.1:8080
105105
```
106106

107+
Set a custom expiry:
108+
109+
```bash
110+
printf 'hello' | 1time send --expires-in 23h
111+
printf 'hello' | 1time send --expires-in 2d
112+
printf 'hello' | 1time send --expires-in 2d23h
113+
```
114+
107115
### `1time read <link>`
108116

109117
Fetch and decrypt a one-time link.
@@ -136,6 +144,10 @@ Examples:
136144
1time send-file --host http://127.0.0.1:8080 --passphrase 'extra-passphrase' ./report.zip
137145
```
138146

147+
```bash
148+
1time send-file --expires-in 2d23h ./report.zip
149+
```
150+
139151
### `1time read-file <link>`
140152

141153
Fetch, decrypt, and save a one-time file link.
@@ -159,6 +171,7 @@ Examples:
159171
## Command Reference
160172

161173
- `--host <host-or-origin>`
174+
- `--expires-in <duration>` for `send` and `send-file`; supports `h` and `d`, for example `23h`, `2d`, or `2d23h`; default is `1d`; maximum is `30d`
162175
- `--passphrase <passphrase>` for `send-file` and `read-file`
163176
- `--out <path>` for `read-file`
164177
- `-h`, `--help`
@@ -176,11 +189,10 @@ Examples:
176189
- `1time send 'secret'` is supported for convenience, but it is insecure because command-line arguments can leak through shell history and process listings.
177190
- `1time read <link>` also places the full secret link in command history and process listings. In this protocol, the URL fragment contains the decryption material.
178191
- `send-file` and `read-file` support optional passphrases via `--passphrase` or `1TIME_PASSPHRASE`.
179-
- Custom expiry is not yet supported from the CLI.
192+
- `--expires-in` is validated locally before the CLI sends the secret or file.
180193

181194
## Current Limitations
182195

183-
- no custom expiry support yet
184196
- `read` currently accepts the link as a command argument only
185197

186198
## Local Development

cli/lib.mjs

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ const secretArgWarning = 'Warning: passing the secret in argv may leak via shell
1919
const passphraseArgWarning = 'Warning: passing the passphrase in argv may leak via shell history or process listings.\n';
2020
const textEncoder = new TextEncoder();
2121
const textDecoder = new TextDecoder();
22+
const secondsPerHour = 60 * 60;
23+
const secondsPerDay = secondsPerHour * 24;
24+
const maxExpiresInSeconds = 30 * secondsPerDay;
25+
const defaultExpiresInSeconds = ProtocolConstants.defaultDuration * secondsPerDay;
2226

2327
function write(stream, text) {
2428
if (text) {
@@ -30,9 +34,9 @@ export function getHelpText() {
3034
return `1time v0
3135
3236
Usage:
33-
1time send [--host <host-or-origin>] [secret]
37+
1time send [--host <host-or-origin>] [--expires-in <Nd|Nh|NdNh>] [secret]
3438
1time read [--host <host-or-origin>] <link>
35-
1time send-file [--host <host-or-origin>] [--passphrase <passphrase>] <path>
39+
1time send-file [--host <host-or-origin>] [--expires-in <Nd|Nh|NdNh>] [--passphrase <passphrase>] <path>
3640
1time read-file [--host <host-or-origin>] [--passphrase <passphrase>] [--out <path>] <link>
3741
1time --help
3842
@@ -42,9 +46,8 @@ Input precedence for send:
4246
3. positional secret argument (warns because argv is not safe)
4347
4448
Notes:
45-
- read only supports links passed as an argument in v0
4649
- send-file/read-file support optional passphrases via --passphrase or 1TIME_PASSPHRASE
47-
- custom expiry is not supported in v0
50+
- --expires-in supports h and d units, for example 23h, 2d, or 2d23h (default 1d, max 30d)
4851
- http:// is only allowed for loopback hosts such as 127.0.0.1
4952
`;
5053
}
@@ -91,6 +94,30 @@ function resolveOptionalPassphrase({env, values, stderr}) {
9194
return '';
9295
}
9396

97+
function parseExpiresIn(value) {
98+
if (value === undefined) {
99+
return defaultExpiresInSeconds;
100+
}
101+
102+
const raw = String(value).trim().toLowerCase();
103+
const match = /^(?:(\d+)d)?(?:(\d+)h)?$/.exec(raw);
104+
if (!match || (!match[1] && !match[2])) {
105+
throw new Error(`Invalid expires-in value "${raw}": use d and h units, for example 23h, 2d, or 2d23h.`);
106+
}
107+
108+
const totalSeconds = Number(match[1] || 0) * secondsPerDay + Number(match[2] || 0) * secondsPerHour;
109+
110+
if (totalSeconds <= 0) {
111+
throw new Error(`Invalid expires-in value "${raw}": duration must be greater than 0.`);
112+
}
113+
114+
if (totalSeconds > maxExpiresInSeconds) {
115+
throw new Error(`Invalid expires-in value "${raw}": maximum is 30d.`);
116+
}
117+
118+
return totalSeconds;
119+
}
120+
94121
async function postJson({origin, path, payload, fetchImpl}) {
95122
const response = await fetchImpl(buildApiUrl(origin, path), {
96123
method: 'POST',
@@ -107,7 +134,7 @@ async function postJson({origin, path, payload, fetchImpl}) {
107134
return response.json();
108135
}
109136

110-
export async function createSecretLink({host, secret, fetchImpl}) {
137+
export async function createSecretLink({host, secret, expiresInSeconds = defaultExpiresInSeconds, fetchImpl}) {
111138
const origin = normalizeOrigin(host || ProtocolConstants.defaultHost);
112139
const generatedKey = getRandomString(ProtocolConstants.randomKeyLen);
113140
//left for later passphrase
@@ -118,7 +145,7 @@ export async function createSecretLink({host, secret, fetchImpl}) {
118145
payload: {
119146
secretMessage: encryptedMessage,
120147
hashedKey,
121-
duration: ProtocolConstants.defaultDuration * 86400,
148+
duration: expiresInSeconds,
122149
},
123150
fetchImpl,
124151
});
@@ -188,7 +215,7 @@ async function writeFileToAvailablePath(targetPath, fileBytes) {
188215
throw new Error(`Failed to allocate output path for ${targetPath}`);
189216
}
190217

191-
async function createFileLink({host, filePath, passphrase = '', fetchImpl}) {
218+
async function createFileLink({host, filePath, passphrase = '', expiresInSeconds = defaultExpiresInSeconds, fetchImpl}) {
192219
const origin = normalizeOrigin(host || ProtocolConstants.defaultHost);
193220
const fileBytes = await readFile(filePath);
194221
const meta = {
@@ -204,7 +231,7 @@ async function createFileLink({host, filePath, passphrase = '', fetchImpl}) {
204231
const formData = new FormData();
205232
formData.append('file', new Blob([encryptedBytes]), 'encrypted.bin');
206233
formData.append('hashedKey', hashedKey);
207-
formData.append('duration', String(ProtocolConstants.defaultDuration * 86400));
234+
formData.append('duration', String(expiresInSeconds));
208235

209236
const response = await fetchImpl(buildApiUrl(origin, 'saveFile'), {
210237
method: 'POST',
@@ -322,6 +349,9 @@ function parseSendArgs(args) {
322349
host: {
323350
type: 'string',
324351
},
352+
'expires-in': {
353+
type: 'string',
354+
},
325355
},
326356
});
327357
}
@@ -338,6 +368,9 @@ function parseSendFileArgs(args) {
338368
host: {
339369
type: 'string',
340370
},
371+
'expires-in': {
372+
type: 'string',
373+
},
341374
passphrase: {
342375
type: 'string',
343376
},
@@ -414,6 +447,7 @@ export async function run(argv = process.argv.slice(2), io = {}) {
414447
}
415448

416449
try {
450+
const expiresInSeconds = parseExpiresIn(values['expires-in']);
417451
const secret = await resolveSecret({
418452
stdin,
419453
env,
@@ -426,6 +460,7 @@ export async function run(argv = process.argv.slice(2), io = {}) {
426460
const link = await createSecretLink({
427461
host: values.host,
428462
secret,
463+
expiresInSeconds,
429464
fetchImpl,
430465
});
431466
write(stdout, `${link}\n`);
@@ -448,10 +483,12 @@ export async function run(argv = process.argv.slice(2), io = {}) {
448483
}
449484

450485
try {
486+
const expiresInSeconds = parseExpiresIn(values['expires-in']);
451487
const link = await createFileLink({
452488
host: values.host,
453489
filePath: positionals[0],
454490
passphrase: resolveOptionalPassphrase({env, values, stderr}),
491+
expiresInSeconds,
455492
fetchImpl,
456493
});
457494
write(stdout, `${link}\n`);

0 commit comments

Comments
 (0)