Compare commits

...

3 Commits

Author SHA1 Message Date
antanst
139b2e7733 . 2025-10-15 09:03:50 +03:00
antanst
971f1e5206 Add a gmi test file. 2025-06-03 12:40:36 +03:00
antanst
39651a6021 Fix rendering bugs, update name 2025-06-03 12:38:53 +03:00
11 changed files with 290 additions and 32 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/.idea
/.vscode
/dist

44
AGENTS.md Normal file
View File

@@ -0,0 +1,44 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Essential Commands
Build, test, and develop:
```shell
make # Full workflow: format, lint, test, clean, build
make test # Run tests only
make build # Build binary to ./dist/gmi2html
make fmt # Format code with gofumpt and gci
make lint # Run linter after formatting
make lintfix # Run linter with auto-fix
```
Running the tool:
```shell
./dist/gmi2html <input.gmi >output.html
./dist/gmi2html --no-container <input.gmi >content.html
./dist/gmi2html --replace-gmi-ext <input.gmi >output.html
```
## Architecture
This is a Go library and CLI tool that converts Gemini text format to HTML.
**Core Components:**
- `gmi2html.go`: Main conversion logic with `Gmi2html()` function and `convertGeminiContent()` for parsing
- `templates.go`: HTML templates for each Gemini element type (headings, links, lists, etc.)
- `cmd/gmi2html/main.go`: CLI entry point that reads from stdin and writes to stdout
- `assets/main.html`: Embedded HTML template with CSS for the full page container
**Key Architecture Patterns:**
- Uses Go's `html/template` package for safe HTML generation with automatic escaping
- Embeds the main HTML template using `//go:embed` directive
- Line-by-line parser that switches between normal and preformatted modes
- Two output modes: full HTML document or content-only for embedding
- Optional `.gmi` to `.html` link conversion for static site generation
**Gemini Format Support:**
- Headings (#, ##, ###), links (=>), lists (*), quotes (>), preformatted blocks (```)
- Proper handling of preformatted content with mode switching
- URL parsing and validation for links

98
CLAUDE.md Normal file
View File

@@ -0,0 +1,98 @@
# CLAUDE.md
This file provides guidance to AI Agents such as Claude Code or ChatGPT Codex when working with code in this repository.
## General guidelines
Use idiomatic Go as possible. Prefer simple code than complex.
## Project Overview
Gemserve is a simple Gemini protocol server written in Go that serves static files over TLS-encrypted connections. The Gemini protocol is a lightweight, privacy-focused alternative to HTTP designed for serving text-based content.
### Development Commands
```bash
# Build, test, and format everything
make
# Run tests only
make test
# Build binaries to ./dist/ (gemserve, gemget, gembench)
make build
# Format code with gofumpt and gci
make fmt
# Run golangci-lint
make lint
# Run linter with auto-fix
make lintfix
# Clean build artifacts
make clean
# Run the server (after building)
./dist/gemserve
# Generate TLS certificates for development
certs/generate.sh
```
### Architecture
Core Components
- **cmd/gemserve/gemserve.go**: Entry point with TLS server setup, signal handling, and graceful shutdown
- **cmd/gemget/**: Gemini protocol client for fetching content
- **cmd/gembench/**: Benchmarking tool for Gemini servers
- **server/**: Request processing, file serving, and Gemini protocol response handling
- **gemini/**: Gemini protocol implementation (URL parsing, status codes, path normalization)
- **config/**: CLI-based configuration system
- **lib/logging/**: Structured logging package with context-aware loggers
- **lib/apperrors/**: Application error handling (fatal vs non-fatal errors)
- **uid/**: Connection ID generation for logging (uses external vendor package)
Key Patterns
- **Security First**: All file operations use `filepath.IsLocal()` and path cleaning to prevent directory traversal
- **Error Handling**: Uses structured errors via `lib/apperrors` package distinguishing fatal from non-fatal errors
- **Logging**: Structured logging with configurable levels via internal logging package
- **Testing**: Table-driven tests with parallel execution, heavy focus on security edge cases
Request Flow
1. TLS connection established on port 1965
2. Read up to 1KB request (Gemini spec limit)
3. Parse and normalize Gemini URL
4. Validate path security (prevent traversal)
5. Serve file or directory index with appropriate MIME type
6. Send response with proper Gemini status codes
Configuration
Server configured via CLI flags:
- `--listen`: Server address (default: localhost:1965)
- `--root-path`: Directory to serve files from
- `--dir-indexing`: Enable directory browsing (default: false)
- `--log-level`: Logging verbosity (debug, info, warn, error; default: info)
- `--response-timeout`: Response timeout in seconds (default: 30)
- `--tls-cert`: TLS certificate file path (default: certs/server.crt)
- `--tls-key`: TLS key file path (default: certs/server.key)
- `--max-response-size`: Maximum response size in bytes (default: 5242880)
Testing Strategy
- **server/server_test.go**: Path security and file serving tests
- **gemini/url_test.go**: URL parsing and normalization tests
- Focus on security edge cases (Unicode, traversal attempts, malformed URLs)
- Use parallel test execution for performance
Security Considerations
- All connections require TLS certificates (stored in certs/)
- Path traversal protection is critical - test thoroughly when modifying file serving logic
- Request size limited to 1KB per Gemini specification
- Input validation on all URLs and paths

View File

@@ -1,4 +1,4 @@
SHELL := /bin/env oksh SHELL := /bin/sh
export PATH := $(PATH) export PATH := $(PATH)
all: fmt lintfix tidy test clean build all: fmt lintfix tidy test clean build
@@ -8,7 +8,6 @@ debug:
@echo "GOPATH: $(shell go env GOPATH)" @echo "GOPATH: $(shell go env GOPATH)"
@which go @which go
@which gofumpt @which gofumpt
@which gci
@which golangci-lint @which golangci-lint
clean: clean:
@@ -24,7 +23,6 @@ tidy:
# Format code # Format code
fmt: fmt:
gofumpt -l -w . gofumpt -l -w .
gci write .
# Run linter # Run linter
lint: fmt lint: fmt

View File

@@ -6,23 +6,36 @@
<title>{{.Title}}</title> <title>{{.Title}}</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,200..900;1,200..900&family=Source+Serif+4:ital,opsz,wght@0,8..60,200..900;1,8..60,200..900&display=swap" rel="stylesheet">
<style> <style>
:root { :root {
--text-color: #eee; --text-color: #333;
--bg-color: #292929; --bg-color: #ffffff;
--link-color: #4a9eff; --link-color: #0066cc;
--link-hover: #77b6ff; --link-hover: #0052a3;
--quote-bg: #333; --quote-bg: #f5f5f5;
--quote-border: #444; --quote-border: #ddd;
--pre-bg: #2a2a2a; --pre-bg: #f8f8f8;
--pre-border: #3a3a3a; --pre-border: #e1e1e1;
}
@media (prefers-color-scheme: dark) {
:root {
--text-color: #e0e0e0;
--bg-color: #1a1a1a;
--link-color: #66a3ff;
--link-hover: #87b5ff;
--quote-bg: #2a2a2a;
--quote-border: #444;
--pre-bg: #252525;
--pre-border: #3a3a3a;
}
} }
body { body {
font-family: "Source Code Pro", monospace; font-family: serif;
font-weight: 300; /* font-weight: 300; */
font-size: 14px; font-size: 16px;
color: var(--text-color); color: var(--text-color);
background-color: var(--bg-color); background-color: var(--bg-color);
max-width: 34rem; max-width: 34rem;

View File

@@ -6,16 +6,16 @@ import (
"io" "io"
"os" "os"
"github.com/antanst/gmi2html/pkg/gmi2html" "git.antanst.com/antanst/gmi2html"
) )
func main() { func main() {
noContainer := flag.Bool("no-container", false, "Don't output container HTML") noContainer := flag.Bool("no-container", false, "Don't output container HTML")
replaceGmiExt := flag.Bool("replace-gmi-ext", false, "Replace .gmi extension with .html in links") replaceGmiExt := flag.Bool("replace-gmi-ext", false, "In links, replace original .gmi extension with .html")
flag.Parse() flag.Parse()
err := runApp(*noContainer, *replaceGmiExt) err := runApp(*noContainer, *replaceGmiExt)
if err != nil { if err != nil {
fmt.Println(err) fmt.Fprintln(os.Stderr, err)
os.Exit(1) os.Exit(1)
} }
} }

