diff --git a/.env.example b/.env.example index e25215b1..8bd5150b 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,6 @@ PORTAL_URL=https://localhost:4017 # Listener ports API_PORT=4017 SNI_PORT=443 - # UDP transport (0 = disabled). Set count > 0 to enable QUIC tunnel + allocate UDP ports starting from 50000. # e.g., UDP_PORT_COUNT=10 → ports 50000-50009. Also requires enabling UDP in the admin panel. UDP_PORT_COUNT=0 @@ -27,6 +26,7 @@ AWS_HOSTED_ZONE_ID= # Admin/auth configuration ADMIN_SECRET_KEY= +LANDING_PAGE_ENABLED=false # Enable when the relay is behind nginx/ingress/load balancers and should trust forwarded client IP headers. # Optionally restrict which proxy source ranges may supply those headers; leave empty for default private/loopback proxy ranges. TRUST_PROXY_HEADERS=false diff --git a/Dockerfile b/Dockerfile index f42decc4..fb485ccb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ make && rm -rf /var/lib/apt/lists/* COPY frontend ./frontend +COPY utils ./utils COPY Makefile ./ RUN --mount=type=cache,target=/root/.npm \ diff --git a/README.md b/README.md index da2db1f6..1f2d4f31 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,12 @@ For architecture decisions, see [docs/adr/README.md](docs/adr/README.md). ## Public Relay Registry +Portal's official public relay registry is: + +`https://raw.githubusercontent.com/gosuda/portal/main/registry.json` + +Portal tunnel clients can include this registry by default, and the relay UI also reads from the same path to show the official relay list. + If you operate a public Portal relay, open a Pull Request to add your relay URL to `registry.json`. Keeping the registry updated makes public relays easier for the community to discover. ## Contributing diff --git a/cmd/portal-tunnel/README.md b/cmd/portal-tunnel/README.md index 3ab38234..c0bc7a04 100644 --- a/cmd/portal-tunnel/README.md +++ b/cmd/portal-tunnel/README.md @@ -19,13 +19,13 @@ portal list Custom relay and metadata example: ```text -portal expose --name myapp \ +portal expose localhost:8080 \ + --name myapp \ --relays https://portal.example.com \ --description "Service description" \ --tags tag1,tag2 \ --thumbnail https://example.com/thumb.png \ - --owner "Portal Operator" \ - localhost:8080 + --owner "Portal Operator" ``` ## Commands diff --git a/cmd/portal-tunnel/main.go b/cmd/portal-tunnel/main.go index 7bab3473..b23a6ccc 100644 --- a/cmd/portal-tunnel/main.go +++ b/cmd/portal-tunnel/main.go @@ -239,8 +239,8 @@ func printRootUsage(w io.Writer) { }, []string{ "portal expose 3000", - "portal expose --name my-app localhost:8080", - "portal expose --udp --udp-addr 127.0.0.1:5353 3000", + "portal expose localhost:8080 --name my-app", + "portal expose 3000 --udp --udp-addr 127.0.0.1:5353", "portal list", }, ) @@ -253,9 +253,9 @@ func printExposeUsage(w io.Writer) { }, []string{ "portal expose 3000", - "portal expose --name my-app localhost:8080", - "portal expose --udp --udp-addr 127.0.0.1:5353 3000", - "portal expose --relays https://portal.example.com --default-relays=false 3000", + "portal expose localhost:8080 --name my-app", + "portal expose 3000 --udp --udp-addr 127.0.0.1:5353", + "portal expose 3000 --relays https://portal.example.com --default-relays=false", }, ) } diff --git a/cmd/relay-server/admin.go b/cmd/relay-server/admin.go index 63ac8926..c11ef43b 100644 --- a/cmd/relay-server/admin.go +++ b/cmd/relay-server/admin.go @@ -97,26 +97,29 @@ func (a *adminAuth) cleanupExpiredSessionsLocked() { } } -func loadAdminState(path string, runtime *policy.Runtime) error { +func loadAdminState(path string, runtime *policy.Runtime) (persistedAdminState, error) { root, name, err := openSettingsRoot(path) if err != nil { - return err + return persistedAdminState{}, err } defer root.Close() data, err := root.ReadFile(name) if err != nil { if errors.Is(err, os.ErrNotExist) { - return nil + return persistedAdminState{}, nil } - return err + return persistedAdminState{}, err } var payload persistedAdminState if err := json.Unmarshal(data, &payload); err != nil { - return err + return persistedAdminState{}, err } - return payload.apply(runtime) + if err := payload.apply(runtime); err != nil { + return persistedAdminState{}, err + } + return payload, nil } func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) { @@ -196,13 +199,29 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) { return } utils.WriteAPIData(w, http.StatusOK, types.AdminSnapshotResponse{ - ApprovalMode: string(runtime.Approver().Mode()), - Leases: f.adminLeaseSnapshots(), + ApprovalMode: string(runtime.Approver().Mode()), + LandingPageEnabled: f.isLandingPageEnabled(), + Leases: f.adminLeaseSnapshots(), UDP: types.AdminUDPSettingsResponse{ Enabled: runtime.IsUDPEnabled(), MaxLeases: runtime.UDPMaxLeases(), }, }) + case types.PathAdminLandingPage: + if r.Method != http.MethodPost { + methodNotAllowed() + return + } + var req types.AdminLandingPageSettingsRequest + if err := utils.DecodeJSONBody(w, r, &req, 1<<16); err != nil { + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid request body") + return + } + f.setLandingPageEnabled(req.Enabled) + f.saveAdminState(runtime) + utils.WriteAPIData(w, http.StatusOK, types.AdminLandingPageSettingsResponse{ + Enabled: f.isLandingPageEnabled(), + }) case types.PathAdminUDP: if r.Method != http.MethodPost { methodNotAllowed() @@ -399,11 +418,11 @@ func (f *Frontend) saveAdminState(runtime *policy.Runtime) { if f == nil { return } - saveAdminState(f.adminSettingsPath, runtime) + saveAdminState(f.adminSettingsPath, runtime, f.isLandingPageEnabled()) } -func saveAdminState(path string, runtime *policy.Runtime) { - payload := persistedStateFromRuntime(runtime) +func saveAdminState(path string, runtime *policy.Runtime, landingPageEnabled bool) { + payload := persistedStateFromRuntime(runtime, landingPageEnabled) data, err := json.MarshalIndent(payload, "", " ") if err != nil { return @@ -418,30 +437,39 @@ func saveAdminState(path string, runtime *policy.Runtime) { } type persistedAdminState struct { - ApprovalMode string `json:"approval_mode"` - ApprovedLeases []string `json:"approved_leases,omitempty"` - DeniedLeases []string `json:"denied_leases,omitempty"` - BannedLeases []string `json:"banned_leases,omitempty"` - BannedIPs []string `json:"banned_ips,omitempty"` - LeaseBPS map[string]int64 `json:"lease_bps,omitempty"` - UDPEnabled *bool `json:"udp_enabled,omitempty"` - UDPMaxLeases *int `json:"udp_max_leases,omitempty"` + ApprovalMode string `json:"approval_mode"` + ApprovedLeases []string `json:"approved_leases,omitempty"` + DeniedLeases []string `json:"denied_leases,omitempty"` + BannedLeases []string `json:"banned_leases,omitempty"` + BannedIPs []string `json:"banned_ips,omitempty"` + LeaseBPS map[string]int64 `json:"lease_bps,omitempty"` + UDPEnabled *bool `json:"udp_enabled,omitempty"` + UDPMaxLeases *int `json:"udp_max_leases,omitempty"` + LandingPageEnabled *bool `json:"landing_page_enabled,omitempty"` } -func persistedStateFromRuntime(runtime *policy.Runtime) persistedAdminState { +func persistedStateFromRuntime(runtime *policy.Runtime, landingPageEnabled bool) persistedAdminState { approver := runtime.Approver() udpEnabled := runtime.IsUDPEnabled() udpMaxLeases := runtime.UDPMaxLeases() return persistedAdminState{ - ApprovalMode: string(approver.Mode()), - ApprovedLeases: approver.ApprovedLeases(), - DeniedLeases: approver.DeniedLeases(), - BannedLeases: runtime.BannedLeases(), - BannedIPs: runtime.IPFilter().BannedIPs(), - LeaseBPS: runtime.BPSManager().LeaseBPSLimits(), - UDPEnabled: &udpEnabled, - UDPMaxLeases: &udpMaxLeases, + ApprovalMode: string(approver.Mode()), + ApprovedLeases: approver.ApprovedLeases(), + DeniedLeases: approver.DeniedLeases(), + BannedLeases: runtime.BannedLeases(), + BannedIPs: runtime.IPFilter().BannedIPs(), + LeaseBPS: runtime.BPSManager().LeaseBPSLimits(), + UDPEnabled: &udpEnabled, + UDPMaxLeases: &udpMaxLeases, + LandingPageEnabled: &landingPageEnabled, + } +} + +func (s persistedAdminState) landingPageEnabled(defaultEnabled bool) bool { + if s.LandingPageEnabled == nil { + return defaultEnabled } + return *s.LandingPageEnabled } func (s persistedAdminState) apply(runtime *policy.Runtime) error { diff --git a/cmd/relay-server/frontend.go b/cmd/relay-server/frontend.go index 9ead92ed..f7bb4526 100644 --- a/cmd/relay-server/frontend.go +++ b/cmd/relay-server/frontend.go @@ -9,12 +9,15 @@ import ( "mime" "net/http" "path" + "strconv" "strings" "sync" + "sync/atomic" "time" "github.com/gosuda/portal/v2/portal" "github.com/gosuda/portal/v2/types" + "github.com/gosuda/portal/v2/utils" ) type readDirFileFS interface { @@ -33,9 +36,10 @@ type Frontend struct { cachedPortalHTML []byte cachedPortalHTMLOnce sync.Once + landingPageEnabled atomic.Bool } -func NewFrontend(server *portal.Server, adminSecret string, adminSettingsPath string) (*Frontend, error) { +func NewFrontend(server *portal.Server, adminSecret string, adminSettingsPath string, defaultLandingPageEnabled bool) (*Frontend, error) { if server == nil { return nil, errors.New("frontend requires portal server") } @@ -43,16 +47,19 @@ func NewFrontend(server *portal.Server, adminSecret string, adminSettingsPath st if runtime == nil { return nil, errors.New("frontend requires policy runtime") } - if err := loadAdminState(adminSettingsPath, runtime); err != nil { + state, err := loadAdminState(adminSettingsPath, runtime) + if err != nil { return nil, err } - return &Frontend{ + frontend := &Frontend{ distFS: embeddedDistFS, server: server, auth: newAdminAuth(adminSecret), adminSettingsPath: strings.TrimSpace(adminSettingsPath), - }, nil + } + frontend.setLandingPageEnabled(state.landingPageEnabled(defaultLandingPageEnabled)) + return frontend, nil } func (f *Frontend) Handler() *http.ServeMux { @@ -78,6 +85,7 @@ func (f *Frontend) Handler() *http.ServeMux { mux.HandleFunc(types.PathAdmin, f.serveAdmin) mux.HandleFunc(types.PathAdminPrefix, f.serveAdmin) + mux.HandleFunc(types.PathTunnelStatus, f.serveTunnelStatus) mux.HandleFunc(types.PathInstallShell, func(w http.ResponseWriter, r *http.Request) { serveInstallScript(w, r, f.server.PortalURL(), false) }) @@ -171,7 +179,7 @@ func (f *Frontend) servePortalHTMLWithSSR(w http.ResponseWriter) { htmlContent := string(f.cachedPortalHTML) htmlContent = f.injectServerData(htmlContent) - htmlContent = f.injectOGMetadata(htmlContent, "", "", "") + htmlContent = f.injectOGMetadata(htmlContent, "", "") w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-cache, must-revalidate") @@ -192,30 +200,60 @@ func (f *Frontend) injectServerData(htmlContent string) string { return strings.Replace(htmlContent, "", ssrScript+"\n", 1) } -func (f *Frontend) injectOGMetadata(htmlContent, title, description, imageURL string) string { +func (f *Frontend) serveTunnelStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed") + return + } + + hostname := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("hostname"))) + if hostname == "" { + utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "hostname is required") + return + } + + resp := types.TunnelStatusResponse{ + Hostname: hostname, + } + if snapshot, ok := f.server.LeaseSnapshotByHostname(hostname); ok { + resp.Hostname = snapshot.Hostname + resp.Registered = true + resp.ServiceAlive = snapshot.Ready > 0 + } + utils.WriteAPIData(w, http.StatusOK, resp) +} + +func (f *Frontend) injectOGMetadata(htmlContent, title, description string) string { if title == "" { title = "Portal Proxy Gateway" } if description == "" { description = "Transform your local services into web-accessible endpoints. Instant access from anywhere." } - if imageURL == "" { - base := strings.TrimSuffix(f.server.PortalURL(), "/") - if !strings.HasPrefix(base, "http") { - base = "https://" + base - } - imageURL = base + "/portal.jpg" - } replacer := strings.NewReplacer( "[%OG_TITLE%]", html.EscapeString(title), "[%OG_DESCRIPTION%]", html.EscapeString(description), - "[%OG_IMAGE_URL%]", html.EscapeString(imageURL), + "[%LANDING_PAGE_ENABLED%]", html.EscapeString(strconv.FormatBool(f.isLandingPageEnabled())), "[%RELEASE_VERSION%]", html.EscapeString(types.ReleaseVersion), ) return replacer.Replace(htmlContent) } +func (f *Frontend) isLandingPageEnabled() bool { + if f == nil { + return false + } + return f.landingPageEnabled.Load() +} + +func (f *Frontend) setLandingPageEnabled(enabled bool) { + if f == nil { + return + } + f.landingPageEnabled.Store(enabled) +} + func (f *Frontend) adminLeaseSnapshots() []types.Lease { snapshots := f.server.LeaseSnapshots() if len(snapshots) == 0 { @@ -303,6 +341,5 @@ func frontendRootAssetPaths() []string { "/apple-touch-icon.png", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png", - "/portal.jpg", } } diff --git a/cmd/relay-server/main.go b/cmd/relay-server/main.go index 950aed58..7090d662 100644 --- a/cmd/relay-server/main.go +++ b/cmd/relay-server/main.go @@ -35,6 +35,7 @@ type relayServerConfig struct { APIPort int SNIPort int UDPPortCount int + LandingPageEnabled bool Bootstraps string DiscoveryEnabled bool OwnerPrivateKey string @@ -60,6 +61,7 @@ func runServeCommand(args []string) error { utils.IntFlagEnv(fs, &cfg.APIPort, "api-port", 4017, utils.ParsePortNumber, "Admin/API server port", "API_PORT") utils.IntFlagEnv(fs, &cfg.SNIPort, "sni-port", 443, utils.ParsePortNumber, "TCP SNI router port number", "SNI_PORT") utils.IntFlagEnv(fs, &cfg.UDPPortCount, "udp-port-count", 0, utils.ParseNonNegativeInt, "Number of UDP ports to allocate for leases, starting at port 50000 (0=disabled)", "UDP_PORT_COUNT") + utils.BoolFlagEnv(fs, &cfg.LandingPageEnabled, "landing-page-enabled", false, "enable landing page by default when no admin setting has been saved yet", "LANDING_PAGE_ENABLED") utils.StringFlagEnv(fs, &cfg.Bootstraps, "bootstraps", "", "additional bootstrap relay API URLs used for discovery expansion", "BOOTSTRAPS") utils.BoolFlagEnv(fs, &cfg.DiscoveryEnabled, "discovery", false, "serve relay discovery endpoints and poll discovery peers", "DISCOVERY_ENABLED") utils.StringFlagEnv(fs, &cfg.OwnerPrivateKey, "owner-private-key", "", "relay owner private key used to derive a discovery address", "OWNER_PRIVATE_KEY") @@ -92,6 +94,7 @@ func runServeCommand(args []string) error { Str("release_version", types.ReleaseVersion). Str("portal_url", cfg.PortalURL). Str("admin_settings_path", cfg.AdminSettingsPath). + Bool("landing_page_enabled", cfg.LandingPageEnabled). Bool("discovery_enabled", cfg.DiscoveryEnabled). Bool("udp_enabled", cfg.UDPPortCount > 0). Msg("configured relay server") @@ -128,7 +131,7 @@ func runServer(ctx context.Context, cfg relayServerConfig) error { return fmt.Errorf("create relay server: %w", err) } - frontend, err := NewFrontend(server, cfg.AdminSecretKey, cfg.AdminSettingsPath) + frontend, err := NewFrontend(server, cfg.AdminSecretKey, cfg.AdminSettingsPath, cfg.LandingPageEnabled) if err != nil { return fmt.Errorf("create frontend: %w", err) } @@ -186,6 +189,7 @@ func printRootUsage(w io.Writer) { "relay-server serve", "relay-server --portal-url https://portal.example.com", "relay-server --discovery --udp-port-count 100", + "relay-server --landing-page-enabled", "relay-server help", }, ) diff --git a/cmd/relay-server/tunnel.go b/cmd/relay-server/tunnel.go index 3f8c9c09..100d960b 100644 --- a/cmd/relay-server/tunnel.go +++ b/cmd/relay-server/tunnel.go @@ -126,7 +126,7 @@ case ":$PATH:" in esac echo "Next step:" >&2 -echo " portal expose --relays $BASE_URL 3000" >&2 +echo " portal expose 3000 --relays $BASE_URL" >&2 ` const installPowerShellTemplate = `$ErrorActionPreference = "Stop" @@ -191,7 +191,7 @@ try { Write-Host "Installed portal to $InstallPath" Write-Host "Next step:" - Write-Host " portal expose --relays $BaseUrl 3000" + Write-Host " portal expose 3000 --relays $BaseUrl" } finally { [System.Net.ServicePointManager]::SecurityProtocol = $OriginalSecurityProtocol if ($WorkDir -and (Test-Path $WorkDir)) { diff --git a/docker-compose.yml b/docker-compose.yml index 405b6f22..c037c7f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,6 +24,7 @@ services: # Admin/auth configuration ADMIN_SECRET_KEY: ${ADMIN_SECRET_KEY:-} + LANDING_PAGE_ENABLED: ${LANDING_PAGE_ENABLED:-false} TRUST_PROXY_HEADERS: ${TRUST_PROXY_HEADERS:-false} TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-} diff --git a/extensions/vscode/CHANGELOG.md b/extensions/vscode/CHANGELOG.md index 7a776cf2..1339958b 100644 --- a/extensions/vscode/CHANGELOG.md +++ b/extensions/vscode/CHANGELOG.md @@ -9,7 +9,7 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how - Split quick start and advanced commands - Make `Portal: Start Tunnel` prompt only for the local host - Enforce `https://` relay URLs -- Allow empty service names so the CLI can auto-generate them +- Generate a stable default service name in the extension when the name is empty - Use the installed Portal binary path after installer execution ## [0.0.1] diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md index 1dcbb2af..2a5a28a2 100644 --- a/extensions/vscode/README.md +++ b/extensions/vscode/README.md @@ -22,7 +22,7 @@ Expose your local service to the internet via a [Portal](https://github.com/gosu |---|---|---| | `portal.relayUrls` | `[]` | Relay server URLs (`https://` only). If empty, the extension uses `https://raw.githubusercontent.com/gosuda/portal/main/registry.json`. | | `portal.defaultHost` | `"localhost:3000"` | Default local host:port shown by `Portal: Start Tunnel`. | -| `portal.defaultName` | `""` | Default tunnel service name suggestion. If empty, the advanced prompt starts blank. | +| `portal.defaultName` | `""` | Default tunnel service name suggestion. If empty, the extension omits `--name`. | Example `settings.json`: @@ -70,5 +70,4 @@ If you want Linux behavior from WSL, open the folder with `Remote - WSL` first s - Enforce `https://` relay URLs - Prompt only for the local host in `Portal: Start Tunnel` - Add `Portal: Start Tunnel (Advanced)` for host, name, relay, and thumbnail overrides -- Allow empty service names so the CLI can auto-generate them - Use the installed Portal binary path after installer execution diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 778982c1..46c05275 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -49,7 +49,7 @@ "portal.defaultName": { "type": "string", "default": "", - "description": "Default tunnel service name suggestion. Leave empty to start with a blank prompt." + "description": "Default tunnel service name suggestion. Leave empty to omit --name." } } } diff --git a/extensions/vscode/src/command.ts b/extensions/vscode/src/command.ts index 9bd1695f..14123e18 100644 --- a/extensions/vscode/src/command.ts +++ b/extensions/vscode/src/command.ts @@ -40,8 +40,9 @@ export function buildCommand(opts: TunnelCommandOptions, target = shellTargetFor const installPowerShellUrl = `${relayUrl}/install.ps1`; const exposeArgs: string[] = []; - if (name.trim()) { - exposeArgs.push(`--name ${formatToken(name.trim(), target)}`); + const trimmedName = name.trim(); + if (trimmedName) { + exposeArgs.push(`--name ${formatToken(trimmedName, target)}`); } if (relayList.trim()) { exposeArgs.push(`--relays ${formatToken(relayList, target)}`); @@ -50,7 +51,7 @@ export function buildCommand(opts: TunnelCommandOptions, target = shellTargetFor exposeArgs.push(`--thumbnail ${formatToken(thumbnail.trim(), target)}`); } - const exposeCommand = `expose ${[...exposeArgs, formatToken(host, target)].join(" ")}`; + const exposeCommand = `expose ${[formatToken(host, target), ...exposeArgs].join(" ")}`; if (target === "windows") { const commandLines = [`$ProgressPreference = 'SilentlyContinue'`]; diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index b24b8ce0..91a8759d 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -117,7 +117,7 @@ async function promptName(): Promise { const defaultName = config.get("defaultName") ?? ""; return vscode.window.showInputBox({ title: "Portal: Service Name", - prompt: "Optional public hostname prefix. Leave empty to let the CLI auto-generate one.", + prompt: "Optional public hostname prefix. Leave empty to omit --name.", value: defaultName, }); } diff --git a/extensions/vscode/src/test/extension.test.ts b/extensions/vscode/src/test/extension.test.ts index cb927d98..a7805e75 100644 --- a/extensions/vscode/src/test/extension.test.ts +++ b/extensions/vscode/src/test/extension.test.ts @@ -21,7 +21,7 @@ suite("Extension Test Suite", () => { assert.match(command, /curl -fsSL https:\/\/relay\.example\.com\/install\.sh \| bash/); assert.match(command, /PORTAL_BIN="\$\(command -v portal 2>\/dev\/null \|\| true\)"/); - assert.match(command, /"\$PORTAL_BIN" expose --relays https:\/\/relay\.example\.com localhost:3000/); + assert.match(command, /"\$PORTAL_BIN" expose localhost:3000 --relays https:\/\/relay\.example\.com/); assert.ok(!command.includes("--name")); }); @@ -37,6 +37,7 @@ suite("Extension Test Suite", () => { assert.ok(!command.includes("/install.sh")); assert.ok(!command.includes("--relays")); + assert.ok(!command.includes("--name")); assert.match(command, /portal CLI not found\. Install from a relay first or configure portal\.relayUrls\./); assert.match(command, /"\$PORTAL_BIN" expose localhost:3000/); }); @@ -53,6 +54,6 @@ suite("Extension Test Suite", () => { assert.match(command, /irm https:\/\/relay\.example\.com\/install\.ps1 \| iex/); assert.match(command, /\$PortalBin = Join-Path \$env:LOCALAPPDATA 'portal\\bin\\portal\.exe'/); - assert.match(command, /& \$PortalBin expose --name my-app --relays https:\/\/relay\.example\.com --thumbnail https:\/\/example\.com\/thumb\.png localhost:3000/); + assert.match(command, /& \$PortalBin expose localhost:3000 --name my-app --relays https:\/\/relay\.example\.com --thumbnail https:\/\/example\.com\/thumb\.png/); }); }); diff --git a/extensions/vscode/tsconfig.json b/extensions/vscode/tsconfig.json index cb353759..66d25405 100644 --- a/extensions/vscode/tsconfig.json +++ b/extensions/vscode/tsconfig.json @@ -6,7 +6,6 @@ "ES2022" ], "sourceMap": true, - "rootDir": "src", "strict": true, /* enable all strict type-checking options */ /* Additional Checks */ // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 2901a7b8..9567acec 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -23,7 +23,7 @@ High-signal constraints for the relay-server frontend. Only items expensive to r - Why: any tooling or script assuming `index.html` post-build will fail. 5. **HTML metadata placeholders must match between HTML and Go.** - `index.html` (renamed to `portal.html`) contains `[%OG_TITLE%]`, `[%OG_DESCRIPTION%]`, `[%OG_IMAGE_URL%]`, `[%RELEASE_VERSION%]`. Server-side substitution happens in `cmd/relay-server/frontend.go`. + `index.html` (renamed to `portal.html`) contains `[%OG_TITLE%]`, `[%OG_DESCRIPTION%]`, `[%LANDING_PAGE_ENABLED%]`, `[%SERVER_OWNER_ADDRESS%]`, `[%RELEASE_VERSION%]`. Server-side substitution happens in `cmd/relay-server/frontend.go`. - Why: renaming a placeholder in one place without the other leaves raw placeholder strings in production HTML. 6. **Admin state reads are aggregated through `/admin/snapshot`.** diff --git a/frontend/index.html b/frontend/index.html index 7cf4c150..aac83144 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,26 +1,43 @@ - + - + - - + - + Portal - Local to web. Instant access. + - +
diff --git a/frontend/public/portal.jpg b/frontend/public/portal.jpg deleted file mode 100644 index ca4909d6..00000000 Binary files a/frontend/public/portal.jpg and /dev/null differ diff --git a/frontend/src/components/FloatingActionBar.tsx b/frontend/src/components/FloatingActionBar.tsx index 4a6b8958..4e3038b4 100644 --- a/frontend/src/components/FloatingActionBar.tsx +++ b/frontend/src/components/FloatingActionBar.tsx @@ -78,7 +78,7 @@ export const FloatingActionBar = ({ value={selectedAction} onValueChange={(v) => setSelectedAction(v as BulkAction)} > - + diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 93e2cd8b..5b344d46 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,117 +1,112 @@ -import { useEffect, useState } from "react"; -import { LogOut, Moon, Sun } from "lucide-react"; +import { LogOut } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { ThemeToggleButton } from "@/components/ThemeToggleButton"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; -import { TunnelCommandModal } from "@/components/TunnelCommandModal"; import { getReleaseVersion } from "@/lib/releaseVersion"; -import clsx from "clsx"; interface HeaderProps { title?: string; isAdmin?: boolean; onLogout?: () => void; + showQuickStartLink?: boolean; } -export function Header({ title = "PORTAL", isAdmin, onLogout }: HeaderProps) { - const [theme, setTheme] = useState<"light" | "dark">("dark"); - const releaseVersion = getReleaseVersion(); - - useEffect(() => { - // Check localStorage for saved theme - const savedTheme = localStorage.getItem("theme") as "light" | "dark" | null; - if (savedTheme) { - setTheme(savedTheme); - document.documentElement.classList.remove("light", "dark"); - document.documentElement.classList.add(savedTheme); - document.body.classList.remove("light", "dark"); - document.body.classList.add(savedTheme); - } else { - // Default to dark mode - document.documentElement.classList.add("dark"); - document.body.classList.add("dark"); - } - }, []); +const repoURL = "https://github.com/gosuda/portal"; - const toggleTheme = () => { - const newTheme = theme === "dark" ? "light" : "dark"; - setTheme(newTheme); - localStorage.setItem("theme", newTheme); - document.documentElement.classList.remove("light", "dark"); - document.documentElement.classList.add(newTheme); - document.body.classList.remove("light", "dark"); - document.body.classList.add(newTheme); - }; +export function Header({ + title = "PORTAL", + isAdmin, + onLogout, + showQuickStartLink = true, +}: HeaderProps) { + const releaseVersion = getReleaseVersion(); return ( -
-
-
- - - -
-
-

- {title} -

- {releaseVersion && ( - - {releaseVersion} - - )} +
+
+
+
+
+ + + +
+ +
+

+ {title} +

+ {releaseVersion && ( + + {releaseVersion} + + )} +
+
+ {!isAdmin && ( + + )}
-
- - + {!isAdmin && ( + - - - - - - Add Your Server - - } - /> + + + + )} + + + {isAdmin && onLogout && ( @@ -120,10 +115,10 @@ export function Header({ title = "PORTAL", isAdmin, onLogout }: HeaderProps) { variant="outline" size="icon" onClick={onLogout} - className="cursor-pointer text-foreground hover:text-destructive" + className="h-12 w-12 cursor-pointer rounded-full border-border/70 bg-background/90 text-foreground shadow-sm transition-all hover:-translate-y-0.5 hover:border-destructive/40 hover:bg-background hover:text-destructive" aria-label="Logout" > - + diff --git a/frontend/src/components/LandingHero.tsx b/frontend/src/components/LandingHero.tsx new file mode 100644 index 00000000..3d2dc285 --- /dev/null +++ b/frontend/src/components/LandingHero.tsx @@ -0,0 +1,471 @@ +import { + startTransition, + useEffect, + useMemo, + useRef, + useState, + type PointerEvent as ReactPointerEvent, +} from "react"; +import { TunnelCommandForm } from "@/components/TunnelCommandForm"; + +const heroDifferentiatorCards = [ + { + key: "login", + title: "No Login", + description: "Run the command immediately without accounts or auth flows.", + }, + { + key: "billing", + title: "No Billing", + description: "No credit card, no plan gate, and no billing step before go-live.", + }, + { + key: "cloud", + title: "No Cloud SaaS", + description: "No dashboard, region picker, or managed cloud setup to get started.", + }, + { + key: "permissionless", + title: "Permissionless", + description: "Use the public registry or attach your own relay. No approval required.", + }, +] as const; + +const heroFeatures = [ + { + title: "No setup. No port forwarding.", + description: + "Works instantly, even behind NAT and firewalls.", + }, + { + title: "End-to-end TLS", + description: + "Traffic is routed via SNI with keyless TLS, while TLS still terminates on your app.", + }, + { + title: "Permissionless hosting", + description: + "Attach to arbitrary relays - no accounts, no approval, no trust required.", + }, + { + title: "UDP support", + description: + "Expose web apps and arbitrary protocols through the same tunnel.", + }, + { + title: "One command. Done.", + description: + "Install and expose your app in a single copy-paste.", + }, +] as const; + +export function LandingHero() { + const carouselCardCount = heroDifferentiatorCards.length; + const carouselLoopBoundaryIndex = carouselCardCount + 1; + const carouselTransitionDurationMs = 700; + const [reduceMotion, setReduceMotion] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [trackIndex, setTrackIndex] = useState(1); + const [transitionEnabled, setTransitionEnabled] = useState(true); + const [slideSize, setSlideSize] = useState(328); + const [dragOffset, setDragOffset] = useState(0); + const dragStartXRef = useRef(null); + const dragOffsetRef = useRef(0); + const pointerIdRef = useRef(null); + + const slideGap = 16; + const carouselSlides = useMemo( + () => [ + heroDifferentiatorCards[carouselCardCount - 1], + ...heroDifferentiatorCards, + heroDifferentiatorCards[0], + ], + [carouselCardCount] + ); + const renderedTrackIndex = Math.min( + Math.max(trackIndex, 0), + carouselSlides.length - 1 + ); + const trackTranslateX = `calc(50% - ${slideSize / 2}px - ${ + renderedTrackIndex * (slideSize + slideGap) + }px ${dragOffset >= 0 ? "+" : "-"} ${Math.abs(dragOffset)}px)`; + + useEffect(() => { + if (typeof window === "undefined" || typeof window.matchMedia !== "function") { + return; + } + + const media = window.matchMedia("(prefers-reduced-motion: reduce)"); + const syncReduceMotion = () => { + setReduceMotion(media.matches); + }; + + syncReduceMotion(); + + if (typeof media.addEventListener === "function") { + media.addEventListener("change", syncReduceMotion); + return () => media.removeEventListener("change", syncReduceMotion); + } + + media.addListener(syncReduceMotion); + return () => media.removeListener(syncReduceMotion); + }, []); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const updateSlideSize = () => { + if (window.innerWidth >= 1024) { + setSlideSize(560); + return; + } + + if (window.innerWidth >= 640) { + setSlideSize(472); + return; + } + + const maxMobileWidth = Math.min(window.innerWidth - 48, 368); + setSlideSize(Math.max(maxMobileWidth, 288)); + }; + + updateSlideSize(); + window.addEventListener("resize", updateSlideSize); + + return () => { + window.removeEventListener("resize", updateSlideSize); + }; + }, []); + + useEffect(() => { + if (reduceMotion || isDragging) { + return; + } + + const interval = window.setInterval(() => { + startTransition(() => { + setTransitionEnabled(true); + setTrackIndex((current) => + current >= carouselLoopBoundaryIndex + ? carouselLoopBoundaryIndex + : current + 1 + ); + }); + }, 2200); + + return () => { + window.clearInterval(interval); + }; + }, [carouselLoopBoundaryIndex, isDragging, reduceMotion]); + + useEffect(() => { + if (transitionEnabled) { + return; + } + + if (typeof window === "undefined") { + return; + } + + const frame = window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + setTransitionEnabled(true); + }); + }); + + return () => { + window.cancelAnimationFrame(frame); + }; + }, [transitionEnabled]); + + useEffect(() => { + if (isDragging) { + return; + } + + if (trackIndex !== 0 && trackIndex !== carouselLoopBoundaryIndex) { + return; + } + + const timer = window.setTimeout(() => { + setTransitionEnabled(false); + setTrackIndex(trackIndex === 0 ? carouselCardCount : 1); + }, carouselTransitionDurationMs); + + return () => { + window.clearTimeout(timer); + }; + }, [ + carouselCardCount, + carouselLoopBoundaryIndex, + carouselTransitionDurationMs, + isDragging, + trackIndex, + ]); + + const finishDrag = (shouldAdvance: boolean, direction: "next" | "prev" | null) => { + dragStartXRef.current = null; + dragOffsetRef.current = 0; + pointerIdRef.current = null; + setIsDragging(false); + setTransitionEnabled(true); + setDragOffset(0); + + if (!shouldAdvance || !direction) { + return; + } + + setTrackIndex((current) => { + if (direction === "next") { + return current >= carouselLoopBoundaryIndex + ? carouselLoopBoundaryIndex + : current + 1; + } + + return current <= 0 ? 0 : current - 1; + }); + }; + + const handlePointerDown = (event: ReactPointerEvent) => { + if (event.pointerType === "mouse" && event.button !== 0) { + return; + } + + dragStartXRef.current = event.clientX; + dragOffsetRef.current = 0; + pointerIdRef.current = event.pointerId; + setIsDragging(true); + setTransitionEnabled(false); + setDragOffset(0); + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const handlePointerMove = (event: ReactPointerEvent) => { + if ( + !isDragging || + dragStartXRef.current === null || + pointerIdRef.current !== event.pointerId + ) { + return; + } + + const nextOffset = event.clientX - dragStartXRef.current; + dragOffsetRef.current = nextOffset; + setDragOffset(nextOffset); + }; + + const handlePointerEnd = (event: ReactPointerEvent) => { + if (!isDragging || pointerIdRef.current !== event.pointerId) { + return; + } + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + + const threshold = Math.min(88, slideSize * 0.16); + const shouldAdvance = Math.abs(dragOffsetRef.current) > threshold; + const direction = + dragOffsetRef.current < 0 + ? "next" + : dragOffsetRef.current > 0 + ? "prev" + : null; + + finishDrag(shouldAdvance, direction); + }; + + return ( +
+
+ ); +} diff --git a/frontend/src/components/SearchBar.tsx b/frontend/src/components/SearchBar.tsx index a2ee0753..d8a64164 100644 --- a/frontend/src/components/SearchBar.tsx +++ b/frontend/src/components/SearchBar.tsx @@ -2,7 +2,7 @@ import { Search, Settings } from "lucide-react"; import { Input } from "@/components/ui/input"; import type { SortOption, StatusFilter } from "@/types/filters"; import { TagCombobox } from "@/components/TagCombobox"; -import { Dispatch, SetStateAction } from "react"; +import type { Dispatch, SetStateAction } from "react"; import { StatusSelect } from "@/components/select/StatusSelect"; import { SortbySelect } from "@/components/select/SortbySelect"; @@ -18,7 +18,7 @@ interface SearchBarProps { onAddTag: (tag: string) => void; onRemoveTag: (tag: string) => void; hideFiltersOnMobile?: boolean; - setShowFilterModal: Dispatch>; + setShowFilterModal?: Dispatch>; } export function SearchBar({ @@ -38,19 +38,18 @@ export function SearchBar({ return (
-
+
-
-
-
-

- {name} -

- - {description && ( -

- {description} -

- )} - - {tags && tags.length > 0 && ( - -
- {tags.map((tag, index) => ( - - #{tag} - - ))} -
- -
- )} +
+
+
+

+ {name} +

+ {description && ( +

+ {description} +

+ )} +
- {owner && ( - - by {owner} - - )} + {tags.length > 0 && ( +
+
+ {tags.map((tag, index) => ( + + #{tag} + + ))} +
+ )} - {!showAdminControls && thumbnail && ( -
-
- {`${name} -
-
+
+ {owner && by {owner}} + {dns && ( + + {dns} + )}
+
- {showAdminControls && leaseId && ( -
- {onBPSChange && ( -
- - BPS: {formatBPS(bps)} - - -
- )} + {showAdminControls && leaseId && ( +
+ {onBPSChange && ( +
+ + BPS: {formatBPS(bps)} + + +
+ )} - {isApproved && ip && ( -
- IP: {ip} - {isIPBanned && ( - (Banned) - )} -
- )} + {isApproved && ip && ( +
+ IP: {ip} + {isIPBanned && ( + + (Banned) + + )} +
+ )} - {!isApproved && !isDenied ? ( -
- - -
- ) : ( + {!isApproved && !isDenied ? ( +
+ +
+ ) : ( + - )} -
- )} -
+ + )} +
+ )}
); @@ -444,7 +433,7 @@ export function ServerCard({ {cardBody} @@ -466,19 +455,19 @@ export function ServerCard({ min="0" max={bpsSteps.length - 1} value={sliderIndex} - onChange={(e) => { - const idx = parseInt(e.target.value, 10); + onChange={(event) => { + const idx = parseInt(event.target.value, 10); handleSliderChange(idx); }} - className="w-full h-2 bg-secondary rounded-md appearance-none cursor-pointer" + className="h-2 w-full cursor-pointer appearance-none rounded-md bg-secondary" />
{bpsSteps.map((step, idx) => ( handleSliderChange(idx)} > @@ -487,17 +476,17 @@ export function ServerCard({ ))}
-
)} + {onLandingPageEnabledChange && ( +
+ Landing +
+ + +
+
+ )} {onUDPSettingsChange && udpSettings && ( <>
@@ -307,161 +448,378 @@ export function ServerListView({ ); + const renderServerCard = ({ + server, + adminServer, + }: { + server: ListServer; + adminServer?: AdminServer; + }) => { + const isSelected = adminServer + ? selectedLeaseIds.has(adminServer.peerId) + : false; + + return ( + + ); + }; + + const gridClasses = + "grid grid-cols-1 gap-6 p-4 min-[500px]:grid-cols-2 min-[500px]:p-6 md:grid-cols-3"; + const serverCards = serverRows.map(renderServerCard); + const serverGrid = + serverCards.length > 0 ? ( +
{serverCards}
+ ) : null; + const noMatchingServersMessage = ( +

No servers match these filters

+ ); + + const searchBar = ( + + ); + const publicFooter = ( + + ); + return (
-
-
-
-
-
-
- + {isAdmin ? ( + <> +
+
+
+
+
{searchBar}
-
- {isAdmin && ( -
+
{adminFilterControls}
- )} - {isAdmin && onApprovalModeChange && ( -
+ {onApprovalModeChange && ( +
+ + Approval + + +
+ )} +
+ {onLandingPageEnabledChange && ( +
- Approval + Landing - +
+ + +
)}
-
-
- {serverRows.length > 0 ? ( - serverRows.map(({ server, adminServer }) => { - const isSelected = adminServer - ? selectedLeaseIds.has(adminServer.peerId) - : false; - return ( - - ); - }) - ) : ( -
-

- No servers match these filters -

+
+
+ {serverGrid ?? ( +
+ {noMatchingServersMessage}
)} +
+
+ + ) : ( + <> +
+
+
-
-
-
-
+
+
+
+ {showLandingHero && ( +
+ +
+ )} - - - - Filters - -
-
- - Status - - +
+
+
+

+ Live apps +

+

+ Browse live apps +

+
+ +
+ + {serverRows.length > 0 ? ( +
+ {searchBar} +
+ {filteredServers.length.toLocaleString()} services visible +
+ {serverGrid} +
+ ) : ( +
+ {searchBar} +
+ 0 services visible +
+
+ {noMatchingServersMessage} +
+
+ )} +
+ +
+
+
+

+ Official registry +

+

+ Public relays +

+
+ + Open registry.json + +
+ +
+ {officialRegistryRelays === null ? ( +

+ Loading official registry... +

+ ) : officialRegistryAvailable ? ( +
+ {officialRegistryRelays.map((relay) => { + return ( +
+ + {relay.url} + +
+ {relay.status === "unreachable" ? ( + + Offline + + ) : relay.releaseVersion ? ( + + {relay.releaseVersion} + + ) : null} +
+
+ ); + })} +
+ ) : ( +

+ Registry entries are unavailable right now. +

+ )} +
+
+
- {isAdmin && onBanFilterChange && ( + {publicFooter} + + )} +
+ + {isAdmin && ( + + + + Filters + +
- Ban Status + Status - +
+ {onBanFilterChange && ( +
+ + Ban Status + + +
+ )} +
+ Sort + +
+
+ Tags +
- )} -
- Sort - -
-
- Tags -
-
-
-
+ + + )} {isAdmin && ( void; +} + +const ThemeContext = createContext(null); + +function isTheme(value: string | null): value is Theme { + return value === "light" || value === "dark"; +} + +function readStoredTheme(): Theme | null { + if (typeof window === "undefined") { + return null; + } + + try { + const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY); + return isTheme(storedTheme) ? storedTheme : null; + } catch { + return null; + } +} + +function getInitialTheme(): Theme { + if (typeof document === "undefined") { + return DEFAULT_THEME; + } + + const storedTheme = readStoredTheme(); + if (storedTheme) { + return storedTheme; + } + + return document.documentElement.classList.contains("dark") + ? "dark" + : DEFAULT_THEME; +} + +function applyTheme(theme: Theme) { + const root = document.documentElement; + root.classList.toggle("dark", theme === "dark"); + root.style.colorScheme = theme; +} + +export function ThemeProvider({ children }: PropsWithChildren) { + const [theme, setThemeState] = useState(getInitialTheme); + + useLayoutEffect(() => { + applyTheme(theme); + }, [theme]); + + const updateTheme = (nextTheme: Theme) => { + try { + window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme); + } catch { + // Ignore storage failures and still apply the theme locally. + } + + startTransition(() => { + setThemeState(nextTheme); + }); + }; + + const toggleTheme = () => { + updateTheme(theme === "dark" ? "light" : "dark"); + }; + + return ( + + {children} + + ); +} + +export function useTheme() { + const value = useContext(ThemeContext); + if (!value) { + throw new Error("useTheme must be used within ThemeProvider"); + } + return value; +} diff --git a/frontend/src/components/ThemeToggleButton.tsx b/frontend/src/components/ThemeToggleButton.tsx new file mode 100644 index 00000000..26b74691 --- /dev/null +++ b/frontend/src/components/ThemeToggleButton.tsx @@ -0,0 +1,34 @@ +import { Moon, Sun } from "lucide-react"; +import clsx from "clsx"; +import { Button } from "@/components/ui/button"; +import { useTheme } from "@/components/ThemeProvider"; + +interface ThemeToggleButtonProps { + className?: string; +} + +export function ThemeToggleButton({ className }: ThemeToggleButtonProps) { + const { theme, toggleTheme } = useTheme(); + const nextTheme = theme === "dark" ? "light" : "dark"; + + return ( + + ); +} diff --git a/frontend/src/components/TunnelCommandForm.tsx b/frontend/src/components/TunnelCommandForm.tsx new file mode 100644 index 00000000..90f016e9 --- /dev/null +++ b/frontend/src/components/TunnelCommandForm.tsx @@ -0,0 +1,766 @@ +import { + useEffect, + useId, + useMemo, + useState, + type KeyboardEvent, +} from "react"; +import { Check, Copy, RefreshCw, X } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { apiClient } from "@/lib/apiClient"; +import { API_PATHS } from "@/lib/apiPaths"; +import { cn } from "@/lib/utils"; +import { + buildTunnelPreviewURL, + buildTunnelStatusHostname, + normalizeAbsoluteHTTPURL, +} from "@/lib/tunnelCommand"; +import { + DEFAULT_HOST, + readCurrentOrigin, + useTunnelCommand, +} from "@/hooks/useTunnelCommand"; + +interface TunnelCommandFormProps { + className?: string; + theme?: "light" | "terminal"; + mode?: "full" | "hero"; +} + +type TunnelStatus = "waiting" | "registered" | "alive"; + +interface TunnelStatusResponse { + hostname: string; + registered: boolean; + service_alive: boolean; +} + +export function TunnelCommandForm({ + className, + theme = "light", + mode = "full", +}: TunnelCommandFormProps) { + if (mode === "hero") { + return ; + } + + return ; +} + +function HeroTunnelCommandForm({ + className, + theme, +}: Required> & + Pick) { + const isTerminal = theme === "terminal"; + const { + currentOrigin, + nameSeed, + target, + setTarget, + copied, + os, + setOs, + generatedName, + effectiveName, + installBlock, + runBlock, + handleCopy, + handleNameChange, + handleShuffleName, + } = useTunnelCommand(); + + const [tunnelStatus, setTunnelStatus] = useState("waiting"); + + const previewURL = useMemo( + () => buildTunnelPreviewURL(currentOrigin, effectiveName, target, nameSeed), + [currentOrigin, effectiveName, nameSeed, target] + ); + const statusHostname = useMemo( + () => + buildTunnelStatusHostname(currentOrigin, effectiveName, target, nameSeed), + [currentOrigin, effectiveName, nameSeed, target] + ); + + useEffect(() => { + if (statusHostname === "") { + return; + } + + let cancelled = false; + + const poll = async () => { + try { + const params = new URLSearchParams({ hostname: statusHostname }); + const statusResponse = await apiClient.get( + `${API_PATHS.tunnel.status}?${params.toString()}` + ); + if (cancelled) { + return; + } + + if (!statusResponse.registered) { + setTunnelStatus("waiting"); + return; + } + + setTunnelStatus(statusResponse.service_alive ? "alive" : "registered"); + } catch { + if (!cancelled) { + setTunnelStatus("waiting"); + } + } + }; + + setTunnelStatus("waiting"); + void poll(); + const interval = window.setInterval(() => { + void poll(); + }, 1500); + + return () => { + cancelled = true; + window.clearInterval(interval); + }; + }, [statusHostname]); + + const tunnelStatusTone = { + alive: isTerminal ? "bg-green-400" : "bg-green-600", + registered: isTerminal ? "bg-sky-400" : "bg-sky-600", + waiting: isTerminal ? "bg-slate-500" : "bg-slate-400", + }[tunnelStatus]; + const tunnelStatusHeadline = { + alive: "This URL is live now", + registered: "URL reserved", + waiting: "Waiting for Connection", + }[tunnelStatus]; + const isPreviewURLDisabled = tunnelStatus === "waiting"; + const heroSectionLabelClass = cn( + "text-[13px] font-semibold tracking-[0.04em] sm:text-sm", + isTerminal ? "text-slate-100" : "text-foreground/85" + ); + const heroURLClass = cn( + "block overflow-x-auto whitespace-nowrap font-mono text-[15px] font-medium sm:text-base", + isTerminal ? "text-sky-300" : "text-primary" + ); + const platformButtonGroupClass = cn( + "flex shrink-0 rounded-lg border p-0.5", + isTerminal + ? "border-white/6 bg-white/[0.035]" + : "border-border bg-border" + ); + const platformButtonClass = (selected: boolean) => + cn( + "min-w-[72px] whitespace-nowrap rounded-md px-2.5 py-1.5 text-[11px] font-semibold transition-colors", + selected + ? isTerminal + ? "bg-white/[0.08] text-slate-200" + : "bg-background text-foreground/85" + : isTerminal + ? "text-slate-500 hover:text-slate-300" + : "text-text-muted hover:text-foreground" + ); + const heroControlLabelClass = cn( + "shrink-0 text-[9px] font-semibold uppercase tracking-[0.16em]", + isTerminal ? "text-slate-500" : "text-text-muted" + ); + const heroControlInputClass = cn( + "h-auto border-0 bg-transparent px-0 py-0 text-[13px] shadow-none focus-visible:ring-0", + isTerminal + ? "text-slate-200 placeholder:text-slate-600" + : "text-foreground/85 placeholder:text-muted-foreground" + ); + const heroShuffleButtonClass = cn( + "inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-colors", + isTerminal + ? "text-slate-500 hover:bg-white/[0.06] hover:text-slate-200" + : "text-text-muted hover:bg-foreground/5 hover:text-foreground" + ); + return ( +
+
+
+

+ 1. Start your local app + + (e.g. + + localhost:3000 + + ) + +

+
+
+ +
+
+

2. Run this command

+
+ + +
+
+
+
+ Port + setTarget(event.target.value)} + placeholder={DEFAULT_HOST} + aria-label="Local port or address" + className={cn(heroControlInputClass, "w-[19 font-mono")} + /> +
+
+ Name + + +
+
+
+ +
+            {installBlock}
+            {runBlock}
+          
+
+
+ +
+

3. Open this public URL

+
+
+
+ {isPreviewURLDisabled ? ( + + {previewURL} + + ) : ( + + {previewURL} + + )} +
+
+
+ ); +} + +function FullTunnelCommandForm({ + className, + theme, +}: Required> & + Pick) { + const inputId = useId(); + const isTerminal = theme === "terminal"; + + const [relayUrls, setRelayUrls] = useState(() => [ + readCurrentOrigin(), + ]); + const [defaultRelays, setDefaultRelays] = useState(true); + const [urlInput, setUrlInput] = useState(""); + const [enableUDP, setEnableUDP] = useState(false); + const [udpPort, setUDPPort] = useState(""); + const [thumbnailURL, setThumbnailURL] = useState(""); + + const normalizedThumbnailURL = useMemo( + () => normalizeAbsoluteHTTPURL(thumbnailURL), + [thumbnailURL] + ); + const thumbnailError = useMemo(() => { + if (thumbnailURL.trim() === "" || normalizedThumbnailURL !== "") { + return ""; + } + + return "Thumbnail must be an absolute http:// or https:// URL."; + }, [normalizedThumbnailURL, thumbnailURL]); + + const { + target, + setTarget, + copied, + os, + setOs, + generatedName, + installBlock, + runBlock, + handleCopy, + handleNameChange, + handleShuffleName, + } = useTunnelCommand({ + relayUrls, + defaultRelays, + thumbnailURL: normalizedThumbnailURL, + enableUDP, + udpPort, + }); + + const addRelayURL = (url: string) => { + const trimmed = url.trim(); + if (!trimmed || relayUrls.includes(trimmed)) { + return; + } + + try { + new URL(trimmed); + setRelayUrls((prev) => [...prev, trimmed]); + setUrlInput(""); + } catch { + // Ignore invalid relay URL input. + } + }; + + const removeRelayURL = (url: string) => { + setRelayUrls((prev) => prev.filter((candidate) => candidate !== url)); + }; + + const handleURLKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter") { + event.preventDefault(); + addRelayURL(urlInput); + return; + } + + if (event.key === "Backspace" && urlInput === "" && relayUrls.length > 0) { + setRelayUrls((prev) => prev.slice(0, -1)); + } + }; + + const shuffleButtonClass = cn( + "inline-flex h-12 shrink-0 items-center justify-center rounded-lg border px-3 text-xs font-semibold transition-colors", + isTerminal + ? "border-white/10 bg-white/5 text-slate-400 hover:bg-white/10 hover:text-white" + : "border-border bg-white text-text-muted hover:text-foreground" + ); + + return ( +
+
+

+ Start your local app, then point Portal at it with a port like + + 3000 + + or an address like + + localhost:3000 + + . +

+ + setTarget(event.target.value)} + placeholder={DEFAULT_HOST} + className={cn( + "h-12 rounded-xl", + isTerminal + ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500" + : "border-border bg-white" + )} + /> +

+ Use a local port or address that is already running. +

+
+ +
+ +
+ + +
+
+ +
+ + +
+ +
+ +
+ {relayUrls.map((url) => ( + + {url} + + + ))} + + setUrlInput(event.target.value)} + onKeyDown={handleURLKeyDown} + placeholder="Add relay URL..." + className={cn( + "min-w-35 flex-1 bg-transparent text-sm outline-none", + isTerminal + ? "text-white placeholder:text-slate-500" + : "text-foreground placeholder:text-muted-foreground" + )} + /> +
+
+ +
+ + + + {enableUDP && ( +
+ setUDPPort(event.target.value)} + placeholder={target.trim() || DEFAULT_HOST} + className={cn( + "h-12 rounded-xl", + isTerminal + ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500" + : "border-border bg-white" + )} + /> +

+ Local UDP port to forward. Defaults to the same as Host. +

+
+ )} +
+ +
+ + setThumbnailURL(event.target.value)} + placeholder="https://cdn.example.com/thumb.png" + className={cn( + "h-12 rounded-xl", + isTerminal + ? "border-white/10 bg-white/5 text-white placeholder:text-slate-500" + : "border-border bg-white" + )} + /> + {normalizedThumbnailURL && ( +
+ Thumbnail preview +
+ )} + {thumbnailError &&

{thumbnailError}

} +
+ +
+
+ + +
+
+ +
+ +
+
+            {installBlock}
+            {runBlock}
+          
+ +
+
+
+ ); +} diff --git a/frontend/src/components/TunnelCommandModal.tsx b/frontend/src/components/TunnelCommandModal.tsx index a1d04cb2..349bfe9f 100644 --- a/frontend/src/components/TunnelCommandModal.tsx +++ b/frontend/src/components/TunnelCommandModal.tsx @@ -1,445 +1,41 @@ -import { useMemo, useState } from "react"; -import { Check, Copy, Terminal, X } from "lucide-react"; -import { cn } from "@/lib/utils"; import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { API_PATHS } from "@/lib/apiPaths"; - -interface TunnelCommandModalProps { - trigger?: React.ReactNode; -} - -export function TunnelCommandModal({ trigger }: TunnelCommandModalProps) { - const defaultHost = "3000"; - - // Get current host URL dynamically - const currentOrigin = useMemo(() => { - if (typeof window !== "undefined") { - return window.location.origin; - } - return "https://localhost:4017"; - }, []); - - const [open, setOpen] = useState(false); - const [target, setTarget] = useState(defaultHost); - const [name, setName] = useState(""); - const [relayUrls, setRelayUrls] = useState([currentOrigin]); - const [defaultRelays, setDefaultRelays] = useState(true); - const [urlInput, setUrlInput] = useState(""); - const [copied, setCopied] = useState(false); - const [os, setOs] = useState<"unix" | "windows">("unix"); - const [enableUDP, setEnableUDP] = useState(false); - const [udpPort, setUdpPort] = useState(""); - const [thumbnailURL, setThumbnailURL] = useState(""); - const normalizedThumbnailURL = useMemo( - () => normalizeAbsoluteHTTPURL(thumbnailURL), - [thumbnailURL] - ); - const thumbnailError = useMemo(() => { - if (thumbnailURL.trim() === "") { - return ""; - } - if (normalizedThumbnailURL !== "") { - return ""; - } - return "Thumbnail must be an absolute http:// or https:// URL."; - }, [thumbnailURL, normalizedThumbnailURL]); - - const addRelayUrl = (url: string) => { - const trimmed = url.trim(); - if (!trimmed || relayUrls.includes(trimmed)) return; - // Basic URL validation - try { - new URL(trimmed); - setRelayUrls([...relayUrls, trimmed]); - setUrlInput(""); - } catch { - // Invalid URL, ignore - } - }; - - const removeRelayUrl = (url: string) => { - setRelayUrls(relayUrls.filter((u) => u !== url)); - }; - - const handleUrlKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter") { - e.preventDefault(); - addRelayUrl(urlInput); - } else if ( - e.key === "Backspace" && - urlInput === "" && - relayUrls.length > 0 - ) { - // Remove last URL when backspace on empty input - setRelayUrls(relayUrls.slice(0, -1)); - } - }; - - // Generate the tunnel command - const command = useMemo(() => { - const targetVal = target.trim() === "" ? defaultHost : target.trim(); - const nameVal = name.trim(); - const relayUrlVal = - relayUrls.length > 0 ? relayUrls.join(",") : currentOrigin; - const installScriptURL = new URL( - API_PATHS.install.shell, - currentOrigin - ).toString(); - const installPowerShellURL = new URL( - API_PATHS.install.powershell, - currentOrigin - ).toString(); - const localhostRelay = isLocalRelayOrigin(currentOrigin); - - const exposeArgs: string[] = []; - - if (nameVal !== "") { - exposeArgs.push(`--name ${formatToken(nameVal, os)}`); - } - if (relayUrls.length > 0) { - exposeArgs.push(`--relays ${formatToken(relayUrlVal, os)}`); - } - if (!defaultRelays) { - exposeArgs.push("--default-relays=false"); - } - if (normalizedThumbnailURL) { - exposeArgs.push(`--thumbnail ${formatToken(normalizedThumbnailURL, os)}`); - } - if (enableUDP) { - exposeArgs.push("--udp"); - const udpAddrVal = udpPort.trim(); - if (udpAddrVal !== "") { - exposeArgs.push(`--udp-addr ${formatToken(udpAddrVal, os)}`); - } - } - - if (os === "windows") { - return [ - `$ProgressPreference = 'SilentlyContinue'`, - `irm ${formatToken(installPowerShellURL, os)} | iex`, - `portal expose ${[...exposeArgs, formatToken(targetVal, os)].join(" ")}`, - ].join("\n"); - } - - const curlFlags = localhostRelay ? "-ksSL" : "-sSL"; - return [ - `curl ${curlFlags} ${formatToken(installScriptURL, os)} | bash`, - `portal expose ${[...exposeArgs, formatToken(targetVal, os)].join(" ")}`, - ].join("\n"); - }, [ - currentOrigin, - defaultRelays, - enableUDP, - name, - normalizedThumbnailURL, - os, - relayUrls, - target, - udpPort, - ]); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(command); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error("Failed to copy:", err); - } - }; - - const handleOpenChange = (nextOpen: boolean) => { - setOpen(nextOpen); - if (!nextOpen) { - return; - } - setTarget(defaultHost); - setName(""); - setRelayUrls([currentOrigin]); - setDefaultRelays(true); - setUrlInput(""); - setCopied(false); - setOs("unix"); - setEnableUDP(false); - setUdpPort(""); - setThumbnailURL(""); - }; +import { TunnelCommandForm } from "@/components/TunnelCommandForm"; +export function TunnelCommandModal() { return ( - + - {trigger || ( - - )} + - - - - - Tunnel Setup Command - + + + Add Your Server + + Start your local app, for example on + + localhost:3000 + + , then copy and run the generated command. + -
- {/* Host Input */} -
- - setTarget(e.target.value)} - placeholder={defaultHost} - /> -
- - {/* Name Input */} -
- - setName(e.target.value)} - placeholder="auto-generated when empty" - /> -
- - {/* Relay URLs Input */} -
-
- - -
-
- {relayUrls.map((url) => ( - - {url} - - - ))} - setUrlInput(e.target.value)} - onKeyDown={handleUrlKeyDown} - placeholder="Add relay URL..." - className="min-w-[140px] flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" - /> -
-
- - {/* UDP Transport */} -
- - - {enableUDP && ( -
- setUdpPort(e.target.value)} - placeholder={target.trim() || defaultHost} - /> -

- Local UDP port to forward. Defaults to the same as Host. -

-
- )} -
- -
- - setThumbnailURL(e.target.value)} - placeholder="https://cdn.example.com/thumb.png" - /> - {normalizedThumbnailURL && ( -
- Thumbnail preview -
- )} - {thumbnailError && ( -

{thumbnailError}

- )} -
- - {/* OS Selection */} -
- -
- - -
-
- - {/* Generated Command */} -
- -
-
-                {command}
-              
- -
-

- After installation, run portal list to inspect the - configured public relays. -

-
+
+
); } - -function isLocalRelayOrigin(origin: string): boolean { - try { - const parsed = new URL(origin); - const host = parsed.hostname.trim().toLowerCase(); - return ( - host === "localhost" || - host === "127.0.0.1" || - host === "::1" || - host.endsWith(".localhost") - ); - } catch { - return false; - } -} - -function quoteShellValue(value: string): string { - return "'" + value.replace(/'/g, `'"'"'`) + "'"; -} - -function quotePowerShellValue(value: string): string { - return `'${value.replace(/'/g, "''")}'`; -} - -function formatToken(value: string, os: "unix" | "windows"): string { - if (/^[A-Za-z0-9:/.=_-]+$/.test(value)) { - return value; - } - return os === "windows" ? quotePowerShellValue(value) : quoteShellValue(value); -} - -function normalizeAbsoluteHTTPURL(raw: string): string { - const trimmed = raw.trim(); - if (trimmed === "") { - return ""; - } - - try { - const parsed = new URL(trimmed); - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - return ""; - } - return parsed.toString(); - } catch { - return ""; - } -} diff --git a/frontend/src/components/select/SortbySelect.tsx b/frontend/src/components/select/SortbySelect.tsx index edd9670f..37184800 100644 --- a/frontend/src/components/select/SortbySelect.tsx +++ b/frontend/src/components/select/SortbySelect.tsx @@ -24,7 +24,7 @@ export const SortbySelect = ({