/* MastoGem, A Mastodon proxy for Gemini Copyright (C) 2021 Romain de Laage This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ package main import ( "crypto/tls" "net/url" "log" "net" "bufio" "strconv" "fmt" "strings" ) 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, rateLimit int) { for { conn, err := listener.Accept() if err != nil { log.Println(err) } go handleConn(conn.(*tls.Conn), baseURL, title, home_message, rateLimit) } } 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, string, error) { rawURL, err := getRawURL(conn) if err != nil { return "", "", err } parsedURL, err := url.Parse(rawURL) if err != nil { return "", "", err } return parsedURL.Path, parsedURL.RawQuery, nil } func handleConn(conn *tls.Conn, baseURL, title, home_message string, rateLimit int) { defer conn.Close() if !rateIsOk(rateMap, strings.Split(conn.RemoteAddr().String(), ":")[0], rateLimit) { log.Printf("Too many requests for %s\n", conn.RemoteAddr().String()) _, err := fmt.Fprintf(conn, "44 60\r\n") if err != nil { log.Println("send error: %s", err) return } return } path, query, 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 == "/" { log.Println("Received request for home page") _, err = fmt.Fprintf(conn, "20 text/gemini\r\n# %s\n\n%s\n\n=> /timeline View public timeline\n=> /tag Search for a tag\n=> /about About MastoGem", title, home_message) if err != nil { log.Println("send error: %s", err) return } return } // profile if strings.HasPrefix(path, "/profile/") { if strings.HasSuffix(path, "/reblog") { // skip prefix and suffix path = path[9:len(path)-len("/reblog")] _, 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 with reblog " + path) printProfileWithReblog(conn, baseURL, path) } else { // skip prefix path = path[9:] _, 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[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 thread " + path) printThread(conn, baseURL, path) } /* about */ else if strings.HasPrefix(path, "/about") { const page = `# About MastoGem This capsule is running MastoGem, a free (as in free speech, not as in free beer) and open source software. It is released under AGPLv3 License (a copy a the license is available below). This software was written by Romain de Laage in 2021 during his free time. You can get a copy of the sources of this software here : => https://git.rdelaage.ovh/rdelaage/mastoGem Gitea repository (web) Feel free to contribute, send feedback or share ideas. # Mastodon instance This capsule use %s Mastodon instance. # AGPLv3 License => http://www.gnu.org/licenses/agpl-3.0.txt AGPLv3 License text` log.Println("Received request for about page") _, err = fmt.Fprintf(conn, "20 text/gemini\r\n" + page, baseURL) if err != nil { log.Println("send: %s", err) return } } /* tag */ else if strings.HasPrefix(path, "/tag") { if query == "" { _, err = fmt.Fprintf(conn, "10 Enter a tag name\r\n") if err != nil { log.Println("send: %s", err) return } return } log.Println("Received request for tag " + query) printTag(conn, baseURL, query) } /* toot */ else if strings.HasPrefix(path, "/toot/") { path = path[6:] _, 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 toot " + path) printToot(conn, baseURL, path) } /* timeline */ else if strings.HasPrefix(path, "/timeline") { log.Println("Received request for timeline") printTimeline(conn, baseURL) } /* media */ else if strings.HasPrefix(path, "/media") { if query == "" { _, err = fmt.Fprintf(conn, "59 Invalid request\r\n") if err != nil { log.Println("send: %s", err) return } return } log.Println("Received request for media " + query) proxyMedia(conn, baseURL, query) } else { _, err = fmt.Fprintf(conn, "59 Invalid request\r\n") if err != nil { log.Println("send: %s", err) return } } } func proxyMedia(conn *tls.Conn, baseURL, query string) { mediaURL, err := url.QueryUnescape(query) if err != nil { _, err = fmt.Fprintf(conn, "59 Invalid url encoded media\r\n") if err != nil { log.Println("handleConn: %s", err) return } return } if !strings.HasPrefix(mediaURL, baseURL) { _, err = fmt.Fprintf(conn, "59 Invalid media url\r\n") if err != nil { log.Println("handleConn: %s", err) return } return } mime, media, err := getMedia(mediaURL) if err != 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 %s\r\n", mime) if err != nil { log.Println("handleConn: %s", err) return } _, err = conn.Write(media) if err != nil { log.Println("handleConn: %s", err) return } } func printProfile(conn *tls.Conn, baseURL, profileID string) { account, err := getAccount(baseURL, profileID) if err != nil { _, err = fmt.Fprintf(conn, "40 Remote mastodon instance failed\r\n") if err != nil { log.Println("handleConn: %s", err) return } return } blogs, err := getBlog(baseURL, profileID) if err != 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 %s account\n", account.Name) if err != nil { log.Println("handleConn: %s", err) return } for _, blog := range blogs { _, err = fmt.Fprintf(conn, "\n%s\n=> /thread/%s View the thread\n", formatBlog(blog), blog.Id) if err != nil { log.Println("read blogs: %s", err) return } } _, err = fmt.Fprintf(conn, "\n=> /profile/%s/reblog This profile with reblog\n=> %s Go to %s account", account.Id, account.Url, account.Name) if err != nil { log.Println("add link: %s", err) return } } func printProfileWithReblog(conn *tls.Conn, baseURL, profileID string) { account, err := getAccount(baseURL, profileID) if err != nil { _, err = fmt.Fprintf(conn, "40 Remote mastodon instance failed\r\n") if err != nil { log.Println("handleConn: %s", err) return } return } blogs, err := getBlogAndReblog(baseURL, profileID) if err != 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 %s account\n", account.Name) if err != nil { log.Println("handleConn: %s", err) return } for _, blog := range blogs { _, err = fmt.Fprintf(conn, "\n%s\n=> /thread/%s View the thread\n", formatBlog(blog), blog.Id) if err != nil { log.Println("read blogs: %s", err) return } } _, err = fmt.Fprintf(conn, "\n=> /profile/%s This profile without reblog\n=> %s Go to %s account", account.Id, account.Url, account.Name) if err != nil { log.Println("add link: %s", err) return } } func printTimeline(conn *tls.Conn, baseURL string) { toots, err := getTimeline(baseURL) 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") if err != nil { log.Println("handleConn: %s", err) return } // Print toots for _, toot := range toots { _, err = fmt.Fprintf(conn, "\n%s\n=> /thread/%s View the thread\n", formatBlog(toot), toot.Id) if err != nil { log.Println("read timeline: %s", err) return } } } func printToot(conn *tls.Conn, baseURL, tootID string) { toot, 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 } // Print header _, err = fmt.Fprintf(conn, "20 text/gemini\r\n") if err != nil { log.Println("handleConn: %s", err) return } // Print toot if toot.Reblog == nil { _, err = fmt.Fprintf(conn, "# Toot\n\n%s\n=> /thread/%s View the thread\n=> /profile/%s More toots from %s\n", formatBlog(toot), toot.Id, toot.Author.Id, toot.Author.Name) if err != nil { log.Println("handleConn: %s", err) return } } else { _, err = fmt.Fprintf(conn, "# Toot\n\n%s\n=> /toot/%s Original toot\n=> /thread/%s View the thread\n=> /profile/%s More toots from %s\n=> /profile/%s More toots from %s\n", formatBlog(toot), toot.Reblog.Id, toot.Id, toot.Author.Id, toot.Author.Name, toot.Reblog.Author.Id, toot.Reblog.Author.Name) if err != nil { log.Println("handleConn: %s", err) return } } // print mentions _, err = fmt.Fprintf(conn, "\n# Mentions\n") if err != nil { log.Println("handleConn: %s", err) return } for _, mention := range toot.Mentions { _, err = fmt.Fprintf(conn, "\n=> /profile/%s View %s profile", mention.Id, mention.Name) if err != nil { log.Println("handleConn: %s", err) return } } // print tags _, err = fmt.Fprintf(conn, "\n\n# Tags\n") if err != nil { log.Println("handleConn: %s", err) return } for _, tag := range toot.Tags { _, err = fmt.Fprintf(conn, "\n=> /tag?%s View %s tag", url.QueryEscape(tag.Name), tag.Name) if err != nil { log.Println("handleConn: %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%s\n=> /profile/%s More toots from %s\n", formatBlog(toot), toot.Author.Id, toot.Author.Name) if err != nil { log.Println("handleConn: %s", err) return } } // Print original toot _, err = fmt.Fprintf(conn, "\n# Toot\n\n%s\n=> /profile/%s More toots from %s\n", formatBlog(originalToot), originalToot.Author.Id, originalToot.Author.Name) 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%s\n=> /profile/%s More toots from %s\n", formatBlog(toot), toot.Author.Id, toot.Author.Name) if err != nil { log.Println("handleConn: %s", err) return } } } func printTag(conn *tls.Conn, baseURL, tag string) { toots, err := getTag(baseURL, tag) if err != nil { _, err = fmt.Fprintf(conn, "40 Remote mastodon instance failed\r\n") if err != nil { log.Println("handleConn: %s", err) return } return } //decode tag (url encoded char like space) tag, err = url.QueryUnescape(tag) if err != nil { _, err = fmt.Fprintf(conn, "59 Invalid url encoded tag\r\n") if err != nil { log.Println("handleConn: %s", err) return } return } // Print header _, err = fmt.Fprintf(conn, "20 text/gemini\r\n# Toots for %s\n", tag) if err != nil { log.Println("handleConn: %s", err) return } // Print toots for _, toot := range toots { _, err = fmt.Fprintf(conn, "\n%s\n=> /profile/%s More toots from %s\n=> /thread/%s View the thread\n", formatBlog(toot), toot.Author.Id, toot.Author.Name, toot.Id) if err != nil { log.Println("handleConn: %s", err) return } } }