View File

@@ -48,10 +48,7 @@ func convertGeminiContent(text string, replaceGmiExt bool) string {
normalMode := true normalMode := true
for _, line := range lines { for _, line := range lines {
switch { if strings.HasPrefix(line, "```") {
case strings.HasPrefix(line, "=>"):
handleLinkLine(&buffer, line, replaceGmiExt)
case strings.HasPrefix(line, "```"):
if normalMode { if normalMode {
err := preformattedTmplStart.Execute(&buffer, line) err := preformattedTmplStart.Execute(&buffer, line)
if err != nil { if err != nil {
@@ -65,7 +62,21 @@ func convertGeminiContent(text string, replaceGmiExt bool) string {
} }
} }
normalMode = !normalMode normalMode = !normalMode
// Don't output the ``` line // Don't output the ``` line itself
continue
}
if !normalMode {
// Inside preformatted block - output line directly with HTML escaping
buffer.WriteString(template.HTMLEscapeString(line))
buffer.WriteString("\n")
continue
}
// Normal mode - process gemini markup
switch {
case strings.HasPrefix(line, "=>"):
handleLinkLine(&buffer, line, replaceGmiExt)
case strings.HasPrefix(line, "###"): case strings.HasPrefix(line, "###"):
content := strings.TrimSpace(strings.TrimPrefix(line, "###")) content := strings.TrimSpace(strings.TrimPrefix(line, "###"))
err := h3Tmpl.Execute(&buffer, content) err := h3Tmpl.Execute(&buffer, content)
@@ -97,14 +108,9 @@ func convertGeminiContent(text string, replaceGmiExt bool) string {
return "" return ""
} }
default: default:
if normalMode { err := textLineTmpl.Execute(&buffer, line)
err := textLineTmpl.Execute(&buffer, line) if err != nil {
if err != nil { return ""
return ""
}
} else {
buffer.WriteString(line)
buffer.WriteString("\n")
} }
} }
} }

2
go.mod
View File

@@ -1,3 +1,3 @@
module github.com/antanst/gmi2html module git.antanst.com/antanst/gmi2html
go 1.21 go 1.21

96
test_files/1.gmi Normal file
View File

@@ -0,0 +1,96 @@
# pollux.casa 🟊.⌂
```ascii-art pollux.casa
_______ _______ ___ ___ __ __ __ __ ___
| | | | | | | | | | |_| | / \
| _ | _ | | | | | | | | | / \
| |_| | | | | | | | | |_| | | / _ _ \
| ___| |_| | |___| |___| || | ___ ||_| |_||
| | | | | | | _ | | | | _ |
|___| |_______|_______|_______|_______|__| |__| |___| |__|_|__|
```
=> /index.fr.gmi 🏳️ Version française
## A gemini capsules free hosting
You want to create a gemini capsule, but do not want to manage a server?
You're welcome !
This service is proposed and managed by Adële
=> gemini://adele.pollux.casa/ Adële's capsule
If your "pseudo" is still available, your capsule will be created and reachable on this address:
```
gemini://"pseudo".pollux.casa/
```
Also accessible in http/https if you want to share with persons that do not know gemini yet:
```
https://"pseudo".pollux.casa/
```
Go on board, it will 🚀
=> requestaccount.gmi How to request an account
=> content.gmi How to edit your gemini capsule content
## News
=> /gemlog/ news gemlog
## Why pollux.casa domain name?
* 🟊 Pollux, because it is one of main stars in Gemini constellation
* ⌂ Casa, because it will become your home in this constellation
## Hardware & software
pollux.casa is hosted on a small Intel NUC machine
* Processor Celeron N2830 @2.16GHz (64bits)
* 4 Gb RAM
* Disk SATA 1 Tb (5400rpm)
* ADSL connection 6Mb/s up and 23Mb/s down
* OS Archlinux
* Gemserv Gemini server & Lighttpd + Ergol-http
=> img/nuc.jpg pollux.casa server !
## Charter and policy
This service is offered freely without service guarantee. However, this service will be supplied in accordance to the Charter of CHATONS.
CHATONS is a collective of independant, transparent, open, neutral and ethical hosters providing FLOSS-based online services.
=> https://www.chatons.org/en/manifeste Manifesto of CHATONS
=> https://www.chatons.org/en/charte Charter of CHATONS
pollux.casa is not an official entity so, it cannot officially be member of CHATONS.
I'm French and lives in France, so French laws will be applied to this service.
## Free to leave pollux.casa
If you decide to self-host your capsule, it is possible download your capsule (of course, you have uploaded it) and to redirect "pseudo.pollux.casa" to your own domain and machine. I'll be happy to have helped you to start gemini experience.
## Contact
You can join me on the Fediverse:
=> https://social.pollux.casa/@adele @adele@social.pollux.casa
Or by email:
=> /adele_at_pollux.casa.pub.asc get openpgp pub key of adele(at)pollux.casa
``` asci-art astronaut
_____
./ \. _
/ .-""-\ _/ \
.-| /:. | | |
| \ |:. /.-'-./
| .-'-;:__.' =/
.'= *=| _.='
/ _. | 🚀 ;
;-.-'| \ |
/ | \ _\ _\
\__/'._;. ==' ==\
\ \ |
/ / /
/-._/-._/
\ `\ \
`-._/._/
```
└───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
Time: 2.569 ms