Skip to content
JoleDev
ES
All projects
ssh.joledev.com — Terminal Portfolio
Web
Web Featured

ssh.joledev.com — Terminal Portfolio

A personal portfolio accessible via SSH with an interactive TUI, Unicode braille art, daily song from Spotify, and an integrated blog.

Go Bubbletea Lipgloss SSH Spotify API GitHub Actions Linux

The idea

It all started with an Instagram reel from @morilliu showcasing a portfolio accessible via SSH. I loved the concept, but instead of replicating a traditional portfolio, I wanted to design something more personal: a space of my own for my ideas, where I could share music, thoughts, and projects from the terminal.

The result is ssh.joledev.com — a custom SSH server that instead of giving you a shell, greets you with an interactive interface.

Architecture

The project has an intentionally simple architecture — a single Go binary that does everything:

ssh ssh.joledev.com
Wish SSH FrameworkPort 22 → iptables → 2223
Bubbletea TUIElm Architecture: Model → Update → View
Song
About
Blog

Each SSH connection creates an isolated TUI instance. No shell, no filesystem, no escalation — just the app.

The SSH server

The entry point is surprisingly simple. Wish handles the entire SSH handshake and spawns a new Bubbletea program for each session:

s, err := wish.NewServer(
    wish.WithAddress(fmt.Sprintf("%s:%s", host, port)),
    wish.WithHostKeyPath(keyPath),
    wish.WithMiddleware(
        bubbletea.Middleware(func(s ssh.Session) (tea.Model, []tea.ProgramOption) {
            m := tui.NewModel(songsPath, postsDir)
            return m, []tea.ProgramOption{tea.WithAltScreen()}
        }),
    ),
)

WithAltScreen() makes the terminal enter fullscreen mode — like when you open vim or htop. When the user exits, the terminal restores its previous state.

The TUI state

The Bubbletea model holds all session state. Each user gets their own instance:

type Model struct {
    Lang         Lang      // ES or EN
    Section      Section   // SectionSong, SectionAbout, SectionBlog
    Width        int       // User's terminal width
    Height       int       // User's terminal height
    Songs        []data.Song
    Posts        []data.Post
    TodaySong    data.Song
    CoverMono    string    // B&W braille album cover
    CoverColor   string    // ANSI color braille cover
    ColorMode    bool      // Toggle between B&W and color
    SpotifyCode  string    // Scannable barcode
    Frame        int       // Border animation (tick every 400ms)
}

Song of the day

I wanted every visitor to see the same song each day, but a different one every day. The solution: a SHA-256 hash of the current date as a deterministic seed:

func SongOfTheDay(songs []Song) Song {
    loc, _ := time.LoadLocation("America/Tijuana")
    now := time.Now().In(loc)
    dateStr := now.Format("2006-01-02")

    h := sha256.Sum256([]byte(dateStr))
    seed := int64(binary.BigEndian.Uint64(h[:8]))
    r := rand.New(rand.NewSource(seed))
    idx := r.Intn(len(songs))

    return songs[idx]
}

Same date, same hash, same seed, same song. No matter how many times you connect or from where — the result is identical for everyone.

The catalog: 578+ songs with no API key

To populate the catalog, I wrote a Python script that scrapes Spotify’s public embed page. No tokens, no OAuth, no app registration:

def scrape_spotify_playlist(playlist_url: str) -> list[dict]:
    playlist_id = re.search(r"playlist/([a-zA-Z0-9]+)", playlist_url).group(1)
    embed_url = f"https://open.spotify.com/embed/playlist/{playlist_id}"

    # Spotify embeds the data as JSON in the HTML
    html = urllib.request.urlopen(req, timeout=15).read().decode("utf-8")
    json_match = re.search(
        r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
        html
    )
    data = json.loads(json_match.group(1))
    track_list = data["props"]["pageProps"]["state"]["data"]["entity"]["trackList"]

The trick is that Spotify’s embed endpoint renders HTML with __NEXT_DATA__ containing all song info as JSON.


Rendering images in braille

This was the biggest challenge. Terminals don’t support images. The solution: Unicode braille art.

Why braille over ASCII?

Each braille character encodes a 2x4 pixel grid (8 dots), while traditional ASCII art only has 1 pixel per character. That’s 8x the resolution in the same space:

