dsh-zen-remote 1.0.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/AGENTS.md +51 -0
- package/LICENSE +38 -0
- package/README.md +237 -0
- package/assets/home.png +0 -0
- package/assets/info.png +0 -0
- package/assets/pairing.png +0 -0
- package/assets/session.png +0 -0
- package/assets/sheet.png +0 -0
- package/cordis.patch.yml +17 -0
- package/cordis.patch.yml.example +16 -0
- package/docs/interface.md +200 -0
- package/docs/remote-access.en.md +335 -0
- package/docs/remote-access.md +422 -0
- package/dsh-push.mjs +213 -0
- package/lan-gate.mjs +78 -0
- package/lib/client.js +6615 -0
- package/lib/index.js +310 -0
- package/lib/index.js.map +1 -0
- package/lib/lan-gate-server.cjs +626 -0
- package/lib/types/client/MobileAttachButton.d.ts +25 -0
- package/lib/types/client/MobileAttachButton.d.ts.map +1 -0
- package/lib/types/client/MobileAttachChips.d.ts +32 -0
- package/lib/types/client/MobileAttachChips.d.ts.map +1 -0
- package/lib/types/client/MobileDrawerFooter.d.ts +21 -0
- package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -0
- package/lib/types/client/MobileHome.d.ts +24 -0
- package/lib/types/client/MobileHome.d.ts.map +1 -0
- package/lib/types/client/MobileHomeChips.d.ts +76 -0
- package/lib/types/client/MobileHomeChips.d.ts.map +1 -0
- package/lib/types/client/MobileNavOverlay.d.ts +15 -0
- package/lib/types/client/MobileNavOverlay.d.ts.map +1 -0
- package/lib/types/client/MobileNavToggle.d.ts +18 -0
- package/lib/types/client/MobileNavToggle.d.ts.map +1 -0
- package/lib/types/client/MobileSessionHeader.d.ts +58 -0
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -0
- package/lib/types/client/MobileSessionInfo.d.ts +41 -0
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -0
- package/lib/types/client/attach-upload.d.ts +78 -0
- package/lib/types/client/attach-upload.d.ts.map +1 -0
- package/lib/types/client/chips-store.d.ts +13 -0
- package/lib/types/client/chips-store.d.ts.map +1 -0
- package/lib/types/client/debug.d.ts +9 -0
- package/lib/types/client/debug.d.ts.map +1 -0
- package/lib/types/client/effects/aionui-compat.d.ts +4 -0
- package/lib/types/client/effects/aionui-compat.d.ts.map +1 -0
- package/lib/types/client/effects/gestures.d.ts +7 -0
- package/lib/types/client/effects/gestures.d.ts.map +1 -0
- package/lib/types/client/effects/header-status.d.ts +15 -0
- package/lib/types/client/effects/header-status.d.ts.map +1 -0
- package/lib/types/client/effects/phone-chrome.d.ts +106 -0
- package/lib/types/client/effects/phone-chrome.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts +25 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +18 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +58 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/nav-store.d.ts +73 -0
- package/lib/types/client/nav-store.d.ts.map +1 -0
- package/lib/types/client/session-dot.d.ts +12 -0
- package/lib/types/client/session-dot.d.ts.map +1 -0
- package/lib/types/client/styles/base.css.d.ts +2 -0
- package/lib/types/client/styles/base.css.d.ts.map +1 -0
- package/lib/types/client/styles/chips.css.d.ts +2 -0
- package/lib/types/client/styles/chips.css.d.ts.map +1 -0
- package/lib/types/client/styles/compat.css.d.ts +2 -0
- package/lib/types/client/styles/compat.css.d.ts.map +1 -0
- package/lib/types/client/styles/composer.css.d.ts +2 -0
- package/lib/types/client/styles/composer.css.d.ts.map +1 -0
- package/lib/types/client/styles/header.css.d.ts +2 -0
- package/lib/types/client/styles/header.css.d.ts.map +1 -0
- package/lib/types/client/styles/home.css.d.ts +2 -0
- package/lib/types/client/styles/home.css.d.ts.map +1 -0
- package/lib/types/client/styles/index.d.ts +15 -0
- package/lib/types/client/styles/index.d.ts.map +1 -0
- package/lib/types/client/styles/info.css.d.ts +2 -0
- package/lib/types/client/styles/info.css.d.ts.map +1 -0
- package/lib/types/client/styles/layout.css.d.ts +2 -0
- package/lib/types/client/styles/layout.css.d.ts.map +1 -0
- package/lib/types/client/styles/misc.css.d.ts +2 -0
- package/lib/types/client/styles/misc.css.d.ts.map +1 -0
- package/lib/types/client/styles/turn-fold.css.d.ts +2 -0
- package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -0
- package/lib/types/index.d.ts +83 -0
- package/lib/types/index.d.ts.map +1 -0
- package/llms.txt +20 -0
- package/package.json +128 -0
- package/pwa/app.css +95 -0
- package/pwa/icons/icon-192.png +0 -0
- package/pwa/icons/icon-512.png +0 -0
- package/pwa/icons/icon-maskable-512.png +0 -0
- package/pwa/icons/icon-maskable.svg +1 -0
- package/pwa/icons/icon.svg +1 -0
- package/pwa/inject.js +190 -0
- package/pwa/manifest.json +29 -0
- package/pwa/offline.html +38 -0
- package/pwa/sw.js +176 -0
- package/pwa/touch-gestures.js +94 -0
- package/scripts/build-client.mjs +101 -0
- package/scripts/cdp-probe.mjs +64 -0
- package/scripts/check-attach-upload.mjs +99 -0
- package/scripts/check-sunk-viewport.mjs +33 -0
- package/scripts/check-upload-endpoint.mjs +168 -0
- package/src/client/MobileAttachButton.tsx +125 -0
- package/src/client/MobileAttachChips.tsx +85 -0
- package/src/client/MobileDrawerFooter.tsx +56 -0
- package/src/client/MobileHome.tsx +348 -0
- package/src/client/MobileHomeChips.tsx +426 -0
- package/src/client/MobileNavOverlay.tsx +279 -0
- package/src/client/MobileNavToggle.tsx +52 -0
- package/src/client/MobileSessionHeader.tsx +242 -0
- package/src/client/MobileSessionInfo.tsx +303 -0
- package/src/client/attach-upload.ts +140 -0
- package/src/client/chips-store.ts +41 -0
- package/src/client/debug.ts +253 -0
- package/src/client/effects/aionui-compat.ts +183 -0
- package/src/client/effects/gestures.ts +265 -0
- package/src/client/effects/header-status.ts +30 -0
- package/src/client/effects/phone-chrome.ts +265 -0
- package/src/client/effects/turn-fold.ts +258 -0
- package/src/client/index.tsx +227 -0
- package/src/client/locales.ts +109 -0
- package/src/client/nav-store.ts +78 -0
- package/src/client/session-dot.ts +17 -0
- package/src/client/styles/base.css.ts +167 -0
- package/src/client/styles/chips.css.ts +224 -0
- package/src/client/styles/compat.css.ts +681 -0
- package/src/client/styles/composer.css.ts +504 -0
- package/src/client/styles/header.css.ts +368 -0
- package/src/client/styles/home.css.ts +477 -0
- package/src/client/styles/index.ts +25 -0
- package/src/client/styles/info.css.ts +238 -0
- package/src/client/styles/layout.css.ts +501 -0
- package/src/client/styles/misc.css.ts +128 -0
- package/src/client/styles/turn-fold.css.ts +106 -0
- package/src/index.ts +343 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// turn-fold — the folded turn process and its summary row (S8, 2026-08-17).
|
|
2
|
+
// Everything that can hide content lives inside (max-width: 767px); the two
|
|
3
|
+
// markers this file reads (data-mnav-fold on a process element,
|
|
4
|
+
// data-mobile-nav="turn-fold" on the injected summary row) are only ever
|
|
5
|
+
// written by effects/turn-fold.ts, which detaches and wipes them at >= 768px
|
|
6
|
+
// — so tablet and desktop are a no-op twice over, by attribute and by media
|
|
7
|
+
// query. Appended last in styles/index.ts; it shares no selector with any
|
|
8
|
+
// other file, the position just keeps the "phone files come after the shared
|
|
9
|
+
// <=1023px block" ordering intact.
|
|
10
|
+
|
|
11
|
+
export const TURN_FOLD_CSS = `/* ---------- turn process fold (< 768px) ---------- */
|
|
12
|
+
|
|
13
|
+
/* The summary row never paints outside the phone breakpoint, even if a
|
|
14
|
+
media-query change raced the effect's own cleanup. */
|
|
15
|
+
[data-mobile-nav="turn-fold"] {
|
|
16
|
+
display: none;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@media (max-width: 767px) {
|
|
20
|
+
/* Folded process: tool-call / context / command rows and the Think
|
|
21
|
+
disclosures inside an assistant step. !important because the official
|
|
22
|
+
flow item and ReasoningRow both set their own display. */
|
|
23
|
+
[data-mnav-fold]:not([data-mnav-fold-open]) {
|
|
24
|
+
display: none !important;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* Compact chip, sized like the composer's own pills rather than a button:
|
|
28
|
+
it is a reading affordance in the middle of the message flow, so it has
|
|
29
|
+
to stay quieter than the content around it. The flow column is a flex
|
|
30
|
+
column with gap: 16px (ChatView.module.css) — align-self keeps the chip
|
|
31
|
+
from stretching, and the negative block margin trims that generous gap
|
|
32
|
+
back to something a 26px row can live in. */
|
|
33
|
+
[data-chat-flow] > [data-mobile-nav="turn-fold"] {
|
|
34
|
+
display: inline-flex;
|
|
35
|
+
align-self: flex-start;
|
|
36
|
+
align-items: center;
|
|
37
|
+
gap: 6px;
|
|
38
|
+
margin: -4px 0;
|
|
39
|
+
padding: 0 10px;
|
|
40
|
+
height: 26px;
|
|
41
|
+
border: none;
|
|
42
|
+
border-radius: 13px;
|
|
43
|
+
background: var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06));
|
|
44
|
+
color: var(--dsw-alias-label-secondary, rgba(0, 0, 0, .55));
|
|
45
|
+
font: inherit;
|
|
46
|
+
font-size: 12px;
|
|
47
|
+
line-height: 26px;
|
|
48
|
+
white-space: nowrap;
|
|
49
|
+
cursor: pointer;
|
|
50
|
+
-webkit-tap-highlight-color: transparent;
|
|
51
|
+
}
|
|
52
|
+
[data-chat-flow] > [data-mobile-nav="turn-fold"]:active {
|
|
53
|
+
background: var(--dsw-alias-interactive-bg-pressed, rgba(0, 0, 0, .1));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* Leading dot: idle turns get a quiet mark, a running turn gets the same
|
|
57
|
+
business-primary colour the session header's own status dot uses
|
|
58
|
+
(styles/header.css.ts) plus a breathing pulse. */
|
|
59
|
+
[data-chat-flow] > [data-mobile-nav="turn-fold"]::before {
|
|
60
|
+
content: '';
|
|
61
|
+
flex: none;
|
|
62
|
+
width: 6px;
|
|
63
|
+
height: 6px;
|
|
64
|
+
border-radius: 50%;
|
|
65
|
+
background: currentColor;
|
|
66
|
+
opacity: .45;
|
|
67
|
+
}
|
|
68
|
+
[data-chat-flow] > [data-mobile-nav="turn-fold"][data-running]::before {
|
|
69
|
+
background: var(--dsw-alias-state-business-primary, #4f6ef7);
|
|
70
|
+
opacity: 1;
|
|
71
|
+
animation: dsh-mobile-nav-breathe 1.4s var(--ds-ease-in-out, ease-in-out) infinite;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/* Chevron drawn from two borders — no icon injection, no mask image, and
|
|
75
|
+
it rotates to point up once the turn is open. */
|
|
76
|
+
[data-chat-flow] > [data-mobile-nav="turn-fold"]::after {
|
|
77
|
+
content: '';
|
|
78
|
+
flex: none;
|
|
79
|
+
width: 5px;
|
|
80
|
+
height: 5px;
|
|
81
|
+
margin-top: -3px;
|
|
82
|
+
border-right: 1.5px solid currentColor;
|
|
83
|
+
border-bottom: 1.5px solid currentColor;
|
|
84
|
+
transform: rotate(45deg);
|
|
85
|
+
transition: transform .16s var(--ds-ease-out, ease-in-out);
|
|
86
|
+
}
|
|
87
|
+
[data-chat-flow] > [data-mobile-nav="turn-fold"][data-open]::after {
|
|
88
|
+
margin-top: 3px;
|
|
89
|
+
transform: rotate(-135deg);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@media (prefers-reduced-motion: reduce) {
|
|
93
|
+
[data-chat-flow] > [data-mobile-nav="turn-fold"][data-running]::before {
|
|
94
|
+
animation: none;
|
|
95
|
+
}
|
|
96
|
+
[data-chat-flow] > [data-mobile-nav="turn-fold"]::after {
|
|
97
|
+
transition: none;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
@keyframes dsh-mobile-nav-breathe {
|
|
103
|
+
0%, 100% { opacity: 1; transform: scale(1); }
|
|
104
|
+
50% { opacity: .4; transform: scale(.72); }
|
|
105
|
+
}
|
|
106
|
+
`
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mobile-nav, node half.
|
|
3
|
+
*
|
|
4
|
+
* Was an empty apply (pure client UI plugin) until S7. It now owns ONE host
|
|
5
|
+
* route: the phone composer's attachment upload. The official file picker
|
|
6
|
+
* opens on the machine running DSH, which is useless from a phone, and the
|
|
7
|
+
* public client API has no upload verb at all — the only public browser->host
|
|
8
|
+
* byte channel is `session.prompt([{type:'image',…}])`, which is images only
|
|
9
|
+
* and lands as a sent message rather than a file on disk. So a non-image
|
|
10
|
+
* attachment needs a route of its own, and that route belongs here rather
|
|
11
|
+
* than in the gateway half of this plugin: the gateway authenticates and forwards
|
|
12
|
+
* verbatim, it does not know what a session or a workspace is.
|
|
13
|
+
*
|
|
14
|
+
* The browser half still ships via exports["./client"], discovered through
|
|
15
|
+
* the package.json dsh.client declaration.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { lstat, mkdir, open, realpath, rm } from 'node:fs/promises'
|
|
19
|
+
import type { FileHandle } from 'node:fs/promises'
|
|
20
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
21
|
+
import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
22
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
23
|
+
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
24
|
+
import type {} from '@deepseek-ai/dsh-session'
|
|
25
|
+
|
|
26
|
+
/** Exact route the phone composer POSTs one file body to. */
|
|
27
|
+
export const UPLOAD_ROUTE = '/_dsh/mobile-nav/upload'
|
|
28
|
+
|
|
29
|
+
/** Workspace-relative directory uploads land in (also the `@` prefix the composer inserts). */
|
|
30
|
+
export const UPLOAD_DIR = '.dsh-uploads'
|
|
31
|
+
|
|
32
|
+
/** Body cap when the plugin row sets no `maxUploadBytes`. */
|
|
33
|
+
export const DEFAULT_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
|
34
|
+
|
|
35
|
+
/** Longest filename, in bytes, that survives sanitization (ext4/APFS leaf limit is 255). */
|
|
36
|
+
const MAX_NAME_BYTES = 180
|
|
37
|
+
|
|
38
|
+
/** Distinct leaf names tried before a collision is given up on. */
|
|
39
|
+
const MAX_COLLISION_TRIES = 100
|
|
40
|
+
|
|
41
|
+
/** Host half config; the only knob is the body cap. */
|
|
42
|
+
export interface MobileNavConfig {
|
|
43
|
+
/** Max upload body in bytes; larger bodies get 413. Default {@link DEFAULT_MAX_UPLOAD_BYTES}. */
|
|
44
|
+
maxUploadBytes?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One rejection carrying the status the client should see.
|
|
49
|
+
*
|
|
50
|
+
* Fields are assigned in the body rather than declared as constructor
|
|
51
|
+
* parameter properties: `scripts/check-upload-endpoint.mjs` imports this
|
|
52
|
+
* module through Node's strip-only type stripping, which rejects that syntax.
|
|
53
|
+
*/
|
|
54
|
+
class UploadError extends Error {
|
|
55
|
+
readonly status: number
|
|
56
|
+
readonly code: string
|
|
57
|
+
|
|
58
|
+
constructor(status: number, code: string, message: string) {
|
|
59
|
+
super(message)
|
|
60
|
+
this.name = 'UploadError'
|
|
61
|
+
this.status = status
|
|
62
|
+
this.code = code
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function message(error: unknown): string {
|
|
67
|
+
return error instanceof Error ? error.message : String(error)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isErrnoCode(error: unknown, code: string): boolean {
|
|
71
|
+
return error instanceof Error && 'code' in error && (error as { code?: unknown }).code === code
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function responseJson(res: ServerResponse, status: number, body: unknown): void {
|
|
75
|
+
const bytes = Buffer.from(JSON.stringify(body))
|
|
76
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
77
|
+
res.setHeader('Content-Length', String(bytes.length))
|
|
78
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
79
|
+
res.setHeader('X-Content-Type-Options', 'nosniff')
|
|
80
|
+
res.setHeader('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'")
|
|
81
|
+
res.writeHead(status)
|
|
82
|
+
res.end(bytes)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Accept a state-changing request only from this DSH Web application's origin.
|
|
87
|
+
*
|
|
88
|
+
* The gateway half rewrites `Origin`/`Host` to the upstream origin
|
|
89
|
+
* before forwarding (lan-gate-server.cjs `cleanHeaders`), so a phone request
|
|
90
|
+
* that already cleared the pairing wall presents here as same-origin; a
|
|
91
|
+
* request with neither header falls back to the Fetch metadata.
|
|
92
|
+
* @param req - the inbound request.
|
|
93
|
+
* @returns true when the request may mutate the workspace.
|
|
94
|
+
*/
|
|
95
|
+
export function sameOriginPost(req: IncomingMessage): boolean {
|
|
96
|
+
const fetchSite = req.headers['sec-fetch-site']
|
|
97
|
+
if (fetchSite === 'cross-site') return false
|
|
98
|
+
const origin = req.headers.origin
|
|
99
|
+
if (origin === undefined) return fetchSite === 'same-origin' || fetchSite === 'same-site' || fetchSite === 'none'
|
|
100
|
+
const host = req.headers.host
|
|
101
|
+
if (host === undefined) return false
|
|
102
|
+
try {
|
|
103
|
+
const parsed = new URL(origin)
|
|
104
|
+
return (parsed.protocol === 'http:' || parsed.protocol === 'https:') && parsed.host === host
|
|
105
|
+
} catch {
|
|
106
|
+
return false
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Reject a resolved path that is not rooted below the expected directory.
|
|
112
|
+
* @param root - the directory the target must stay inside.
|
|
113
|
+
* @param target - the resolved candidate path.
|
|
114
|
+
* @throws when the target escapes the root.
|
|
115
|
+
*/
|
|
116
|
+
export function ensurePathInside(root: string, target: string): void {
|
|
117
|
+
const rel = relative(root, target)
|
|
118
|
+
if (rel !== '' && (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))) {
|
|
119
|
+
throw new UploadError(400, 'path-escape', `resolved upload path escapes its workspace root: ${target}`)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Convert an untrusted browser label into one portable leaf filename.
|
|
125
|
+
*
|
|
126
|
+
* Everything that could steer the write out of the upload directory is gone
|
|
127
|
+
* after this: only the basename survives (so `../../etc/passwd` becomes
|
|
128
|
+
* `passwd`), separators and control characters become `_`, leading dots are
|
|
129
|
+
* dropped, and the Windows reserved device names are prefixed. Whitespace
|
|
130
|
+
* folds to `_` rather than being kept: the client appends the result to the
|
|
131
|
+
* composer draft as an `@path` mention, and a mention with a space in it is
|
|
132
|
+
* broken for the agent reading it, not just for the chip parser. Length is
|
|
133
|
+
* capped in BYTES because the label arrives as UTF-8.
|
|
134
|
+
* @param raw - browser-supplied filename.
|
|
135
|
+
* @returns a single safe leaf name, never empty.
|
|
136
|
+
*/
|
|
137
|
+
export function safeUploadName(raw: string): string {
|
|
138
|
+
const leaf = basename(raw.replaceAll('\\', '/')).normalize('NFC')
|
|
139
|
+
let cleaned = leaf
|
|
140
|
+
.replace(/[<>:"|?*\u0000-\u001f/\\]/gu, '_')
|
|
141
|
+
.replace(/\s+/gu, '_')
|
|
142
|
+
.replace(/^\.+/u, '')
|
|
143
|
+
.trim()
|
|
144
|
+
.replace(/[. ]+$/u, '')
|
|
145
|
+
if (/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/iu.test(cleaned)) cleaned = `_${cleaned}`
|
|
146
|
+
const candidate = cleaned === '' ? 'upload.bin' : cleaned
|
|
147
|
+
if (Buffer.byteLength(candidate) <= MAX_NAME_BYTES) return candidate
|
|
148
|
+
const extension = extname(candidate).slice(0, 20)
|
|
149
|
+
const budget = Math.max(1, MAX_NAME_BYTES - Buffer.byteLength(extension))
|
|
150
|
+
let stem = candidate.slice(0, Math.max(1, candidate.length - extension.length))
|
|
151
|
+
while (Buffer.byteLength(stem) > budget) stem = stem.slice(0, -1)
|
|
152
|
+
return `${stem}${extension}`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function singleQuery(url: URL, key: string): string {
|
|
156
|
+
const values = url.searchParams.getAll(key)
|
|
157
|
+
const value = values[0]
|
|
158
|
+
if (values.length !== 1 || value === undefined || value === '') {
|
|
159
|
+
throw new UploadError(400, 'bad-request', `${key} is required exactly once`)
|
|
160
|
+
}
|
|
161
|
+
return value
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Create the directory if absent, then prove it is a real directory inside the workspace. */
|
|
165
|
+
async function ensureManagedDirectory(workspace: string, path: string): Promise<string> {
|
|
166
|
+
try {
|
|
167
|
+
await mkdir(path, { mode: 0o700 })
|
|
168
|
+
} catch (error) {
|
|
169
|
+
if (!isErrnoCode(error, 'EEXIST')) throw error
|
|
170
|
+
}
|
|
171
|
+
const entry = await lstat(path)
|
|
172
|
+
// A symlink here would be the one way a prior workspace write could still
|
|
173
|
+
// redirect the bytes elsewhere — realpath alone would happily follow it.
|
|
174
|
+
if (entry.isSymbolicLink()) {
|
|
175
|
+
throw new UploadError(400, 'path-escape', `upload directory is a symbolic link: ${path}`)
|
|
176
|
+
}
|
|
177
|
+
if (!entry.isDirectory()) throw new UploadError(400, 'path-escape', `upload path is not a directory: ${path}`)
|
|
178
|
+
const canonical = await realpath(path)
|
|
179
|
+
ensurePathInside(workspace, canonical)
|
|
180
|
+
return canonical
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface UploadRoot {
|
|
184
|
+
/** Canonical directory the bytes are written into. */
|
|
185
|
+
writeRoot: string
|
|
186
|
+
/** The same directory as the user sees it (pre-realpath), for the returned relative path. */
|
|
187
|
+
visibleRoot: string
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Resolve (and create) the upload directory of one live session.
|
|
192
|
+
* @param ctx - host context carrying the sessions service.
|
|
193
|
+
* @param sessionId - the session whose workspace receives the file.
|
|
194
|
+
* @returns the canonical and visible upload directories.
|
|
195
|
+
* @throws 404 when no live session has that id.
|
|
196
|
+
*/
|
|
197
|
+
async function sessionUploadRoot(ctx: Context, sessionId: string): Promise<UploadRoot> {
|
|
198
|
+
const session = ctx.sessions.get(sessionId as never)
|
|
199
|
+
if (session === undefined) throw new UploadError(404, 'session-not-found', `live Session not found: ${sessionId}`)
|
|
200
|
+
const cwd = session.header.cwd
|
|
201
|
+
if (cwd === undefined || !isAbsolute(cwd)) {
|
|
202
|
+
throw new UploadError(404, 'session-not-found', `Session has no absolute workspace: ${sessionId}`)
|
|
203
|
+
}
|
|
204
|
+
const visibleWorkspace = resolve(cwd)
|
|
205
|
+
const workspace = await realpath(visibleWorkspace)
|
|
206
|
+
const visibleRoot = join(visibleWorkspace, UPLOAD_DIR)
|
|
207
|
+
const writeRoot = await ensureManagedDirectory(workspace, visibleRoot)
|
|
208
|
+
return { writeRoot, visibleRoot }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Claim one not-yet-existing leaf name, suffixing `-1`, `-2`, … on collision.
|
|
213
|
+
* `wx` makes the claim atomic, so two concurrent uploads of the same name
|
|
214
|
+
* cannot both win the same path.
|
|
215
|
+
*/
|
|
216
|
+
async function openUnique(directory: string, filename: string): Promise<{ handle: FileHandle; path: string }> {
|
|
217
|
+
const extension = extname(filename)
|
|
218
|
+
const stem = filename.slice(0, filename.length - extension.length) || 'upload'
|
|
219
|
+
for (let n = 0; n < MAX_COLLISION_TRIES; n += 1) {
|
|
220
|
+
const path = join(directory, n === 0 ? `${stem}${extension}` : `${stem}-${n}${extension}`)
|
|
221
|
+
ensurePathInside(directory, path)
|
|
222
|
+
try {
|
|
223
|
+
return { handle: await open(path, 'wx', 0o600), path }
|
|
224
|
+
} catch (error) {
|
|
225
|
+
if (!isErrnoCode(error, 'EEXIST')) throw error
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
throw new UploadError(409, 'name-taken', `too many files named like ${filename}`)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Stream the request body onto disk under a running byte cap.
|
|
233
|
+
* @param req - the request whose body is the file.
|
|
234
|
+
* @param directory - canonical upload directory.
|
|
235
|
+
* @param filename - sanitized leaf name.
|
|
236
|
+
* @param maxBytes - hard cap; exceeding it aborts and unlinks.
|
|
237
|
+
* @returns the absolute path written and its byte count.
|
|
238
|
+
*/
|
|
239
|
+
async function writeUpload(
|
|
240
|
+
req: IncomingMessage,
|
|
241
|
+
directory: string,
|
|
242
|
+
filename: string,
|
|
243
|
+
maxBytes: number,
|
|
244
|
+
): Promise<{ path: string; bytes: number }> {
|
|
245
|
+
const declared = req.headers['content-length']
|
|
246
|
+
const expected = declared === undefined ? undefined : Number(declared)
|
|
247
|
+
if (expected !== undefined && (!Number.isSafeInteger(expected) || expected < 0)) {
|
|
248
|
+
throw new UploadError(400, 'bad-request', 'Content-Length is not a byte count')
|
|
249
|
+
}
|
|
250
|
+
// Reject the oversized upload before a single byte is read, so the phone
|
|
251
|
+
// gets its 413 without spending the whole body on the radio.
|
|
252
|
+
if (expected !== undefined && expected > maxBytes) {
|
|
253
|
+
throw new UploadError(413, 'too-large', `upload exceeds the ${maxBytes}-byte limit`)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const { handle, path } = await openUnique(directory, filename)
|
|
257
|
+
let received = 0
|
|
258
|
+
try {
|
|
259
|
+
for await (const chunk of req) {
|
|
260
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)
|
|
261
|
+
received += bytes.length
|
|
262
|
+
// Chunked bodies declare no length, so the cap must also hold here.
|
|
263
|
+
if (received > maxBytes) throw new UploadError(413, 'too-large', `upload exceeds the ${maxBytes}-byte limit`)
|
|
264
|
+
await handle.write(bytes)
|
|
265
|
+
}
|
|
266
|
+
if (expected !== undefined && received !== expected) {
|
|
267
|
+
throw new UploadError(400, 'truncated', `upload body size mismatch: expected ${expected}, received ${received}`)
|
|
268
|
+
}
|
|
269
|
+
await handle.close()
|
|
270
|
+
return { path, bytes: received }
|
|
271
|
+
} catch (error) {
|
|
272
|
+
await handle.close().catch(() => {})
|
|
273
|
+
await rm(path, { force: true }).catch(() => {})
|
|
274
|
+
throw error
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Handle one `POST {@link UPLOAD_ROUTE}?session=<id>&name=<file>` request.
|
|
280
|
+
*
|
|
281
|
+
* Exported so an integration check can drive it with a plain node:http server
|
|
282
|
+
* and a fake sessions service instead of booting a harness.
|
|
283
|
+
* @param ctx - host context carrying the sessions service and logger.
|
|
284
|
+
* @param maxBytes - body cap.
|
|
285
|
+
* @param req - inbound request; its body is the raw file.
|
|
286
|
+
* @param res - the response this call owns end to end.
|
|
287
|
+
*/
|
|
288
|
+
export async function handleUpload(
|
|
289
|
+
ctx: Context,
|
|
290
|
+
maxBytes: number,
|
|
291
|
+
req: IncomingMessage,
|
|
292
|
+
res: ServerResponse,
|
|
293
|
+
): Promise<void> {
|
|
294
|
+
if (req.method !== 'POST') {
|
|
295
|
+
res.setHeader('Allow', 'POST')
|
|
296
|
+
responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use POST' } })
|
|
297
|
+
return
|
|
298
|
+
}
|
|
299
|
+
if (!sameOriginPost(req)) {
|
|
300
|
+
const error = { code: 'origin-rejected', message: 'The request must originate from this DSH Web application' }
|
|
301
|
+
responseJson(res, 403, { ok: false, error })
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
const url = new URL(req.url ?? UPLOAD_ROUTE, 'http://dsh.internal')
|
|
306
|
+
const sessionId = singleQuery(url, 'session')
|
|
307
|
+
const filename = safeUploadName(singleQuery(url, 'name'))
|
|
308
|
+
const root = await sessionUploadRoot(ctx, sessionId)
|
|
309
|
+
const written = await writeUpload(req, root.writeRoot, filename, maxBytes)
|
|
310
|
+
const leaf = basename(written.path)
|
|
311
|
+
responseJson(res, 201, {
|
|
312
|
+
ok: true,
|
|
313
|
+
relPath: `${UPLOAD_DIR}/${leaf}`,
|
|
314
|
+
absolutePath: join(root.visibleRoot, leaf),
|
|
315
|
+
filename: leaf,
|
|
316
|
+
bytes: written.bytes,
|
|
317
|
+
})
|
|
318
|
+
} catch (error) {
|
|
319
|
+
const status = error instanceof UploadError ? error.status : 400
|
|
320
|
+
const code = error instanceof UploadError ? error.code : 'upload-rejected'
|
|
321
|
+
ctx.logger.warn('dsh-mobile-nav upload rejected: %s', message(error))
|
|
322
|
+
responseJson(res, status, { ok: false, error: { code, message: message(error) } })
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Host half: mount the upload route wherever a webServer and live sessions
|
|
328
|
+
* exist. Both are injected INSIDE apply rather than declared as a top-level
|
|
329
|
+
* `inject`, so the plugin row still loads (and the browser half still ships)
|
|
330
|
+
* in a composition without them — Electron carries no webServer.
|
|
331
|
+
* @param ctx - host plugin context.
|
|
332
|
+
* @param config - optional body cap override.
|
|
333
|
+
*/
|
|
334
|
+
export function apply(ctx: Context, config: MobileNavConfig = {}): void {
|
|
335
|
+
const maxBytes = config.maxUploadBytes ?? DEFAULT_MAX_UPLOAD_BYTES
|
|
336
|
+
ctx.inject(['webServer', 'sessions'], (webCtx) => {
|
|
337
|
+
webCtx.effect(() => webCtx.webServer.register({
|
|
338
|
+
kind: 'exact',
|
|
339
|
+
path: UPLOAD_ROUTE,
|
|
340
|
+
handler: (req, res) => handleUpload(webCtx, maxBytes, req, res),
|
|
341
|
+
}), 'dsh-mobile-nav: upload route')
|
|
342
|
+
})
|
|
343
|
+
}
|