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}