main.go

  1package main
  2
  3import (
  4	"bytes"
  5	"encoding/json"
  6	"fmt"
  7	"io"
  8	"log"
  9	"net/http"
 10	"net/url"
 11	"os"
 12	"strings"
 13	"text/template"
 14
 15	"github.com/mmcdole/gofeed"
 16	flag "github.com/spf13/pflag"
 17	"golang.org/x/net/html"
 18)
 19
 20var (
 21	flagFeed         *string = flag.StringP("feed", "f", "", "RSS/Atom feed URL (required)")
 22	flagTemplate     *string = flag.StringP("template", "t", "", "Template file to use, (required)")
 23	flagVisibility   *string = flag.StringP("visibility", "v", "", "Visibility of the post, public, unlisted, or private (required)")
 24	flagLanguage     *string = flag.StringP("language", "l", "en", "Language of the post, ISO 639-1 code (optional)")
 25	flagSpoiler      *string = flag.StringP("spoiler", "s", "", "Spoiler text for the post (optional)")
 26	flagSensitive    *bool   = flag.BoolP("sensitive", "S", false, "Mark the post as sensitive (optional)")
 27	flagAuthenticate *bool   = flag.BoolP("authenticate", "a", false, "Authenticate with the server")
 28	flagInstance     *string = flag.StringP("instance", "i", "", "Instance to post to (required)")
 29)
 30
 31type post struct {
 32	Title       string
 33	Description string `json:"status"`
 34	Image       string `json:"media_attachments"`
 35	Link        string
 36	Visibility  string `json:"visibility"`
 37	Sensitive   bool   `json:"sensitive"`
 38	Spoiler     string `json:"spoiler_text"`
 39}
 40
 41func main() {
 42	flag.Parse()
 43	validateFlags()
 44
 45	fp := gofeed.NewParser()
 46	feed, _ := fp.ParseURL(*flagFeed)
 47	description, _ := htmlToPlaintext(feed.Items[0].Description)
 48	description = strings.ReplaceAll(description, "\n\n", " ")
 49	description = strings.ReplaceAll(description, "\n", " ")
 50	splitDesc := strings.Split(description, " ")
 51	if len(splitDesc) > 50 {
 52		description = strings.Join(splitDesc[:50], " ")
 53	}
 54
 55	post := post{
 56		Title:       feed.Items[0].Title,
 57		Description: description + "...",
 58		Link:        feed.Items[0].Link,
 59	}
 60
 61	if feed.Items[0].Image != nil {
 62		post.Image = feed.Items[0].Image.URL
 63	}
 64
 65	tFile, err := os.ReadFile(*flagTemplate)
 66	if err != nil {
 67		fmt.Println(err)
 68	}
 69	tmpl, err := template.New("template").Parse(string(tFile))
 70	if err != nil {
 71		fmt.Println(err)
 72	}
 73	buffer := new(strings.Builder)
 74	err = tmpl.Execute(buffer, post)
 75	if err != nil {
 76		fmt.Println(err)
 77	}
 78
 79	post.Description = buffer.String()
 80	post.Sensitive = *flagSensitive
 81	post.Spoiler = *flagSpoiler
 82	post.Visibility = *flagVisibility
 83
 84	postBytes, err := json.Marshal(post)
 85	if err != nil {
 86		fmt.Println(err)
 87		os.Exit(1)
 88	}
 89
 90	client := &http.Client{}
 91	req, err := http.NewRequest("POST", "https://"+*flagInstance+"/api/v1/statuses", bytes.NewBuffer(postBytes))
 92	if err != nil {
 93		log.Fatal(err)
 94	}
 95	req.Header.Set("Authorization", "Bearer "+os.Getenv("RSS2FEDI_TOKEN"))
 96	req.Header.Set("Content-Type", "application/json")
 97	resp, err := client.Do(req)
 98	if err != nil {
 99		log.Fatal(err)
100	}
101	defer resp.Body.Close()
102	respData, err := io.ReadAll(resp.Body)
103	if err != nil {
104		log.Fatal(err)
105	}
106	respMap := make(map[string]string)
107	err = json.Unmarshal(respData, &respMap)
108	fmt.Println(respMap["url"])
109}
110
111func extractText(node *html.Node) string {
112	if node.Type == html.TextNode {
113		return node.Data
114	}
115
116	var result string
117	for child := node.FirstChild; child != nil; child = child.NextSibling {
118		result += extractText(child)
119	}
120
121	return result
122}
123
124func htmlToPlaintext(htmlString string) (string, error) {
125	doc, err := html.Parse(strings.NewReader(htmlString))
126	if err != nil {
127		return "", err
128	}
129
130	return extractText(doc), nil
131}
132
133func authenticate() {
134	v := url.Values{}
135	v.Add("client_name", "RSS2Fedi")
136	v.Add("redirect_uris", "urn:ietf:wg:oauth:2.0:oob")
137	v.Add("scopes", "write:statuses")
138	v.Add("website", "https://git.sr.ht/~amolith/rss2fedi")
139	resp, err := http.PostForm("https://"+*flagInstance+"/api/v1/apps", v)
140	if err != nil {
141		fmt.Println(err)
142		os.Exit(1)
143	}
144	defer resp.Body.Close()
145
146	defer resp.Body.Close()
147	data, err := io.ReadAll(resp.Body)
148	if err != nil {
149		fmt.Println(err)
150		os.Exit(1)
151	}
152
153	var result map[string]string
154	err = json.Unmarshal(data, &result)
155
156	fmt.Println("Visit the following URL to authenticate:")
157	fmt.Println("https://" + *flagInstance + "/oauth/authorize?client_id=" + result["client_id"] + "&response_type=code&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=write:statuses\n")
158	fmt.Print("Paste the code you receive here then press enter: ")
159	var code string
160	fmt.Scanln(&code)
161	fmt.Println()
162
163	v = url.Values{}
164	v.Add("grant_type", "authorization_code")
165	v.Add("code", code)
166	v.Add("client_id", result["client_id"])
167	v.Add("client_secret", result["client_secret"])
168	v.Add("redirect_uri", "urn:ietf:wg:oauth:2.0:oob")
169	v.Add("scope", "write:statuses")
170	resp, err = http.PostForm("https://"+*flagInstance+"/oauth/token", v)
171
172	if err != nil {
173		fmt.Println(err)
174		os.Exit(1)
175	}
176	defer resp.Body.Close()
177
178	data, err = io.ReadAll(resp.Body)
179	if err != nil {
180		fmt.Println(err)
181		os.Exit(1)
182	}
183
184	var response map[string]string
185	err = json.Unmarshal(data, &response)
186
187	fmt.Println("Run the following command to set your token for this session:")
188	fmt.Println("	export RSS2FEDI_TOKEN=" + response["access_token"] + "")
189	fmt.Println("If using systemd, you can add the following to your service file in the [Service] section:")
190	fmt.Println("	Environment='RSS2FEDI_TOKEN=" + response["access_token"] + "'\n")
191	fmt.Println("You can now run RSS2Fedi without the authenticate flag! :)")
192
193	os.Exit(0)
194}
195
196func validateFlags() {
197	if *flagAuthenticate {
198		authenticate()
199	}
200	// Ensure the token is set
201	if os.Getenv("RSS2FEDI_TOKEN") == "" {
202		fmt.Println("No token set! Please run with the -a/--authenticate flag to obtain one.")
203		os.Exit(1)
204	}
205	if *flagFeed == "" {
206		flag.Usage()
207		panic("No feed URL specified")
208	}
209	if *flagTemplate == "" {
210		flag.Usage()
211		panic("No template file specified")
212	}
213	if *flagVisibility == "" {
214		flag.Usage()
215		panic("No visibility specified")
216	}
217	if *flagInstance == "" {
218		flag.Usage()
219		panic("No instance specified")
220	}
221	if *flagVisibility != "public" && *flagVisibility != "unlisted" && *flagVisibility != "private" && *flagVisibility != "direct" {
222		flag.Usage()
223		panic("Invalid visibility specified")
224	}
225}