3 Commits

Author SHA1 Message Date
2b9bca65d5 refactor(interaction): separate view and update logic into modular files
Docker Build and Push / build-and-push-branches (push) Has been skipped
Docker Build and Push / build-and-push-tags (push) Successful in 11m44s
- Extract slug editing logic to slug.go (slugView/slugUpdate)
- Extract commands menu logic to commands.go (commandsView/commandsUpdate)
- Extract coming soon modal to coming_soon.go (comingSoonView/comingSoonUpdate)
- Extract main dashboard logic to dashboard.go (dashboardView/dashboardUpdate)
- Create model.go for shared model struct and helper functions
- Replace math/rand with crypto/rand for random subdomain generation
- Remove legacy TLS cipher suite configuration
2026-01-17 17:33:10 +07:00
6587dc0f39 refactor(interaction): separate view and update logic into modular files
- Extract slug editing logic to slug.go (slugView/slugUpdate)
- Extract commands menu logic to commands.go (commandsView/commandsUpdate)
- Extract coming soon modal to coming_soon.go (comingSoonView/comingSoonUpdate)
- Extract main dashboard logic to dashboard.go (dashboardView/dashboardUpdate)
- Create model.go for shared model struct and helper functions
- Replace math/rand with crypto/rand for random subdomain generation
- Remove legacy TLS cipher suite configuration
2026-01-17 17:30:21 +07:00
1a04af8873 Merge branch 'main' into staging
Docker Build and Push / build-and-push-branches (push) Successful in 11m35s
Docker Build and Push / build-and-push-tags (push) Has been skipped
2026-01-16 16:28:39 +07:00
9 changed files with 702 additions and 623 deletions
+13 -13
View File
@@ -1,18 +1,18 @@
package random
import (
mathrand "math/rand"
"strings"
"time"
)
import "crypto/rand"
func GenerateRandomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyz"
seededRand := mathrand.New(mathrand.NewSource(time.Now().UnixNano() + int64(mathrand.Intn(9999))))
var result strings.Builder
for i := 0; i < length; i++ {
randomIndex := seededRand.Intn(len(charset))
result.WriteString(string(charset[randomIndex]))
func GenerateRandomString(length int) (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyz0123456789"
b := make([]byte, length)
if _, err := rand.Read(b); err != nil {
return "", err
}
return result.String()
for i := range b {
b[i] = charset[int(b[i])%len(charset)]
}
return string(b), nil
}
+4 -10
View File
@@ -301,22 +301,16 @@ func (tm *tlsManager) initCertMagic() error {
func (tm *tlsManager) getTLSConfig() *tls.Config {
return &tls.Config{
GetCertificate: tm.getCertificate,
MinVersion: tls.VersionTLS13,
MaxVersion: tls.VersionTLS13,
SessionTicketsDisabled: false,
CipherSuites: []uint16{
tls.TLS_AES_128_GCM_SHA256,
tls.TLS_CHACHA20_POLY1305_SHA256,
},
MinVersion: tls.VersionTLS13,
MaxVersion: tls.VersionTLS13,
CurvePreferences: []tls.CurveID{
tls.X25519,
},
ClientAuth: tls.NoClientCert,
NextProtos: nil,
SessionTicketsDisabled: false,
ClientAuth: tls.NoClientCert,
}
}
+83
View File
@@ -0,0 +1,83 @@
package interaction
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
func (m *model) comingSoonUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.showingComingSoon = false
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
}
func (m *model) comingSoonView() string {
isCompact := shouldUseCompactLayout(m.width, 60)
var boxPadding int
var boxMargin int
if isCompact {
boxPadding = 1
boxMargin = 1
} else {
boxPadding = 3
boxMargin = 2
}
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
PaddingTop(1).
PaddingBottom(1)
messageBoxWidth := getResponsiveWidth(m.width, 10, 30, 60)
messageBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#1A1A2E")).
Bold(true).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(boxMargin).
Width(messageBoxWidth).
Align(lipgloss.Center)
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Italic(true).
MarginTop(1)
var b strings.Builder
b.WriteString("\n\n")
var title string
if shouldUseCompactLayout(m.width, 40) {
title = "Coming Soon"
} else {
title = "⏳ Coming Soon"
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
var message string
if shouldUseCompactLayout(m.width, 50) {
message = "Coming soon!\nStay tuned."
} else {
message = "🚀 This feature is coming very soon!\n Stay tuned for updates."
}
b.WriteString(messageBoxStyle.Render(message))
b.WriteString("\n\n")
var helpText string
if shouldUseCompactLayout(m.width, 60) {
helpText = "Press any key..."
} else {
helpText = "This message will disappear in 5 seconds or press any key..."
}
b.WriteString(helpStyle.Render(helpText))
return b.String()
}
+83
View File
@@ -0,0 +1,83 @@
package interaction
import (
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
func (m *model) commandsUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch {
case key.Matches(msg, m.keymap.quit):
m.showingCommands = false
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
case msg.String() == "enter":
selectedItem := m.commandList.SelectedItem()
if selectedItem != nil {
item := selectedItem.(commandItem)
if item.name == "slug" {
m.showingCommands = false
m.editingSlug = true
m.slugInput.SetValue(m.interaction.slug.String())
m.slugInput.Focus()
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
} else if item.name == "tunnel-type" {
m.showingCommands = false
m.showingComingSoon = true
return m, tea.Batch(tickCmd(5*time.Second), tea.ClearScreen, textinput.Blink)
}
m.showingCommands = false
return m, nil
}
case msg.String() == "esc":
m.showingCommands = false
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
}
m.commandList, cmd = m.commandList.Update(msg)
return m, cmd
}
func (m *model) commandsView() string {
isCompact := shouldUseCompactLayout(m.width, 60)
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
PaddingTop(1).
PaddingBottom(1)
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Italic(true).
MarginTop(1)
var b strings.Builder
b.WriteString("\n")
var title string
if shouldUseCompactLayout(m.width, 40) {
title = "Commands"
} else {
title = "⚡ Commands"
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
b.WriteString(m.commandList.View())
b.WriteString("\n")
var helpText string
if isCompact {
helpText = "↑/↓ Nav • Enter Select • Esc Cancel"
} else {
helpText = "↑/↓ Navigate • Enter Select • Esc Cancel"
}
b.WriteString(helpStyle.Render(helpText))
return b.String()
}
+186
View File
@@ -0,0 +1,186 @@
package interaction
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
func (m *model) dashboardUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch {
case key.Matches(msg, m.keymap.quit):
m.quitting = true
return m, tea.Batch(tea.ClearScreen, textinput.Blink, tea.Quit)
case key.Matches(msg, m.keymap.command):
m.showingCommands = true
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
}
return m, nil
}
func (m *model) dashboardView() string {
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
PaddingTop(1)
subtitleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
Italic(true)
urlStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Underline(true).
Italic(true)
urlBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#04B575")).
Bold(true).
Italic(true)
keyHintStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Bold(true)
var b strings.Builder
isCompact := shouldUseCompactLayout(m.width, 85)
var asciiArtMargin int
if isCompact {
asciiArtMargin = 0
} else {
asciiArtMargin = 1
}
asciiArtStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
MarginBottom(asciiArtMargin)
var asciiArt string
if shouldUseCompactLayout(m.width, 50) {
asciiArt = "TUNNEL PLS"
} else if isCompact {
asciiArt = `
▀█▀ █ █ █▄ █ █▄ █ ██▀ █ ▄▀▀ █ ▄▀▀
█ ▀▄█ █ ▀█ █ ▀█ █▄▄ █▄▄ ▄█▀ █▄▄ ▄█▀`
} else {
asciiArt = `
████████╗██╗ ██╗███╗ ██╗███╗ ██╗███████╗██╗ ██████╗ ██╗ ███████╗
╚══██╔══╝██║ ██║████╗ ██║████╗ ██║██╔════╝██║ ██╔══██╗██║ ██╔════╝
██║ ██║ ██║██╔██╗ ██║██╔██╗ ██║█████╗ ██║ ██████╔╝██║ ███████╗
██║ ██║ ██║██║╚██╗██║██║╚██╗██║██╔══╝ ██║ ██╔═══╝ ██║ ╚════██║
██║ ╚██████╔╝██║ ╚████║██║ ╚████║███████╗███████╗ ██║ ███████╗███████║
╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝╚══════╝╚══════╝ ╚═╝ ╚══════╝╚══════╝`
}
b.WriteString(asciiArtStyle.Render(asciiArt))
b.WriteString("\n")
if !shouldUseCompactLayout(m.width, 60) {
b.WriteString(subtitleStyle.Render("Secure tunnel service by Bagas • "))
b.WriteString(urlStyle.Render("https://fossy.my.id"))
b.WriteString("\n\n")
} else {
b.WriteString("\n")
}
boxMaxWidth := getResponsiveWidth(m.width, 10, 40, 80)
var boxPadding int
var boxMargin int
if isCompact {
boxPadding = 1
boxMargin = 1
} else {
boxPadding = 2
boxMargin = 2
}
responsiveInfoBox := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(boxMargin).
Width(boxMaxWidth)
authenticatedUser := m.interaction.lifecycle.User()
userInfoStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
Bold(true)
sectionHeaderStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
Bold(true)
addressStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA"))
var infoContent string
if shouldUseCompactLayout(m.width, 70) {
infoContent = fmt.Sprintf("👤 %s\n\n%s\n%s",
userInfoStyle.Render(authenticatedUser),
sectionHeaderStyle.Render("🌐 FORWARDING ADDRESS:"),
addressStyle.Render(fmt.Sprintf(" %s", urlBoxStyle.Render(m.getTunnelURL()))))
} else {
infoContent = fmt.Sprintf("👤 Authenticated as: %s\n\n%s\n %s",
userInfoStyle.Render(authenticatedUser),
sectionHeaderStyle.Render("🌐 FORWARDING ADDRESS:"),
addressStyle.Render(urlBoxStyle.Render(m.getTunnelURL())))
}
b.WriteString(responsiveInfoBox.Render(infoContent))
b.WriteString("\n")
var quickActionsTitle string
if shouldUseCompactLayout(m.width, 50) {
quickActionsTitle = "Actions"
} else if isCompact {
quickActionsTitle = "Quick Actions"
} else {
quickActionsTitle = "✨ Quick Actions"
}
b.WriteString(titleStyle.Render(quickActionsTitle))
b.WriteString("\n")
var featureMargin int
if isCompact {
featureMargin = 1
} else {
featureMargin = 2
}
compactFeatureStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
MarginLeft(featureMargin)
var commandsText string
var quitText string
if shouldUseCompactLayout(m.width, 60) {
commandsText = fmt.Sprintf(" %s Commands", keyHintStyle.Render("[C]"))
quitText = fmt.Sprintf(" %s Quit", keyHintStyle.Render("[Q]"))
} else {
commandsText = fmt.Sprintf(" %s Open commands menu", keyHintStyle.Render("[C]"))
quitText = fmt.Sprintf(" %s Quit application", keyHintStyle.Render("[Q]"))
}
b.WriteString(compactFeatureStyle.Render(commandsText))
b.WriteString("\n")
b.WriteString(compactFeatureStyle.Render(quitText))
if !shouldUseCompactLayout(m.width, 70) {
b.WriteString("\n\n")
footerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Italic(true)
b.WriteString(footerStyle.Render("Press 'C' to customize your tunnel settings"))
}
return b.String()
}
+8 -598
View File
@@ -2,12 +2,8 @@ package interaction
import (
"context"
"fmt"
"log"
"strings"
"time"
"tunnel_pls/internal/config"
"tunnel_pls/internal/random"
"tunnel_pls/session/slug"
"tunnel_pls/types"
@@ -84,45 +80,6 @@ func (i *interaction) SetWH(w, h int) {
}
}
type commandItem struct {
name string
desc string
}
type model struct {
domain string
protocol string
tunnelType types.TunnelType
port uint16
keymap keymap
help help.Model
quitting bool
showingCommands bool
editingSlug bool
showingComingSoon bool
commandList list.Model
slugInput textinput.Model
slugError string
interaction *interaction
width int
height int
}
func (m *model) getTunnelURL() string {
if m.tunnelType == types.HTTP {
return buildURL(m.protocol, m.interaction.slug.String(), m.domain)
}
return fmt.Sprintf("tcp://%s:%d", m.domain, m.port)
}
type keymap struct {
quit key.Binding
command key.Binding
random key.Binding
}
type tickMsg time.Time
func New(slug slug.Slug, forwarder Forwarder) Interaction {
ctx, cancel := context.WithCancel(context.Background())
return &interaction{
@@ -159,47 +116,7 @@ func (i *interaction) Stop() {
}
}
func getResponsiveWidth(screenWidth, padding, minWidth, maxWidth int) int {
width := screenWidth - padding
if width > maxWidth {
width = maxWidth
}
if width < minWidth {
width = minWidth
}
return width
}
func shouldUseCompactLayout(width int, threshold int) bool {
return width < threshold
}
func truncateString(s string, maxLength int) string {
if len(s) <= maxLength {
return s
}
if maxLength < 4 {
return s[:maxLength]
}
return s[:maxLength-3] + "..."
}
func (i commandItem) FilterValue() string { return i.name }
func (i commandItem) Title() string { return i.name }
func (i commandItem) Description() string { return i.desc }
func tickCmd(d time.Duration) tea.Cmd {
return tea.Tick(d, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func (m *model) Init() tea.Cmd {
return tea.Batch(textinput.Blink, tea.WindowSize())
}
func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
switch msg := msg.(type) {
case tickMsg:
@@ -225,93 +142,18 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case tea.KeyMsg:
if m.showingComingSoon {
m.showingComingSoon = false
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
return m.comingSoonUpdate(msg)
}
if m.editingSlug {
if m.tunnelType != types.HTTP {
m.editingSlug = false
m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
}
switch msg.String() {
case "esc":
m.editingSlug = false
m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
case "enter":
inputValue := m.slugInput.Value()
if err := m.interaction.sessionRegistry.Update(m.interaction.lifecycle.User(), types.SessionKey{
Id: m.interaction.slug.String(),
Type: types.HTTP,
}, types.SessionKey{
Id: inputValue,
Type: types.HTTP,
}); err != nil {
m.slugError = err.Error()
return m, nil
}
m.editingSlug = false
m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
case "ctrl+c":
m.editingSlug = false
m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
default:
if key.Matches(msg, m.keymap.random) {
newSubdomain := generateRandomSubdomain()
m.slugInput.SetValue(newSubdomain)
m.slugError = ""
m.slugInput, cmd = m.slugInput.Update(msg)
return m, cmd
}
m.slugError = ""
m.slugInput, cmd = m.slugInput.Update(msg)
return m, cmd
}
return m.slugUpdate(msg)
}
if m.showingCommands {
switch {
case key.Matches(msg, m.keymap.quit):
m.showingCommands = false
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
case msg.String() == "enter":
selectedItem := m.commandList.SelectedItem()
if selectedItem != nil {
item := selectedItem.(commandItem)
if item.name == "slug" {
m.showingCommands = false
m.editingSlug = true
m.slugInput.SetValue(m.interaction.slug.String())
m.slugInput.Focus()
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
} else if item.name == "tunnel-type" {
m.showingCommands = false
m.showingComingSoon = true
return m, tea.Batch(tickCmd(5*time.Second), tea.ClearScreen, textinput.Blink)
}
m.showingCommands = false
return m, nil
}
case msg.String() == "esc":
m.showingCommands = false
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
}
m.commandList, cmd = m.commandList.Update(msg)
return m, cmd
return m.commandsUpdate(msg)
}
switch {
case key.Matches(msg, m.keymap.quit):
m.quitting = true
return m, tea.Batch(tea.ClearScreen, textinput.Blink, tea.Quit)
case key.Matches(msg, m.keymap.command):
m.showingCommands = true
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
}
return m.dashboardUpdate(msg)
}
return m, nil
@@ -323,448 +165,24 @@ func (i *interaction) Redraw() {
}
}
func (m *model) helpView() string {
return "\n" + m.help.ShortHelpView([]key.Binding{
m.keymap.command,
m.keymap.quit,
})
}
func (m *model) View() string {
if m.quitting {
return ""
}
if m.showingComingSoon {
isCompact := shouldUseCompactLayout(m.width, 60)
var boxPadding int
var boxMargin int
if isCompact {
boxPadding = 1
boxMargin = 1
} else {
boxPadding = 3
boxMargin = 2
}
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
PaddingTop(1).
PaddingBottom(1)
messageBoxWidth := getResponsiveWidth(m.width, 10, 30, 60)
messageBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#1A1A2E")).
Bold(true).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(boxMargin).
Width(messageBoxWidth).
Align(lipgloss.Center)
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Italic(true).
MarginTop(1)
var b strings.Builder
b.WriteString("\n\n")
var title string
if shouldUseCompactLayout(m.width, 40) {
title = "Coming Soon"
} else {
title = "⏳ Coming Soon"
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
var message string
if shouldUseCompactLayout(m.width, 50) {
message = "Coming soon!\nStay tuned."
} else {
message = "🚀 This feature is coming very soon!\n Stay tuned for updates."
}
b.WriteString(messageBoxStyle.Render(message))
b.WriteString("\n\n")
var helpText string
if shouldUseCompactLayout(m.width, 60) {
helpText = "Press any key..."
} else {
helpText = "This message will disappear in 5 seconds or press any key..."
}
b.WriteString(helpStyle.Render(helpText))
return b.String()
return m.comingSoonView()
}
if m.editingSlug {
isCompact := shouldUseCompactLayout(m.width, 70)
isVeryCompact := shouldUseCompactLayout(m.width, 50)
var boxPadding int
var boxMargin int
if isVeryCompact {
boxPadding = 1
boxMargin = 1
} else if isCompact {
boxPadding = 1
boxMargin = 1
} else {
boxPadding = 2
boxMargin = 2
}
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
PaddingTop(1).
PaddingBottom(1)
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
MarginTop(1)
inputBoxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(boxMargin)
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Italic(true).
MarginTop(1)
errorBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000")).
Background(lipgloss.Color("#3D0000")).
Bold(true).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#FF0000")).
Padding(0, boxPadding).
MarginTop(1).
MarginBottom(1)
rulesBoxWidth := getResponsiveWidth(m.width, 10, 30, 60)
rulesBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(0, boxPadding).
MarginTop(1).
MarginBottom(1).
Width(rulesBoxWidth)
var b strings.Builder
var title string
if isVeryCompact {
title = "Edit Subdomain"
} else {
title = "🔧 Edit Subdomain"
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
if m.tunnelType != types.HTTP {
warningBoxWidth := getResponsiveWidth(m.width, 10, 30, 60)
warningBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFA500")).
Background(lipgloss.Color("#3D2000")).
Bold(true).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#FFA500")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(boxMargin).
Width(warningBoxWidth)
var warningText string
if isVeryCompact {
warningText = "⚠️ TCP tunnels don't support custom subdomains."
} else {
warningText = "⚠️ TCP tunnels cannot have custom subdomains. Only HTTP/HTTPS tunnels support subdomain customization."
}
b.WriteString(warningBoxStyle.Render(warningText))
b.WriteString("\n\n")
var helpText string
if isVeryCompact {
helpText = "Press any key to go back"
} else {
helpText = "Press Enter or Esc to go back"
}
b.WriteString(helpStyle.Render(helpText))
return b.String()
}
var rulesContent string
if isVeryCompact {
rulesContent = "Rules:\n3-20 chars\na-z, 0-9, -\nNo leading/trailing -"
} else if isCompact {
rulesContent = "📋 Rules:\n • 3-20 chars\n • a-z, 0-9, -\n • No leading/trailing -"
} else {
rulesContent = "📋 Rules: \n\t• 3-20 chars \n\t• a-z, 0-9, - \n\t• No leading/trailing -"
}
b.WriteString(rulesBoxStyle.Render(rulesContent))
b.WriteString("\n")
var instruction string
if isVeryCompact {
instruction = "Custom subdomain:"
} else {
instruction = "Enter your custom subdomain:"
}
b.WriteString(instructionStyle.Render(instruction))
b.WriteString("\n")
if m.slugError != "" {
errorInputBoxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#FF0000")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(1)
b.WriteString(errorInputBoxStyle.Render(m.slugInput.View()))
b.WriteString("\n")
b.WriteString(errorBoxStyle.Render("❌ " + m.slugError))
b.WriteString("\n")
} else {
b.WriteString(inputBoxStyle.Render(m.slugInput.View()))
b.WriteString("\n")
}
previewURL := buildURL(m.protocol, m.slugInput.Value(), m.domain)
previewWidth := getResponsiveWidth(m.width, 10, 30, 80)
if len(previewURL) > previewWidth-10 {
previewURL = truncateString(previewURL, previewWidth-10)
}
previewStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#04B575")).
Italic(true).
Width(previewWidth)
b.WriteString(previewStyle.Render(fmt.Sprintf("Preview: %s", previewURL)))
b.WriteString("\n")
var helpText string
if isVeryCompact {
helpText = "Enter: save • CTRL+R: random • Esc: cancel"
} else {
helpText = "Press Enter to save • CTRL+R for random • Esc to cancel"
}
b.WriteString(helpStyle.Render(helpText))
return b.String()
return m.slugView()
}
if m.showingCommands {
isCompact := shouldUseCompactLayout(m.width, 60)
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
PaddingTop(1).
PaddingBottom(1)
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Italic(true).
MarginTop(1)
var b strings.Builder
b.WriteString("\n")
var title string
if shouldUseCompactLayout(m.width, 40) {
title = "Commands"
} else {
title = "⚡ Commands"
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
b.WriteString(m.commandList.View())
b.WriteString("\n")
var helpText string
if isCompact {
helpText = "↑/↓ Nav • Enter Select • Esc Cancel"
} else {
helpText = "↑/↓ Navigate • Enter Select • Esc Cancel"
}
b.WriteString(helpStyle.Render(helpText))
return b.String()
return m.commandsView()
}
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
PaddingTop(1)
subtitleStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
Italic(true)
urlStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Underline(true).
Italic(true)
urlBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#04B575")).
Bold(true).
Italic(true)
keyHintStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#7D56F4")).
Bold(true)
var b strings.Builder
isCompact := shouldUseCompactLayout(m.width, 85)
var asciiArtMargin int
if isCompact {
asciiArtMargin = 0
} else {
asciiArtMargin = 1
}
asciiArtStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
MarginBottom(asciiArtMargin)
var asciiArt string
if shouldUseCompactLayout(m.width, 50) {
asciiArt = "TUNNEL PLS"
} else if isCompact {
asciiArt = `
▀█▀ █ █ █▄ █ █▄ █ ██▀ █ ▄▀▀ █ ▄▀▀
█ ▀▄█ █ ▀█ █ ▀█ █▄▄ █▄▄ ▄█▀ █▄▄ ▄█▀`
} else {
asciiArt = `
████████╗██╗ ██╗███╗ ██╗███╗ ██╗███████╗██╗ ██████╗ ██╗ ███████╗
╚══██╔══╝██║ ██║████╗ ██║████╗ ██║██╔════╝██║ ██╔══██╗██║ ██╔════╝
██║ ██║ ██║██╔██╗ ██║██╔██╗ ██║█████╗ ██║ ██████╔╝██║ ███████╗
██║ ██║ ██║██║╚██╗██║██║╚██╗██║██╔══╝ ██║ ██╔═══╝ ██║ ╚════██║
██║ ╚██████╔╝██║ ╚████║██║ ╚████║███████╗███████╗ ██║ ███████╗███████║
╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═══╝╚══════╝╚══════╝ ╚═╝ ╚══════╝╚══════╝`
}
b.WriteString(asciiArtStyle.Render(asciiArt))
b.WriteString("\n")
if !shouldUseCompactLayout(m.width, 60) {
b.WriteString(subtitleStyle.Render("Secure tunnel service by Bagas • "))
b.WriteString(urlStyle.Render("https://fossy.my.id"))
b.WriteString("\n\n")
} else {
b.WriteString("\n")
}
boxMaxWidth := getResponsiveWidth(m.width, 10, 40, 80)
var boxPadding int
var boxMargin int
if isCompact {
boxPadding = 1
boxMargin = 1
} else {
boxPadding = 2
boxMargin = 2
}
responsiveInfoBox := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(boxMargin).
Width(boxMaxWidth)
authenticatedUser := m.interaction.lifecycle.User()
userInfoStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
Bold(true)
sectionHeaderStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#888888")).
Bold(true)
addressStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA"))
var infoContent string
if shouldUseCompactLayout(m.width, 70) {
infoContent = fmt.Sprintf("👤 %s\n\n%s\n%s",
userInfoStyle.Render(authenticatedUser),
sectionHeaderStyle.Render("🌐 FORWARDING ADDRESS:"),
addressStyle.Render(fmt.Sprintf(" %s", urlBoxStyle.Render(m.getTunnelURL()))))
} else {
infoContent = fmt.Sprintf("👤 Authenticated as: %s\n\n%s\n %s",
userInfoStyle.Render(authenticatedUser),
sectionHeaderStyle.Render("🌐 FORWARDING ADDRESS:"),
addressStyle.Render(urlBoxStyle.Render(m.getTunnelURL())))
}
b.WriteString(responsiveInfoBox.Render(infoContent))
b.WriteString("\n")
var quickActionsTitle string
if shouldUseCompactLayout(m.width, 50) {
quickActionsTitle = "Actions"
} else if isCompact {
quickActionsTitle = "Quick Actions"
} else {
quickActionsTitle = "✨ Quick Actions"
}
b.WriteString(titleStyle.Render(quickActionsTitle))
b.WriteString("\n")
var featureMargin int
if isCompact {
featureMargin = 1
} else {
featureMargin = 2
}
compactFeatureStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
MarginLeft(featureMargin)
var commandsText string
var quitText string
if shouldUseCompactLayout(m.width, 60) {
commandsText = fmt.Sprintf(" %s Commands", keyHintStyle.Render("[C]"))
quitText = fmt.Sprintf(" %s Quit", keyHintStyle.Render("[Q]"))
} else {
commandsText = fmt.Sprintf(" %s Open commands menu", keyHintStyle.Render("[C]"))
quitText = fmt.Sprintf(" %s Quit application", keyHintStyle.Render("[Q]"))
}
b.WriteString(compactFeatureStyle.Render(commandsText))
b.WriteString("\n")
b.WriteString(compactFeatureStyle.Render(quitText))
if !shouldUseCompactLayout(m.width, 70) {
b.WriteString("\n\n")
footerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Italic(true)
b.WriteString(footerStyle.Render("Press 'C' to customize your tunnel settings"))
}
return b.String()
return m.dashboardView()
}
func (i *interaction) Start() {
@@ -848,11 +266,3 @@ func (i *interaction) Start() {
log.Printf("Cannot close session: %s \n", err)
}
}
func buildURL(protocol, subdomain, domain string) string {
return fmt.Sprintf("%s://%s.%s", protocol, subdomain, domain)
}
func generateRandomSubdomain() string {
return random.GenerateRandomString(20)
}
+95
View File
@@ -0,0 +1,95 @@
package interaction
import (
"fmt"
"time"
"tunnel_pls/types"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/list"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type commandItem struct {
name string
desc string
}
func (i commandItem) FilterValue() string { return i.name }
func (i commandItem) Title() string { return i.name }
func (i commandItem) Description() string { return i.desc }
type model struct {
domain string
protocol string
tunnelType types.TunnelType
port uint16
keymap keymap
help help.Model
quitting bool
showingCommands bool
editingSlug bool
showingComingSoon bool
commandList list.Model
slugInput textinput.Model
slugError string
interaction *interaction
width int
height int
}
func (m *model) getTunnelURL() string {
if m.tunnelType == types.HTTP {
return buildURL(m.protocol, m.interaction.slug.String(), m.domain)
}
return fmt.Sprintf("tcp://%s:%d", m.domain, m.port)
}
type keymap struct {
quit key.Binding
command key.Binding
random key.Binding
}
type tickMsg time.Time
func (m *model) Init() tea.Cmd {
return tea.Batch(textinput.Blink, tea.WindowSize())
}
func getResponsiveWidth(screenWidth, padding, minWidth, maxWidth int) int {
width := screenWidth - padding
if width > maxWidth {
width = maxWidth
}
if width < minWidth {
width = minWidth
}
return width
}
func shouldUseCompactLayout(width int, threshold int) bool {
return width < threshold
}
func truncateString(s string, maxLength int) string {
if len(s) <= maxLength {
return s
}
if maxLength < 4 {
return s[:maxLength]
}
return s[:maxLength-3] + "..."
}
func tickCmd(d time.Duration) tea.Cmd {
return tea.Tick(d, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
func buildURL(protocol, subdomain, domain string) string {
return fmt.Sprintf("%s://%s.%s", protocol, subdomain, domain)
}
+224
View File
@@ -0,0 +1,224 @@
package interaction
import (
"fmt"
"strings"
"tunnel_pls/internal/random"
"tunnel_pls/types"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
func (m *model) slugUpdate(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
var cmd tea.Cmd
if m.tunnelType != types.HTTP {
m.editingSlug = false
m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
}
switch msg.String() {
case "esc":
m.editingSlug = false
m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
case "enter":
inputValue := m.slugInput.Value()
if err := m.interaction.sessionRegistry.Update(m.interaction.lifecycle.User(), types.SessionKey{
Id: m.interaction.slug.String(),
Type: types.HTTP,
}, types.SessionKey{
Id: inputValue,
Type: types.HTTP,
}); err != nil {
m.slugError = err.Error()
return m, nil
}
m.editingSlug = false
m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
case "ctrl+c":
m.editingSlug = false
m.slugError = ""
return m, tea.Batch(tea.ClearScreen, textinput.Blink)
default:
if key.Matches(msg, m.keymap.random) {
newSubdomain, err := random.GenerateRandomString(20)
if err != nil {
return m, cmd
}
m.slugInput.SetValue(newSubdomain)
m.slugError = ""
m.slugInput, cmd = m.slugInput.Update(msg)
}
m.slugError = ""
m.slugInput, cmd = m.slugInput.Update(msg)
return m, cmd
}
}
func (m *model) slugView() string {
isCompact := shouldUseCompactLayout(m.width, 70)
isVeryCompact := shouldUseCompactLayout(m.width, 50)
var boxPadding int
var boxMargin int
if isVeryCompact {
boxPadding = 1
boxMargin = 1
} else if isCompact {
boxPadding = 1
boxMargin = 1
} else {
boxPadding = 2
boxMargin = 2
}
titleStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#7D56F4")).
PaddingTop(1).
PaddingBottom(1)
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
MarginTop(1)
inputBoxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(boxMargin)
helpStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666")).
Italic(true).
MarginTop(1)
errorBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000")).
Background(lipgloss.Color("#3D0000")).
Bold(true).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#FF0000")).
Padding(0, boxPadding).
MarginTop(1).
MarginBottom(1)
rulesBoxWidth := getResponsiveWidth(m.width, 10, 30, 60)
rulesBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA")).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#7D56F4")).
Padding(0, boxPadding).
MarginTop(1).
MarginBottom(1).
Width(rulesBoxWidth)
var b strings.Builder
var title string
if isVeryCompact {
title = "Edit Subdomain"
} else {
title = "🔧 Edit Subdomain"
}
b.WriteString(titleStyle.Render(title))
b.WriteString("\n\n")
if m.tunnelType != types.HTTP {
warningBoxWidth := getResponsiveWidth(m.width, 10, 30, 60)
warningBoxStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFA500")).
Background(lipgloss.Color("#3D2000")).
Bold(true).
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#FFA500")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(boxMargin).
Width(warningBoxWidth)
var warningText string
if isVeryCompact {
warningText = "⚠️ TCP tunnels don't support custom subdomains."
} else {
warningText = "⚠️ TCP tunnels cannot have custom subdomains. Only HTTP/HTTPS tunnels support subdomain customization."
}
b.WriteString(warningBoxStyle.Render(warningText))
b.WriteString("\n\n")
var helpText string
if isVeryCompact {
helpText = "Press any key to go back"
} else {
helpText = "Press Enter or Esc to go back"
}
b.WriteString(helpStyle.Render(helpText))
return b.String()
}
var rulesContent string
if isVeryCompact {
rulesContent = "Rules:\n3-20 chars\na-z, 0-9, -\nNo leading/trailing -"
} else if isCompact {
rulesContent = "📋 Rules:\n • 3-20 chars\n • a-z, 0-9, -\n • No leading/trailing -"
} else {
rulesContent = "📋 Rules: \n\t• 3-20 chars \n\t• a-z, 0-9, - \n\t• No leading/trailing -"
}
b.WriteString(rulesBoxStyle.Render(rulesContent))
b.WriteString("\n")
var instruction string
if isVeryCompact {
instruction = "Custom subdomain:"
} else {
instruction = "Enter your custom subdomain:"
}
b.WriteString(instructionStyle.Render(instruction))
b.WriteString("\n")
if m.slugError != "" {
errorInputBoxStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#FF0000")).
Padding(1, boxPadding).
MarginTop(boxMargin).
MarginBottom(1)
b.WriteString(errorInputBoxStyle.Render(m.slugInput.View()))
b.WriteString("\n")
b.WriteString(errorBoxStyle.Render("❌ " + m.slugError))
b.WriteString("\n")
} else {
b.WriteString(inputBoxStyle.Render(m.slugInput.View()))
b.WriteString("\n")
}
previewURL := buildURL(m.protocol, m.slugInput.Value(), m.domain)
previewWidth := getResponsiveWidth(m.width, 10, 30, 80)
if len(previewURL) > previewWidth-10 {
previewURL = truncateString(previewURL, previewWidth-10)
}
previewStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#04B575")).
Italic(true).
Width(previewWidth)
b.WriteString(previewStyle.Render(fmt.Sprintf("Preview: %s", previewURL)))
b.WriteString("\n")
var helpText string
if isVeryCompact {
helpText = "Enter: save • CTRL+R: random • Esc: cancel"
} else {
helpText = "Press Enter to save • CTRL+R for random • Esc to cancel"
}
b.WriteString(helpStyle.Render(helpText))
return b.String()
}
+6 -2
View File
@@ -291,7 +291,11 @@ func (s *session) HandleHTTPForward(req *ssh.Request, portToBind uint16) {
}
}
randomString := random.GenerateRandomString(20)
randomString, err := random.GenerateRandomString(20)
if err != nil {
fail(fmt.Sprintf("Failed to create slug: %s", err), nil)
return
}
key := types.SessionKey{Id: randomString, Type: types.HTTP}
if !s.registry.Register(key, s) {
fail(fmt.Sprintf("Failed to register client with slug: %s", randomString), nil)
@@ -299,7 +303,7 @@ func (s *session) HandleHTTPForward(req *ssh.Request, portToBind uint16) {
}
buf := new(bytes.Buffer)
err := binary.Write(buf, binary.BigEndian, uint32(portToBind))
err = binary.Write(buf, binary.BigEndian, uint32(portToBind))
if err != nil {
fail(fmt.Sprintf("Failed to write port to buffer: %v", err), &key)
return