/v1/add sunshine 🐳
[pin4sha_cgi.git] / pinboard.go
blob134fcbf078b5e96e4e65ccddf7521bd756558e0a
1 //
2 // Copyright (C) 2019-2019 Marcus Rohrmoser, https://code.mro.name/mro/Shaarli-API-test
3 //
4 // This program is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // This program is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 // GNU General Public License for more details.
14 // You should have received a copy of the GNU General Public License
15 // along with this program. If not, see <http://www.gnu.org/licenses/>.
18 package main
20 import (
21 "encoding/xml"
22 "io"
23 "log"
24 "net/http"
25 "net/http/cgi"
26 "net/http/cookiejar"
27 "net/url"
28 "os"
29 "strings"
30 "time"
32 "github.com/yhat/scrape"
33 "golang.org/x/net/html"
34 "golang.org/x/net/html/atom"
35 // "golang.org/x/net/html/charset"
36 "golang.org/x/net/publicsuffix"
39 var GitSHA1 = "Please set -ldflags \"-X main.GitSHA1=$(git rev-parse --short HEAD)\"" // https://medium.com/@joshroppo/setting-go-1-5-variables-at-compile-time-for-versioning-5b30a965d33e
41 // even cooler: https://stackoverflow.com/a/8363629
43 // inspired by // https://coderwall.com/p/cp5fya/measuring-execution-time-in-go
44 func trace(name string) (string, time.Time) { return name, time.Now() }
45 func un(name string, start time.Time) { log.Printf("%s took %s", name, time.Since(start)) }
47 func main() {
48 if true {
49 // lighttpd doesn't seem to like more than one (per-vhost) server.breakagelog
50 log.SetOutput(os.Stderr)
51 } else { // log to custom logfile rather than stderr (may not be reachable on shared hosting)
54 if err := cgi.Serve(http.HandlerFunc(handleMux)); err != nil {
55 log.Fatal(err)
59 // https://pinboard.in/api
60 func handleMux(w http.ResponseWriter, r *http.Request) {
61 defer un(trace(strings.Join([]string{"v", version, "+", GitSHA1, " ", r.RemoteAddr, " ", r.Method, " ", r.URL.String()}, "")))
62 // w.Header().Set("Server", strings.Join([]string{myselfNamespace, CurrentShaarliGoVersion}, "#"))
63 w.Header().Set("X-Powered-By", strings.Join([]string{"https://code.mro.name/mro/Shaarli-API-test", "#", version, "+", GitSHA1}, ""))
64 now := time.Now()
66 path_info := os.Getenv("PATH_INFO")
67 base := *r.URL
68 base.Path = base.Path[0:len(base.Path)-len(path_info)] + "/../index.php"
69 // script_name := os.Getenv("SCRIPT_NAME")
70 // urlBase := mustParseURL(string(xmlBaseFromRequestURL(r.URL, os.Getenv("SCRIPT_NAME"))))
71 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
73 // https://stackoverflow.com/a/18414432
74 options := cookiejar.Options{
75 PublicSuffixList: publicsuffix.List,
77 jar, err := cookiejar.New(&options)
78 if err != nil {
79 http.Error(w, err.Error(), http.StatusInternalServerError)
80 return
82 client := http.Client{Jar: jar}
84 switch path_info {
85 case "/v1/info":
86 io.WriteString(w, "r.URL: "+r.URL.String()+"\n")
87 io.WriteString(w, "base: "+base.String()+"\n")
89 return
90 case "/v1/posts/add":
91 // extract parameters
92 // agent := r.Header.Get("User-Agent")
93 shared := true
95 uid, pwd, ok := r.BasicAuth()
96 if !ok {
97 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
98 return
101 if "GET" != r.Method {
102 w.Header().Set("Allow", "GET")
103 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
104 return
107 params := r.URL.Query()
108 if 1 != len(params["url"]) {
109 http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
110 return
112 p_url := params["url"][0]
114 if 1 != len(params["description"]) {
115 http.Error(w, "Required parameter missing: description", http.StatusBadRequest)
116 return
118 p_description := params["description"][0]
120 p_extended := ""
121 if 1 == len(params["extended"]) {
122 p_extended = params["extended"][0]
125 p_tags := ""
126 if 1 == len(params["tags"]) {
127 p_tags = params["tags"][0]
130 v := url.Values{}
131 v.Set("post", p_url)
132 base.RawQuery = v.Encode()
134 resp, err := client.Get(base.String())
135 if err != nil {
136 http.Error(w, err.Error(), http.StatusBadGateway)
137 return
139 formLogi, err := formValuesFromReader(resp.Body, "loginform")
140 resp.Body.Close()
141 if err != nil {
142 http.Error(w, err.Error(), http.StatusInternalServerError)
143 return
146 formLogi.Set("login", uid)
147 formLogi.Set("password", pwd)
148 resp, err = client.PostForm(resp.Request.URL.String(), formLogi)
149 if err != nil {
150 http.Error(w, err.Error(), http.StatusBadGateway)
151 return
154 formLink, err := formValuesFromReader(resp.Body, "linkform")
155 resp.Body.Close()
156 if err != nil {
157 http.Error(w, err.Error(), http.StatusBadGateway)
158 return
160 // if we do not have a linkform, auth must have failed.
161 if 0 == len(formLink) {
162 http.Error(w, "Authentication failed", http.StatusForbidden)
163 return
166 // formLink.Set("lf_linkdate", "20190106_172531")
167 // formLink.Set("lf_url", p_url)
168 formLink.Set("lf_title", p_description)
169 formLink.Set("lf_description", p_extended)
170 formLink.Set("lf_tags", p_tags)
171 if shared {
172 formLink.Del("lf_private")
173 } else {
174 formLink.Set("lf_private", "lf_private")
177 resp, err = client.PostForm(resp.Request.URL.String(), formLink)
178 if err != nil {
179 http.Error(w, err.Error(), http.StatusBadGateway)
180 return
182 resp.Body.Close()
184 w.Header().Set("Content-Type", "text/xml; charset=utf-8")
185 io.WriteString(w, "<?xml version='1.0' encoding='UTF-8'?><result code='done' />")
187 return
188 case "/v1/posts/delete":
189 _, _, ok := r.BasicAuth()
190 if !ok {
191 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
192 return
195 if "GET" != r.Method {
196 w.Header().Set("Allow", "GET")
197 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
198 return
201 params := r.URL.Query()
202 if 1 != len(params["url"]) {
203 http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
204 return
206 // p_url := params["url"][0]
208 io.WriteString(w, "bhb")
209 return
210 case "/v1/posts/update":
211 _, _, ok := r.BasicAuth()
212 if !ok {
213 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
214 return
217 if "GET" != r.Method {
218 w.Header().Set("Allow", "GET")
219 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
220 return
223 w.Header().Set("Content-Type", "text/xml; charset=utf-8")
224 io.WriteString(w, "<?xml version='1.0' encoding='UTF-8' ?><update time='2011-03-24T19:02:07Z' />")
225 return
226 case "/v1/posts/get":
227 // pretend to add, but don't actually do it, but return the form preset values.
228 uid, pwd, ok := r.BasicAuth()
229 if !ok {
230 http.Error(w, "Basic Pre-Authentication required.", http.StatusUnauthorized)
231 return
234 if "GET" != r.Method {
235 w.Header().Set("Allow", "GET")
236 http.Error(w, "All API methods are GET requests, even when good REST habits suggest they should use a different verb.", http.StatusMethodNotAllowed)
237 return
240 params := r.URL.Query()
241 if 1 != len(params["url"]) {
242 http.Error(w, "Required parameter missing: url", http.StatusBadRequest)
243 return
245 p_url := params["url"][0]
248 if 1 != len(params["description"]) {
249 http.Error(w, "Required parameter missing: description", http.StatusBadRequest)
250 return
252 p_description := params["description"][0]
254 p_extended := ""
255 if 1 == len(params["extended"]) {
256 p_extended = params["extended"][0]
259 p_tags := ""
260 if 1 == len(params["tags"]) {
261 p_tags = params["tags"][0]
265 v := url.Values{}
266 v.Set("post", p_url)
267 base.RawQuery = v.Encode()
269 resp, err := client.Get(base.String())
270 if err != nil {
271 http.Error(w, err.Error(), http.StatusBadGateway)
272 return
274 formLogi, err := formValuesFromReader(resp.Body, "loginform")
275 resp.Body.Close()
276 if err != nil {
277 http.Error(w, err.Error(), http.StatusInternalServerError)
278 return
281 formLogi.Set("login", uid)
282 formLogi.Set("password", pwd)
283 resp, err = client.PostForm(resp.Request.URL.String(), formLogi)
284 if err != nil {
285 http.Error(w, err.Error(), http.StatusBadGateway)
286 return
289 formLink, err := formValuesFromReader(resp.Body, "linkform")
290 resp.Body.Close()
291 if err != nil {
292 http.Error(w, err.Error(), http.StatusBadGateway)
293 return
295 // if we do not have a linkform, auth must have failed.
296 if 0 == len(formLink) {
297 http.Error(w, "Authentication failed", http.StatusForbidden)
298 return
301 tim, err := time.ParseInLocation("20060102_150405", formLink.Get("lf_linkdate"), time.Now().Location()) // can we do any better?
302 if err != nil {
303 http.Error(w, err.Error(), http.StatusBadGateway)
304 return
307 w.Header().Set("Content-Type", "text/xml; charset=utf-8")
309 rawText := func(s string) { io.WriteString(w, s) }
310 xmlText := func(s string) { xml.EscapeText(w, []byte(s)) }
311 xmlForm := func(s string) { xmlText(formLink.Get(s)) }
313 rawText("<?xml version='1.0' encoding='UTF-8' ?>")
314 rawText("<posts user='")
315 xmlText(uid)
316 rawText("' dt='")
317 xmlText(now.Format("2006-01-02"))
318 rawText("' tag=''>")
319 rawText("<post href='")
320 xmlForm("lf_url")
321 rawText("' hash='")
322 xmlForm("lf_linkdate")
323 rawText("' description='")
324 xmlForm("lf_title")
325 rawText("' extended='")
326 xmlForm("lf_description")
327 rawText("' tag='")
328 xmlForm("lf_tags")
329 rawText("' time='")
330 xmlText(tim.Format(time.RFC3339))
331 rawText("' others='")
332 xmlText("0")
333 rawText("' />")
334 rawText("</posts>")
336 return
337 case "/v1/posts/recent":
338 case "/v1/posts/dates":
339 case "/v1/posts/suggest":
340 case "/v1/tags/get":
341 case "/v1/tags/delete":
342 case "/v1/tags/rename":
343 case "/v1/user/secret":
344 case "/v1/user/api_token":
345 case "/v1/notes/list":
346 case "/v1/notes/ID":
347 http.Error(w, "Not Implemented", http.StatusNotImplemented)
348 return
350 http.NotFound(w, r)
353 func formValuesFromReader(r io.Reader, name string) (ret url.Values, err error) {
354 root, err := html.Parse(r) // assumes r is UTF8
355 if err != nil {
356 return ret, err
359 for _, form := range scrape.FindAll(root, func(n *html.Node) bool { return atom.Form == n.DataAtom }) {
360 if name != scrape.Attr(form, "name") && name != scrape.Attr(form, "id") {
361 continue
363 ret := url.Values{}
364 for _, inp := range scrape.FindAll(form, func(n *html.Node) bool { return atom.Input == n.DataAtom || atom.Textarea == n.DataAtom }) {
365 n := scrape.Attr(inp, "name")
366 if n == "" {
367 n = scrape.Attr(inp, "id")
370 ty := scrape.Attr(inp, "type")
371 v := scrape.Attr(inp, "value")
372 if atom.Textarea == inp.DataAtom {
373 v = scrape.Text(inp)
374 } else if v == "" && ty == "checkbox" {
375 v = scrape.Attr(inp, "checked")
377 ret.Set(n, v)
379 return ret, err // return on first occurence
381 return ret, err