Compare commits

...

12 Commits
1.0 ... main

8 changed files with 431 additions and 56 deletions

View File

@ -1,4 +1,4 @@
VERSION=1.0
VERSION=1.2
SOURCES=$(shell find . -name "*.go" -type f)
all: amd64 arm7 dockerimage

View File

@ -4,5 +4,6 @@
"key_path": "certs/key.rsa",
"base_url": "https://mamot.fr",
"title": "MastoGem",
"home_message": "Welcome on MastoGem, a Mastodon proxy for Gemini.\nYou can view the last 20 toots of a Mastodon account by providing its id, for example:\n=> gemini://localhost/310515 My Mastodon account"
"home_message": "Welcome on MastoGem, a Mastodon proxy for Gemini.\nYou can view the last 20 toots of a Mastodon account by providing its id, for example:\n=> gemini://localhost/310515 My Mastodon account",
"rate_limit": 45
}

View File

@ -2,7 +2,7 @@ version: "3.7"
services:
mastogem:
image: dervom/mastogem:1.0
image: dervom/mastogem:1.1
build: .
container_name: mastogem
volumes:

34
main.go
View File

@ -17,15 +17,24 @@
*/
package main
import "log"
import (
"log"
"time"
)
type Blog struct {
Id string `json:"id"`
Content string `json:"content"`
Date string `json:"created_at"`
Author Account `json:"account"`
Tags []Tag `json:"tags"`
Mentions []Mention `json:"mentions"`
Id string `json:"id"`
Content string `json:"content"`
Date string `json:"created_at"`
Author Account `json:"account"`
Tags []Tag `json:"tags"`
Mentions []Mention `json:"mentions"`
Reblog *Blog `json:"reblog"`
Medias []Media `json:"media_attachments"`
}
type Media struct {
Url string `json:"url"`
}
type Config struct {
@ -35,6 +44,7 @@ type Config struct {
BaseURL string `json:"base_url"`
Title string `json:"title"`
HomeMessage string `json:"home_message"`
RateLimit int `json:"rate_limit"`
}
type Account struct {
@ -58,12 +68,20 @@ type Tag struct {
Name string `json:"name"`
}
type Rate struct {
Date time.Time
Count int
}
var rateMap map[string]Rate
func main() {
config := getConfig()
rateMap = make(map[string]Rate)
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)
serve(listener, config.BaseURL, config.Title, config.HomeMessage, config.RateLimit)
}

View File

@ -21,38 +21,114 @@ import (
"net/http"
"log"
"encoding/json"
"io/ioutil"
"io"
"fmt"
)
func getBlog(baseURL, account string) []Blog {
func getBlogAndReblog(baseURL, account string) ([]Blog, error) {
if baseURL == "" || account == "" {
log.Println("baseURL or account is empty")
return nil
return nil, fmt.Errorf("BaseURL or account is empty")
}
resp, err := http.Get(baseURL + "/api/v1/accounts/" + account + "/statuses?exclude_reblogs=true&exclude_replies=true")
resp, err := http.Get(baseURL + "/api/v1/accounts/" + account + "/statuses?exclude_reblogs=false&exclude_replies=true")
if err != nil {
log.Println("Mastodon API request: %s", err)
return nil
return nil, fmt.Errorf("Failed to request Mastodon API")
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
log.Println("Mastodon API response: %s", resp.Status)
return nil
return nil, fmt.Errorf("Mastodon instance failed")
}
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Println("Mastodon response body: %s", err)
return nil, fmt.Errorf("Failed to read Mastodon response body")
}
var blogs []Blog
json.Unmarshal(body, &blogs)
err = json.Unmarshal(body, &blogs)
if err != nil {
log.Println("Mastodon response %s", err)
return nil, fmt.Errorf("Failed to parse Mastodon response")
}
return blogs
return blogs, nil
}
func getBlog(baseURL, account string) ([]Blog, error) {
if baseURL == "" || account == "" {
log.Println("baseURL or account is empty")
return nil, fmt.Errorf("BaseURL or account is empty")
}
resp, err := http.Get(baseURL + "/api/v1/accounts/" + account + "/statuses?exclude_reblogs=true&exclude_replies=true")
if err != nil {
log.Println("Mastodon API request: %s", err)
return nil, fmt.Errorf("Failed to request Mastodon API")
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
log.Println("Mastodon API response: %s", resp.Status)
return nil, fmt.Errorf("Mastodon instance failed")
}
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Println("Mastodon response body: %s", err)
return nil, fmt.Errorf("Failed to read Mastodon response body")
}
var blogs []Blog
err = json.Unmarshal(body, &blogs)
if err != nil {
log.Println("Mastodon response %s", err)
return nil, fmt.Errorf("Failed to parse Mastodon response")
}
return blogs, nil
}
func getTimeline(baseURL string) ([]Blog, error) {
var toots []Blog
if baseURL == "" {
log.Println("baseURL is empty")
return toots, fmt.Errorf("baseURL is empty")
}
resp, err := http.Get(baseURL + "/api/v1/timelines/public")
if err != nil {
log.Println("Mastodon API request: %s", err)
return toots, fmt.Errorf("API request failed")
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
log.Println("Mastodon API response: %s", resp.Status)
return toots, fmt.Errorf("API response is not 200")
}
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Println("Mastodon response body: %s", err)
return toots, fmt.Errorf("Failed to read response")
}
err = json.Unmarshal(body, &toots)
if err != nil {
log.Println("Mastodon response %s", err)
return toots, fmt.Errorf("Failed to parse response")
}
return toots, nil
}
func getAccount(baseURL, accountId string) (Account, error) {
@ -74,14 +150,18 @@ func getAccount(baseURL, accountId string) (Account, error) {
return Account{}, fmt.Errorf("API response is not 200")
}
body, err := ioutil.ReadAll(resp.Body)
body, err := io.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)
err = json.Unmarshal(body, &account)
if err != nil {
log.Println("Mastodon response %s", err)
return Account{}, fmt.Errorf("Failed to parse response")
}
return account, nil
}
@ -105,18 +185,50 @@ func getToot(baseURL, tootId string) (Blog, error) {
return Blog{}, fmt.Errorf("API response is not 200")
}
body, err := ioutil.ReadAll(resp.Body)
body, err := io.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)
err = json.Unmarshal(body, &toot)
if err != nil {
log.Println("Mastodon response %s", err)
return Blog{}, fmt.Errorf("Failed to parse response")
}
return toot, nil
}
func getMedia(mediaURL string) (string, []byte, error) {
if mediaURL == "" {
log.Println("mediaURL is empty")
return "", nil, fmt.Errorf("mediaURL is empty")
}
resp, err := http.Get(mediaURL)
if err != nil {
log.Println("Mastodon API request: %s", err)
return "", nil, fmt.Errorf("API request failed")
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
log.Println("Mastodon API response: %s", resp.Status)
return "", nil, fmt.Errorf("API response is not 200")
}
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Println("Mastodon response body: %s", err)
return "", nil, fmt.Errorf("Failed to read response")
}
return resp.Header["Content-Type"][0], body , nil
}
func getThread(baseURL, tootId string) (Thread, error) {
if baseURL == "" || tootId == "" {
log.Println("baseURL or tootID is empty")
@ -136,14 +248,18 @@ func getThread(baseURL, tootId string) (Thread, error) {
return Thread{}, fmt.Errorf("API response is not 200")
}
body, err := ioutil.ReadAll(resp.Body)
body, err := io.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)
err = json.Unmarshal(body, &thread)
if err != nil {
log.Println("Mastodon response %s", err)
return Thread{}, fmt.Errorf("Failed to parse response")
}
return thread, nil
}
@ -167,14 +283,18 @@ func getTag(baseURL, tag string) ([]Blog, error) {
return nil, fmt.Errorf("API response is not 200")
}
body, err := ioutil.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Println("Mastodon response body: %s", err)
return nil, fmt.Errorf("Failed to read response")
}
var blogs []Blog
json.Unmarshal(body, &blogs)
err = json.Unmarshal(body, &blogs)
if err != nil {
log.Println("Mastodon response: %s", err)
return nil, fmt.Errorf("Failed to parse response")
}
return blogs, nil
}

