chore: add thumbnail in tunnel
rabbitprincess committed
Mar 7, 2026 at 19:00 UTC
4c08848531618fbd5b35723df1c78d95a742ebe4
6 files changed
+114
-59
cmd/demo-app/main.go
+4
-13
@@ -2,8 +2,6 @@ package main
2
3
import (
4
"context"
5
- _ "embed"
6
- "encoding/base64"
5
"flag"
6
"fmt"
7
"os"
@@ -26,10 +24,7 @@ var (
24
flagTags string
25
flagOwner string
26
flagHide bool
29
-
30
- //go:embed static/thumbnail.png
31
- thumbnailPNG []byte
32
- flagThumbnail = "data:image/png;base64," + base64.StdEncoding.EncodeToString(thumbnailPNG)
27
+ flagThumbnail string
28
)
29
30
func main() {
@@ -42,6 +37,7 @@ func main() {
37
flag.StringVar(&flagDesc, "description", "Portal demo connectivity app", "lease description")
38
flag.StringVar(&flagTags, "tags", "demo,connectivity,activity,cloud,sun,morning", "comma-separated lease tags")
39
flag.StringVar(&flagOwner, "owner", "PortalApp Developer", "lease owner")
40
+ flag.StringVar(&flagThumbnail, "thumbnail", "https://picsum.photos/640/360", "lease thumbnail")
41
flag.BoolVar(&flagHide, "hide", false, "hide this lease from listings")
42
43
flag.Parse()
@@ -54,7 +50,6 @@ func main() {
50
51
func runDemo() error {
52
logger := log.With().Str("component", "demo-app").Logger()
57
-
53
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
54
defer stop()
55
@@ -79,17 +74,13 @@ func runDemo() error {
74
}
75
defer listener.Close()
76
82
- logger.Info().
83
- Strs("public_urls", listener.PublicURLs()).
84
- Int("local_port", flagPort).
85
- Msg("demo app registered with relay")
86
-
87
- if err := sdk.RunHTTPApp(ctx, listener, newHandler(), sdk.HTTPServeOptions{
77
+ if err := sdk.RunHTTP(ctx, listener, newHandler(), sdk.HTTPServeOptions{
78
LocalAddr: fmt.Sprintf(":%d", flagPort),
79
}); err != nil {
80
return err
81
}
82
83
+ logger.Info().Strs("public_urls", listener.PublicURLs()).Int("local_port", flagPort).Msg("demo app registered with relay")
84
if ctx.Err() != nil {
85
logger.Info().Msg("demo app shutting down")
86
}
cmd/demo-app/static/thumbnail.png
Binary files a/cmd/demo-app/static/thumbnail.png and /dev/null differ
cmd/relay-server/tunnel.go
+2
-2
@@ -151,8 +151,8 @@ if ($ActualHash -ne $ExpectedHash) {
151
152
$ArgsList = @("--relays", $RelayUrls)
153
154
-if ($env:HOST) { $ArgsList += "--host", $env:HOST } else { $ArgsList += "--host", "localhost:3000" }
155
-if ($env:NAME) { $ArgsList += "--name", $env:NAME }
154
+if ($env:APP_HOST) { $ArgsList += "--host", $env:APP_HOST } else { $ArgsList += "--host", "localhost:3000" }
155
+if ($env:APP_NAME) { $ArgsList += "--name", $env:APP_NAME }
156
if ($env:APP_DESCRIPTION) { $ArgsList += "--description", $env:APP_DESCRIPTION }
157
if ($env:APP_TAGS) { $ArgsList += "--tags", $env:APP_TAGS }
158
if ($env:APP_THUMBNAIL) { $ArgsList += "--thumbnail", $env:APP_THUMBNAIL }
frontend/src/components/TunnelCommandModal.tsx
+104
-24
@@ -1,5 +1,5 @@
1
-import { useState, useMemo } from "react";
2
-import { Copy, Check, Terminal, X } from "lucide-react";
1
+import { useMemo, useState } from "react";
2
+import { Check, Copy, Terminal, X } from "lucide-react";
3
import { cn } from "@/lib/utils";
4
import {
5
Dialog,
@@ -35,6 +35,20 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
35
const [urlInput, setUrlInput] = useState("");
36
const [copied, setCopied] = useState(false);
37
const [os, setOs] = useState<"unix" | "windows">("unix");
38
+ const [thumbnailURL, setThumbnailURL] = useState("");
39
+ const normalizedThumbnailURL = useMemo(
40
+ () => normalizeAbsoluteHTTPURL(thumbnailURL),
41
+ [thumbnailURL]
42
+ );
43
+ const thumbnailError = useMemo(() => {
44
+ if (thumbnailURL.trim() === "") {
45
+ return "";
46
+ }
47
+ if (normalizedThumbnailURL !== "") {
48
+ return "";
49
+ }
50
+ return "Thumbnail must be an absolute http:// or https:// URL.";
51
+ }, [thumbnailURL, normalizedThumbnailURL]);
52
53
const addRelayUrl = (url: string) => {
54
const trimmed = url.trim();
@@ -69,8 +83,8 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
83
84
// Generate the tunnel command
85
const command = useMemo(() => {
72
- const hostVal = host === "" ? defaultHost : host;
73
- const nameVal = name === "" ? defaultName : name;
86
+ const hostVal = host.trim() === "" ? defaultHost : host.trim();
87
+ const nameVal = name.trim() === "" ? defaultName : name.trim();
88
const relayUrlVal =
89
relayUrls.length > 0 ? relayUrls.join(",") : currentOrigin;
90
const tunnelScriptURL = new URL(API_PATHS.tunnel, currentOrigin).toString();
@@ -79,12 +93,37 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
93
if (os === "windows") {
94
const windowsScriptURL = new URL(tunnelScriptURL);
95
windowsScriptURL.searchParams.set("os", "windows");
82
- return `$ProgressPreference = 'SilentlyContinue'; $env:HOST="${hostVal}"; $env:NAME="${nameVal}"; $env:RELAY_URL="${relayUrlVal}"; irm ${windowsScriptURL.toString()} | iex`;
96
+ const envAssignments = [
97
+ "$ProgressPreference = 'SilentlyContinue'",
98
+ `$env:APP_HOST=${quotePowerShellValue(hostVal)}`,
99
+ `$env:APP_NAME=${quotePowerShellValue(nameVal)}`,
100
+ `$env:RELAYS=${quotePowerShellValue(relayUrlVal)}`,
101
+ ];
102
+ if (normalizedThumbnailURL) {
103
+ envAssignments.push(
104
+ `$env:APP_THUMBNAIL=${quotePowerShellValue(normalizedThumbnailURL)}`
105
+ );
106
+ }
107
+ return `${envAssignments.join("; ")}; irm ${quotePowerShellValue(
108
+ windowsScriptURL.toString()
109
+ )} | iex`;
110
}
111
112
const curlFlags = localhostRelay ? "-kfsSL" : "-fsSL";
86
- return `curl ${curlFlags} ${tunnelScriptURL} | APP_HOST=${hostVal} APP_NAME=${nameVal} RELAYS="${relayUrlVal}" sh`;
87
- }, [currentOrigin, host, name, relayUrls, os]);
113
+ const envAssignments = [
114
+ `APP_HOST=${quoteShellValue(hostVal)}`,
115
+ `APP_NAME=${quoteShellValue(nameVal)}`,
116
+ `RELAYS=${quoteShellValue(relayUrlVal)}`,
117
+ ];
118
+ if (normalizedThumbnailURL) {
119
+ envAssignments.push(
120
+ `APP_THUMBNAIL=${quoteShellValue(normalizedThumbnailURL)}`
121
+ );
122
+ }
123
+ return `curl ${curlFlags} ${quoteShellValue(
124
+ tunnelScriptURL
125
+ )} | ${envAssignments.join(" ")} sh`;
126
+ }, [currentOrigin, host, name, normalizedThumbnailURL, relayUrls, os]);
127
128
const handleCopy = async () => {
129
try {
@@ -126,9 +165,7 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
165
Host
166
</label>
167
<div className="flex items-center rounded-md bg-border">
129
- <span className="px-3 text-sm text-text-muted">
130
- {os === "windows" ? "HOST=" : "APP_HOST="}
131
- </span>
168
+ <span className="px-3 text-sm text-text-muted">APP_HOST=</span>
169
<Input
170
id="host"
171
type="text"
@@ -152,9 +189,7 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
189
Service Name
190
</label>
191
<div className="flex items-center rounded-md bg-border">
155
- <span className="px-3 text-sm text-text-muted">
156
- {os === "windows" ? "NAME=" : "APP_NAME="}
157
- </span>
192
+ <span className="px-3 text-sm text-text-muted">APP_NAME=</span>
193
<Input
194
id="name"
195
type="text"
@@ -205,6 +240,37 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
240
</p>
241
</div>
242
243
+ <div className="space-y-2">
244
+ <label
245
+ htmlFor="thumbnail-url"
246
+ className="text-sm font-medium text-foreground"
247
+ >
248
+ Thumbnail URL
249
+ </label>
250
+ <Input
251
+ id="thumbnail-url"
252
+ type="url"
253
+ value={thumbnailURL}
254
+ onChange={(e) => setThumbnailURL(e.target.value)}
255
+ placeholder="https://cdn.example.com/thumb.png"
256
+ />
257
+ <p className="text-xs text-text-muted">
258
+ Image URL passed to `portal-tunnel`.
259
+ </p>
260
+ {normalizedThumbnailURL && (
261
+ <div className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-md border border-input bg-background">
262
+ <img
263
+ src={normalizedThumbnailURL}
264
+ alt="Thumbnail preview"
265
+ className="h-full w-full object-cover"
266
+ />
267
+ </div>
268
+ )}
269
+ {thumbnailError && (
270
+ <p className="text-xs text-destructive">{thumbnailError}</p>
271
+ )}
272
+ </div>
273
+
274
{/* OS Selection */}
275
<div className="space-y-2">
276
<label className="text-sm font-medium text-foreground">
@@ -236,17 +302,6 @@ export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) {
302
</div>
303
</div>
304
239
- {/* Transport */}
240
- <div className="space-y-2">
241
- <label className="text-sm font-medium text-foreground">
242
- Transport
243
- </label>
244
- <p className="text-xs text-text-muted">
245
- Reverse connect is TLS-only. Generated commands run tunnel in
246
- keyless TLS mode.
247
- </p>
248
- </div>
249
-
305
{/* Generated Command */}
306
<div className="space-y-2">
307
<label className="text-sm font-medium text-foreground">
@@ -289,3 +344,28 @@ function isLocalRelayOrigin(origin: string): boolean {
344
return false;
345
}
346
}
347
+
348
+function quoteShellValue(value: string): string {
349
+ return "'" + value.replace(/'/g, `'"'"'`) + "'";
350
+}
351
+
352
+function quotePowerShellValue(value: string): string {
353
+ return `'${value.replace(/'/g, "''")}'`;
354
+}
355
+
356
+function normalizeAbsoluteHTTPURL(raw: string): string {
357
+ const trimmed = raw.trim();
358
+ if (trimmed === "") {
359
+ return "";
360
+ }
361
+
362
+ try {
363
+ const parsed = new URL(trimmed);
364
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
365
+ return "";
366
+ }
367
+ return parsed.toString();
368
+ } catch {
369
+ return "";
370
+ }
371
+}
sdk/helper.go
+2
-2
@@ -19,9 +19,9 @@ type HTTPServeOptions struct {
19
ReadHeaderTimeout time.Duration
20
}
21
22
-// RunHTTPApp serves one handler on the relay listener and, optionally, on a
22
+// RunHTTP serves one handler on the relay listener and, optionally, on a
23
// local HTTP address for app-local access.
24
-func RunHTTPApp(ctx context.Context, relayListener net.Listener, handler http.Handler, opts HTTPServeOptions) error {
24
+func RunHTTP(ctx context.Context, relayListener net.Listener, handler http.Handler, opts HTTPServeOptions) error {
25
readHeaderTimeout := opts.ReadHeaderTimeout
26
if readHeaderTimeout <= 0 {
27
readHeaderTimeout = defaultRequestTimeout
sdk/helper_test.go
+2
-18
@@ -23,7 +23,7 @@ func TestRunHTTPAppRelayOnly(t *testing.T) {
23
24
errCh := make(chan error, 1)
25
go func() {
26
- errCh <- RunHTTPApp(ctx, listener, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
26
+ errCh <- RunHTTP(ctx, listener, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
27
_, _ = io.WriteString(w, "ok")
28
}), HTTPServeOptions{})
29
}()
@@ -62,7 +62,7 @@ func TestRunHTTPAppLocalAndRelay(t *testing.T) {
62
63
errCh := make(chan error, 1)
64
go func() {
65
- errCh <- RunHTTPApp(ctx, relayListener, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
65
+ errCh <- RunHTTP(ctx, relayListener, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
66
_, _ = io.WriteString(w, "ok")
67
}), HTTPServeOptions{
68
LocalAddr: localAddr,
@@ -83,22 +83,6 @@ func TestRunHTTPAppLocalAndRelay(t *testing.T) {
83
}
84
}
85
86
-func TestSplitCSV(t *testing.T) {
87
- t.Parallel()
88
-
89
- got := SplitCSV(" a, ,b,c ,, d ")
90
- want := []string{"a", "b", "c", "d"}
91
-
92
- if len(got) != len(want) {
93
- t.Fatalf("SplitCSV() len = %d, want %d", len(got), len(want))
94
- }
95
- for i := range want {
96
- if got[i] != want[i] {
97
- t.Fatalf("SplitCSV()[%d] = %q, want %q", i, got[i], want[i])
98
- }
99
- }
100
-}
101
-
86
func waitForHTTP(t *testing.T, rawURL string) {
87
t.Helper()
88