MethodResolutionCharacters
ASCII art1x1~70 gray levels
Block Unicode2x24 combinations
Braille Unicode2x4256 combinations

The complete pipeline

Spotify oEmbed API300x300 JPEG
Resize + GrayscaleArea-average downscaling + BT.601
Contrast EnhanceFactor 1.8x around the mean
Floyd-Steinberg DitheringError diffusion: 7/16, 3/16, 5/16, 1/16
Braille MappingEach 2x4 block → U+2800 + bit pattern

The braille mapping

Each Unicode braille character has a base codepoint of U+2800 plus bits for each active dot:

const brailleBase = 0x2800

var dotMap = [8][3]int{
    {0, 0, 0x01}, {1, 0, 0x08},  // Row 0: bit 0 and bit 3
    {0, 1, 0x02}, {1, 1, 0x10},  // Row 1: bit 1 and bit 4
    {0, 2, 0x04}, {1, 2, 0x20},  // Row 2: bit 2 and bit 5
    {0, 3, 0x40}, {1, 3, 0x80},  // Row 3: bit 6 and bit 7
}

// Result: rune(0x2800 + code)
// Example: all dots active = 0x2800 + 0xFF = ⣿

Floyd-Steinberg dithering

The classic error diffusion algorithm. For each pixel, it decides black or white and distributes the error to neighbors:

func dither(gray [][]float64, w, h int) {
    for y := 0; y < h; y++ {
        for x := 0; x < w; x++ {
            old := gray[y][x]
            newVal := 0.0
            if old >= 128 { newVal = 255 }
            gray[y][x] = newVal
            err := old - newVal

            if x+1 < w             { gray[y][x+1]   += err * 7/16 }
            if y+1 < h && x-1 >= 0 { gray[y+1][x-1] += err * 3/16 }
            if y+1 < h             { gray[y+1][x]   += err * 5/16 }
            if y+1 < h && x+1 < w  { gray[y+1][x+1] += err * 1/16 }
        }
    }
}

Color mode: ANSI true-color

Besides B&W, there’s a color mode using 24-bit ANSI escape sequences. For each braille character, active (foreground) and inactive (background) pixel colors are averaged:

// Foreground + Background in a single character
fmt.Sprintf("\033[38;2;%d;%d;%d;48;2;%d;%d;%dm%c\033[0m",
    fr, fg, fb,  // Text color (active dots)
    br, bgg, bb, // Background color (inactive dots)
    char,        // The braille character
)

The scannable Spotify Code

The Spotify Code is the barcode you scan with the Spotify app. Rendering it in braille needs geometric precision — if the edges aren’t sharp, the scan fails.

The solution: unsharp mask instead of dithering:

func spotifyCodeToBraille(img image.Image, width int) string {
    gray := resizeGray(img, pixW, pixH)
    enhanceContrast(gray, pixW, pixH, 1.5)
    sharpen(gray, pixW, pixH, 2.0) // Unsharp mask 2.0x

    // Hard threshold instead of Floyd-Steinberg
    for y := 0; y < pixH; y++ {
        for x := 0; x < pixW; x++ {
            if gray[y][x] < 128 {
                gray[y][x] = 0
            } else {
                gray[y][x] = 255
            }
        }
    }
    // ... braille mapping same as album covers
}

Controls

KeyAction
Navigate between sections
j kMove cursor in blog
EnterOpen selected post
EscClose post / Exit
CToggle color mode
LToggle language (ES / EN)
RRetry album cover loading
qQuit

Deploy and security

git pushmain
GitHub Actionsgo build
SCPUpload
systemdRestart

The service runs with systemd hardening:

[Service]
User=sshjoledev
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
RestrictSUIDSGID=true

The server doesn’t expose a shell, doesn’t accept files, doesn’t execute external code, and doesn’t persist user data. If there’s no attack surface, there’s nothing to defend.


My workflow

  1. Music: I add playlists on Spotify, run python scripts/scrape_playlist.py "url1" "url2" and the catalog updates with deduplication
  2. Blog: I create a .md in posts/es/ with title and date — the loader picks it up on the next deploy
  3. Deploy: git push and in ~60 seconds the change is live

The entire project is ~2,000 lines of Go and a ~15MB binary. No database, no cache, no CDN — just a binary and a text file with songs.