221
server.go
View File

@ -48,14 +48,14 @@ func listen(address, certFile, keyFile string) net.Listener {
return listener
}
func serve(listener net.Listener, baseURL, title, home_message string) {
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)
go handleConn(conn.(*tls.Conn), baseURL, title, home_message, rateLimit)
}
}
@ -87,9 +87,19 @@ func getPath(conn *tls.Conn) (string, string, error) {
return parsedURL.Path, parsedURL.RawQuery, nil
}
func handleConn(conn *tls.Conn, baseURL, title, home_message string) {
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)
@ -105,7 +115,7 @@ func handleConn(conn *tls.Conn, baseURL, title, home_message string) {
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=> /tag Search for a tag\n=> /about About MastoGem", title, home_message)
_, 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
@ -115,22 +125,39 @@ func handleConn(conn *tls.Conn, baseURL, title, home_message string) {
// profile
if strings.HasPrefix(path, "/profile/") {
// 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 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("send error: %s", err)
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
}
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)
}
log.Println("Received request for account " + path)
printProfile(conn, baseURL, path)
} /* thread */ else if strings.HasPrefix(path, "/thread/") {
// skip prefix
path = path[8:]
@ -203,6 +230,23 @@ This capsule use %s Mastodon instance.
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 {
@ -212,11 +256,62 @@ This capsule use %s Mastodon instance.
}
}
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)
blogs := 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
}
if err != nil || blogs == nil {
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)
@ -239,13 +334,83 @@ func printProfile(conn *tls.Conn, baseURL, profileID string) {
}
}
_, err = fmt.Fprintf(conn, "\n=> %s Go to %s account", account.Url, account.Name)
_, 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 {
@ -265,10 +430,18 @@ func printToot(conn *tls.Conn, baseURL, tootID string) {
}
// Print toot
_, 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
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
@ -286,7 +459,7 @@ func printToot(conn *tls.Conn, baseURL, tootID string) {
}
}
// print tags
_, err = fmt.Fprintf(conn, "\n# Tags\n")
_, err = fmt.Fprintf(conn, "\n\n# Tags\n")
if err != nil {
log.Println("handleConn: %s", err)
return

View File

@ -15,18 +15,27 @@ fi
if [ ! -f /key.rsa ]
then
echo "You must bind a private key at /key.rsa"
exit 1
fi
if [ -z "$TITLE" ]
then
echo "Using default title"
TITLE=MastoGem
fi
if [ -z "$HOME_MESSAGE" ]
then
echo "Using default home message"
HOME_MESSAGE="Welcome on MastoGem, a Mastodon proxy for Gemini !"
fi
if [ -z "$RATE_LIMIT" ]
then
echo "Using default rate limit"
RATE_LIMIT=45
fi
cat << EOF > /config.json
{
"listen": "0.0.0.0:1965",
@ -34,7 +43,8 @@ cat << EOF > /config.json
"key_path": "/key.rsa",
"base_url": "$MASTODON_BASE_URL",
"title": "$TITLE",
"home_message": "$HOME_MESSAGE"
"home_message": "$HOME_MESSAGE",
"rate_limit": $RATE_LIMIT
}
EOF

59
util.go
View File

@ -25,6 +25,8 @@ import (
"io/ioutil"
"encoding/json"
"log"
"time"
"net/url"
)
func getConfig() Config {
@ -39,6 +41,7 @@ func getConfig() Config {
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.",
RateLimit: 45,
}
return config
@ -51,7 +54,10 @@ func getConfig() Config {
var config Config
json.Unmarshal(configFile, &config)
err = json.Unmarshal(configFile, &config)
if err != nil {
log.Fatalln("config file: %s", err)
}
return config
}
@ -83,7 +89,12 @@ func removeHTMLTags(content string) string {
}
func formatBlog(toot Blog) string {
content := toot.Content
var content string
if toot.Reblog == nil {
content = toot.Content
} else {
content = toot.Reblog.Content
}
content = removeHTMLTags(content)
content = strings.Trim(content, " \n\r")
content = strings.ReplaceAll(content, "\n#", "\n[#]")
@ -99,5 +110,47 @@ func formatBlog(toot Blog) string {
author = toot.Author.DisplayName
}
return "### Written by " + author + " on " + toot.Date[0:10] + " at " + toot.Date[11:16] + "\n" + content + "\n=> /toot/" + toot.Id + " More informations about this toot"
var header string
if toot.Reblog == nil {
header = "### Written by " + author + " on " + toot.Date[0:10] + " at " + toot.Date[11:16]
} else {
var originalAuthor string
if toot.Reblog.Author.DisplayName == "" {
originalAuthor = toot.Reblog.Author.Name
} else {
originalAuthor = toot.Reblog.Author.DisplayName
}
header = "### Shared by " + author + " on " + toot.Date[0:10] + " at " + toot.Date[11:16] + " (original by " + originalAuthor + ")"
}
footer := "\n"
for _, media := range toot.Medias {
mediaURL := url.QueryEscape(media.Url)
footer += "=> /media?" + mediaURL + " View attached media\n"
}
footer += "\n=> /toot/" + toot.Id + " More informations about this toot"
return header + "\n" + content + "\n" + footer
}
func rateIsOk(tab map[string]Rate, remoteIP string, limit int) bool {
elmt, ok := tab[remoteIP]
if ok == false {
tab[remoteIP] = Rate{time.Now(), 1}
return true
} else {
if time.Since(elmt.Date).Minutes() >= 1 {
tab[remoteIP] = Rate{time.Now(), 1}
return true
} else {
if elmt.Count < limit {
tab[remoteIP] = Rate{elmt.Date, elmt.Count + 1}
return true
} else {
return false
}
}
}
}