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