package main import ( "crypto/tls" "net/url" "log" "net" "net/http" "bufio" "os" "strconv" "fmt" "encoding/json" "io/ioutil" "strings" "html" "regexp" ) type Blog struct { Id string `json:"id"` Content string `json:"content"` Date string `json:"created_at"` Author Account `json:"account"` } type Config struct { Listen string `json:"listen"` CertPath string `json:"cert_path"` KeyPath string `json:"key_path"` BaseURL string `json:"base_url"` Title string `json:"title"` HomeMessage string `json:"home_message"` } type Account struct { Id string `json:"id"` Name string `json:"display_name"` Url string `json:"url"` } type Thread struct { Ancestors []Blog `json:"ancestors"` Descendants []Blog `json:"descendants"` } func main() { config := getConfig() listener := listen(config.Listen, config.CertPath, config.KeyPath) log.Println("Server successfully started") log.Println("Server is listening at " + config.Listen) serve(listener, config.BaseURL, config.Title, config.HomeMessage) } func getConfig() Config { configPath := os.Getenv("MASTOGEM_CONFIG_PATH") if configPath == "" { log.Println("MASTOGEM_CONFIG_PATH was not set, using default settings") config := Config{ Listen: "127.0.0.1:1965", CertPath: "cert.pem", KeyPath: "key.rsa", BaseURL: "https://mamot.fr", Title: "MastoGem", HomeMessage: "Welcome on MastoGem, this is a Mastodon proxy for Gemini. You can view the last 20 toots of a Mastodon account by providing its ID.", } return config } configFile, err := ioutil.ReadFile(configPath) if err != nil { log.Fatalln("config file: %s", err) } var config Config json.Unmarshal(configFile, &config) return config } func listen(address, certFile, keyFile string) net.Listener { cert, err := tls.LoadX509KeyPair(certFile, keyFile) if err != nil { log.Fatalln("loadkeys: %s", err) } config := &tls.Config{ ClientAuth: tls.RequestClientCert, Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12, InsecureSkipVerify: true, } listener, err := tls.Listen("tcp", address, config) if err != nil { log.Fatalln("failed to listen on 0.0.0.0:1965: %s", err) } return listener } func serve(listener net.Listener, baseURL, title, home_message string) { for { conn, err := listener.Accept() if err != nil { log.Println(err) } go handleConn(conn.(*tls.Conn), baseURL, title, home_message) } } func getRawURL(conn *tls.Conn) (string, error) { scanner := bufio.NewScanner(conn) if ok := scanner.Scan(); !ok { return "", scanner.Err() } rawURL := scanner.Text() if strings.Contains(rawURL, "://") { return rawURL, nil } return fmt.Sprintf("gemini://%s", rawURL), nil } func getPath(conn *tls.Conn) (string, error) { rawURL, err := getRawURL(conn) if err != nil { return "", err } parsedURL, err := url.Parse(rawURL) if err != nil { return "", err } return parsedURL.Path, nil } func handleConn(conn *tls.Conn, baseURL, title, home_message string) { defer conn.Close() path, err := getPath(conn) if err != nil { log.Println("get url: %s", err) _, err = fmt.Fprintf(conn, "59 Can't parse request\r\n") if err != nil { log.Println("send error: %s", err) return } return } // home if path == "" || path == "/" { _, err = fmt.Fprintf(conn, "20 text/gemini\r\n# " + title + "\n\n" + home_message) if err != nil { log.Println("send error: %s", err) return } return } // profile if strings.HasPrefix(path, "/profile/") { // skip prefix path = path[8:] _, err = strconv.ParseUint(path, 10, 64) if err != nil { log.Println("invalid request: %s", err) _, err = fmt.Fprintf(conn, "59 Can't parse request\r\n") if err != nil { log.Println("send error: %s", err) return } return } log.Println("Received request for account " + path) printProfile(conn, baseURL, path) } /* thread */ else if strings.HasPrefix(path, "/thread/") { // skip prefix path = path[7:] _, err = strconv.ParseUint(path, 10, 64) if err != nil { log.Println("invalid request: %s", err) _, err = fmt.Fprintf(conn, "59 Can't parse request\r\n") if err != nil { log.Println("send error: %s", err) return } return } log.Println("Received request for thread " + path) printThread(conn, baseURL, path) } else { _, err = fmt.Fprintf(conn, "59 Invalid request\r\n") if err != nil { log.Println("send: %s", err) return } } } func printProfile(conn *tls.Conn, baseURL, profileID string) { account, err := getAccount(baseURL, profileID) blogs := getBlog(baseURL, profileID) if err != nil || blogs == nil { _, err = fmt.Fprintf(conn, "40 Remote mastodon instance failed\r\n") if err != nil { log.Println("handleConn: %s", err) return } return } _, err = fmt.Fprintf(conn, "20 text/gemini\r\n# Toots for " + account.Name + " account\n") if err != nil { log.Println("handleConn: %s", err) return } for _, blog := range blogs { date := "\n```\n* Posted at " + blog.Date + " *\n```\n" text := removeHTMLTags(blog.Content) + "\n" _, err = fmt.Fprintf(conn, date + text + "=> /thread/" + blog.Id + " View the thread\n") if err != nil { log.Println("read blogs: %s", err) return } } _, err = fmt.Fprintf(conn, "=> " + account.Url + " Go to " + account.Name + " account") if err != nil { log.Println("add link: %s", err) return } } func printThread(conn *tls.Conn, baseURL, tootID string) { originalToot, err := getToot(baseURL, tootID) if err != nil { _, err = fmt.Fprintf(conn, "40 Remote mastodon instance failed\r\n") if err != nil { log.Println("handleConn: %s", err) return } return } thread, err := getThread(baseURL, tootID) if err != nil { _, err = fmt.Fprintf(conn, "40 Remote mastodon instance failed\r\n") if err != nil { log.Println("handleConn: %s", err) return } return } // Print header _, err = fmt.Fprintf(conn, "20 text/gemini\r\n# Ancestors\n") if err != nil { log.Println("handleConn: %s", err) return } // Print each anscestor for _, toot := range thread.Ancestors { _, err = fmt.Fprintf(conn, "\n```\n* Posted on " + toot.Date + " by " + toot.Author.Name + " *\n```\n" + removeHTMLTags(toot.Content) + "\n=> /profile/" + toot.Author.Id + " More toots from " + toot.Author.Name + "\n") if err != nil { log.Println("handleConn: %s", err) return } } // Print original toot _, err = fmt.Fprintf(conn, "\n# Toot\n\n```\n* Posted on " + originalToot.Date + " by "+ originalToot.Author.Name +" *\n```\n" + removeHTMLTags(originalToot.Content) + "\n=> /profile/" + originalToot.Author.Id + " More toots from " + originalToot.Author.Name + "\n") if err != nil { log.Println("handleConn: %s", err) return } // Print each descendant _, err = fmt.Fprintf(conn, "\n# Descendants\n") if err != nil { log.Println("handleConn: %s", err) return } for _, toot := range thread.Descendants { _, err = fmt.Fprintf(conn, "\n```\n* Posted on " + toot.Date + " by " + toot.Author.Name + " *\n```\n" + removeHTMLTags(toot.Content) + "\n=> /profile/" + toot.Author.Id + " More toots from " + toot.Author.Name + "\n") if err != nil { log.Println("handleConn: %s", err) return } } } func removeHTMLTags(content string) string { text := strings.ReplaceAll(content, "

", "") text = strings.ReplaceAll(text, "

", "\n\n") text = strings.ReplaceAll(text, "
", "\n") text = strings.ReplaceAll(text, "
", "\n") text = strings.ReplaceAll(text, "", "") text = strings.ReplaceAll(text, "", "") regexString := "]*)?>" regex, err := regexp.Compile(regexString) if err != nil { log.Println("regex: %s", err) return "" } text = regex.ReplaceAllLiteralString(text, "") regexString = "]*)?>" regex, err = regexp.Compile(regexString) if err != nil { log.Println("regex: %s", err) return "" } text = regex.ReplaceAllLiteralString(text, "") text = html.UnescapeString(text) return text } func getBlog(baseURL, account string) []Blog { if baseURL == "" || account == "" { log.Println("baseURL or account is empty") return nil } resp, err := http.Get(baseURL + "/api/v1/accounts/" + account + "/statuses?exclude_reblogs=true&exlude_replies=true") if err != nil { log.Println("Mastodon API request: %s", err) return nil } defer resp.Body.Close() if resp.StatusCode != 200 { log.Println("Mastodon API response: %s", resp.Status) return nil } body, err := ioutil.ReadAll(resp.Body) if err != nil { log.Println("Mastodon response body: %s", err) } var blogs []Blog json.Unmarshal(body, &blogs) return blogs } func getAccount(baseURL, accountId string) (Account, error) { if baseURL == "" || accountId == "" { log.Println("baseURL or account is empty") return Account{}, fmt.Errorf("baseURL or account is empty") } resp, err := http.Get(baseURL + "/api/v1/accounts/" + accountId) if err != nil { log.Println("Mastodon API request: %s", err) return Account{}, fmt.Errorf("API request failed") } defer resp.Body.Close() if resp.StatusCode != 200 { log.Println("Mastodon API response: %s", resp.Status) return Account{}, fmt.Errorf("API response is not 200") } body, err := ioutil.ReadAll(resp.Body) if err != nil { log.Println("Mastodon response body: %s", err) return Account{}, fmt.Errorf("Failed to read response") } var account Account json.Unmarshal(body, &account) return account, nil } func getToot(baseURL, tootId string) (Blog, error) { if baseURL == "" || tootId == "" { log.Println("baseURL or tootID is empty") return Blog{}, fmt.Errorf("baseURL or tootID is empty") } resp, err := http.Get(baseURL + "/api/v1/statuses/" + tootId) if err != nil { log.Println("Mastodon API request: %s", err) return Blog{}, fmt.Errorf("API request failed") } defer resp.Body.Close() if resp.StatusCode != 200 { log.Println("Mastodon API response: %s", resp.Status) return Blog{}, fmt.Errorf("API response is not 200") } body, err := ioutil.ReadAll(resp.Body) if err != nil { log.Println("Mastodon response body: %s", err) return Blog{}, fmt.Errorf("Failed to read response") } var toot Blog json.Unmarshal(body, &toot) return toot, nil } func getThread(baseURL, tootId string) (Thread, error) { if baseURL == "" || tootId == "" { log.Println("baseURL or tootID is empty") return Thread{}, fmt.Errorf("baseURL or tootID is empty") } resp, err := http.Get(baseURL + "/api/v1/statuses/" + tootId + "/context") if err != nil { log.Println("Mastodon API request: %s", err) return Thread{}, fmt.Errorf("API request failed") } defer resp.Body.Close() if resp.StatusCode != 200 { log.Println("Mastodon API response: %s", resp.Status) return Thread{}, fmt.Errorf("API response is not 200") } body, err := ioutil.ReadAll(resp.Body) if err != nil { log.Println("Mastodon response body: %s", err) return Thread{}, fmt.Errorf("Failed to read response") } var thread Thread json.Unmarshal(body, &thread) return thread, nil }